Commit da42f02a authored by Mahmoud Aglan's avatar Mahmoud Aglan

sportsUpdate

parent 78ab767e
......@@ -3,7 +3,20 @@
"allow": [
"Bash(wc -l /Users/mahmoudaglan/clubphp/app/Core/*.php)",
"Bash(grep -r \"icon\" /Users/mahmoudaglan/clubphp/app/Modules/*/bootstrap.php)",
"Bash(grep -h \"'icon'\" /Users/mahmoudaglan/clubphp/app/Modules/*/bootstrap.php)"
"Bash(grep -h \"'icon'\" /Users/mahmoudaglan/clubphp/app/Modules/*/bootstrap.php)",
"Bash(grep -A 30 \"CREATE TABLE \\\\`sports_members\\\\`\" /Users/mahmoudaglan/clubphp/database/schema.sql)",
"Bash(grep -A 20 \"CREATE TABLE \\\\`subscriptions\\\\`\" /Users/mahmoudaglan/clubphp/database/schema.sql)",
"Bash(grep -A 20 \"CREATE TABLE \\\\`payments\\\\`\" /Users/mahmoudaglan/clubphp/database/schema.sql)",
"Bash(grep -A 60 \"^CREATE TABLE \\\\`members\\\\`\" /Users/mahmoudaglan/clubphp/database/schema.sql)",
"Bash(ls -1 /Users/mahmoudaglan/clubphp/database/migrations/*.php)",
"Bash(ls -1 /Users/mahmoudaglan/clubphp/database/seeds/*.php)",
"Bash(ls -la /Users/mahmoudaglan/clubphp/database/migrations/Phase_17*)",
"Bash(ls -la /Users/mahmoudaglan/clubphp/database/seeds/Phase_17*)",
"Bash(ls /Users/mahmoudaglan/clubphp/database/seeds/Phase_17_004*)",
"Bash(ls /Users/mahmoudaglan/clubphp/database/seeds/Phase_22*)",
"Bash(ls database/migrations/Phase_1[7-9]_* database/migrations/Phase_2[0-3]_*)",
"Bash(ls database/seeds/Phase_1[7-9]_* database/seeds/Phase_2[0-2]_*)",
"Bash(ls cron/jobs/*Job.php)"
]
}
}
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\Academies\Models;
use App\Core\Model;
class Academy extends Model
{
protected static string $table = 'academies';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'code',
'name_ar',
'name_en',
'discipline_id',
'academy_type',
'description_ar',
'config_json',
'sort_order',
'is_active',
];
/**
* Decode config_json into an associative array.
*/
public function getConfig(): array
{
$raw = $this->config_json;
if (empty($raw)) {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Get all academy types with Arabic labels.
*/
public static function getAcademyTypes(): array
{
return [
'football' => 'كرة قدم',
'gymnastics' => 'جمباز',
'swimming' => 'سباحة',
'combat' => 'فنون قتالية',
'racket' => 'راكيت',
'general' => 'عام',
];
}
/**
* Get the Arabic label for an academy type.
*/
public static function getTypeLabel(string $type): string
{
$types = self::getAcademyTypes();
return $types[$type] ?? $type;
}
/**
* Get the badge color for an academy type.
*/
public static function getTypeColor(string $type): string
{
$colors = [
'football' => '#0D7377',
'gymnastics' => '#7C3AED',
'swimming' => '#2563EB',
'combat' => '#DC2626',
'racket' => '#D97706',
'general' => '#059669',
];
return $colors[$type] ?? '#6B7280';
}
/**
* Get all active academies ordered by sort_order, name_ar.
*/
public static function allActive(): array
{
return static::query()
->where('is_active', '=', 1)
->orderBy('sort_order', 'ASC')
->orderBy('name_ar', 'ASC')
->get();
}
/**
* Get active academies filtered by discipline.
*/
public static function getByDiscipline(int $disciplineId): array
{
return static::query()
->where('is_active', '=', 1)
->where('discipline_id', '=', $disciplineId)
->orderBy('sort_order', 'ASC')
->orderBy('name_ar', 'ASC')
->get();
}
/**
* Get active academies filtered by academy_type.
*/
public static function getByType(string $type): array
{
return static::query()
->where('is_active', '=', 1)
->where('academy_type', '=', $type)
->orderBy('sort_order', 'ASC')
->orderBy('name_ar', 'ASC')
->get();
}
/**
* Search academies with filters and pagination.
*/
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$query = static::query();
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$query = $query->whereRaw(
'(`name_ar` LIKE ? OR `name_en` LIKE ? OR `code` LIKE ?)',
[$search, $search, $search]
);
}
if (!empty($filters['academy_type'])) {
$query = $query->where('academy_type', '=', $filters['academy_type']);
}
if (!empty($filters['discipline_id'])) {
$query = $query->where('discipline_id', '=', (int) $filters['discipline_id']);
}
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query = $query->where('is_active', '=', (int) $filters['is_active']);
}
$query = $query->orderBy('sort_order', 'ASC')->orderBy('name_ar', 'ASC');
return $query->paginate($perPage, $page);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Academies\Models;
use App\Core\Model;
class AcademyLevel extends Model
{
protected static string $table = 'academy_levels';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'academy_id',
'code',
'name_ar',
'name_en',
'level_order',
'age_min',
'age_max',
'max_capacity',
'config_json',
'is_active',
];
/**
* Decode config_json into an associative array.
*/
public function getConfig(): array
{
$raw = $this->config_json;
if (empty($raw)) {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Get all levels for a given academy, ordered by level_order.
*/
public static function getForAcademy(int $academyId): array
{
return static::query()
->where('academy_id', '=', $academyId)
->orderBy('level_order', 'ASC')
->get();
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Academies\Models;
use App\Core\Model;
class AcademySchedule extends Model
{
protected static string $table = 'academy_schedules';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'academy_id',
'level_id',
'facility_id',
'day_of_week',
'start_time',
'end_time',
'coach_name',
'season',
'is_active',
];
/**
* Get all schedules for a given academy, ordered by day_of_week then start_time.
*/
public static function getForAcademy(int $academyId): array
{
return static::query()
->where('academy_id', '=', $academyId)
->orderBy('day_of_week', 'ASC')
->orderBy('start_time', 'ASC')
->get();
}
/**
* Get all schedules for a given level.
*/
public static function getForLevel(int $levelId): array
{
return static::query()
->where('level_id', '=', $levelId)
->orderBy('day_of_week', 'ASC')
->orderBy('start_time', 'ASC')
->get();
}
/**
* Get day-of-week labels in Arabic (0=Sunday .. 6=Saturday).
*/
public static function getDayLabels(): array
{
return [
0 => 'الأحد',
1 => 'الاثنين',
2 => 'الثلاثاء',
3 => 'الأربعاء',
4 => 'الخميس',
5 => 'الجمعة',
6 => 'السبت',
];
}
}
<?php
declare(strict_types=1);
return [
['GET', '/academies', 'Academies\Controllers\AcademyController@index', ['auth'], 'academy.view'],
['GET', '/academies/create', 'Academies\Controllers\AcademyController@create', ['auth'], 'academy.manage'],
['POST', '/academies', 'Academies\Controllers\AcademyController@store', ['auth', 'csrf'], 'academy.manage'],
['GET', '/academies/{id:\d+}', 'Academies\Controllers\AcademyController@show', ['auth'], 'academy.view'],
['GET', '/academies/{id:\d+}/edit', 'Academies\Controllers\AcademyController@edit', ['auth'], 'academy.manage'],
['POST', '/academies/{id:\d+}', 'Academies\Controllers\AcademyController@update', ['auth', 'csrf'], 'academy.manage'],
['POST', '/academies/{id:\d+}/toggle', 'Academies\Controllers\AcademyController@toggle', ['auth', 'csrf'], 'academy.manage'],
['POST', '/academies/{id:\d+}/levels', 'Academies\Controllers\AcademyController@addLevel', ['auth', 'csrf'], 'academy.manage'],
['POST', '/academies/{id:\d+}/levels/remove', 'Academies\Controllers\AcademyController@removeLevel', ['auth', 'csrf'], 'academy.manage'],
['POST', '/academies/{id:\d+}/schedules', 'Academies\Controllers\AcademyController@addSchedule', ['auth', 'csrf'], 'academy.manage'],
['POST', '/academies/{id:\d+}/schedules/remove', 'Academies\Controllers\AcademyController@removeSchedule', ['auth', 'csrf'], 'academy.manage'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Academies\Services;
use App\Modules\Academies\Models\Academy;
use App\Modules\Academies\Models\AcademyLevel;
use App\Modules\Academies\Models\AcademySchedule;
class AcademyService
{
/**
* Get all active academies.
*/
public static function getActive(): array
{
return Academy::allActive();
}
/**
* Get active academies filtered by discipline.
*/
public static function getAcademiesForDiscipline(int $disciplineId): array
{
return Academy::getByDiscipline($disciplineId);
}
/**
* Get levels for a given academy.
*/
public static function getLevels(int $academyId): array
{
return AcademyLevel::getForAcademy($academyId);
}
/**
* Get schedules for a given academy.
*/
public static function getSchedule(int $academyId): array
{
return AcademySchedule::getForAcademy($academyId);
}
/**
* Check if an age is eligible for any level within an academy.
*/
public static function checkAgeEligibility(int $academyId, int $age): bool
{
$levels = AcademyLevel::getForAcademy($academyId);
if (empty($levels)) {
// No levels defined — allow all ages
return true;
}
foreach ($levels as $level) {
$minAge = (int) ($level['age_min'] ?? 0);
$maxAge = (int) ($level['age_max'] ?? 999);
if ($age >= $minAge && $age <= $maxAge) {
return true;
}
}
return false;
}
/**
* Get all active academies grouped by academy_type.
*/
public static function getGroupedByType(): array
{
$academies = Academy::allActive();
$grouped = [];
foreach (Academy::getAcademyTypes() as $key => $label) {
$grouped[$key] = [];
}
foreach ($academies as $a) {
$type = $a['academy_type'] ?? 'general';
if (!isset($grouped[$type])) {
$grouped[$type] = [];
}
$grouped[$type][] = $a;
}
return $grouped;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Academies\Services;
class EnrollmentService
{
/**
* Enroll a player in an academy level.
* Placeholder — full logic in PlayerAffairs Phase 19.
*/
public static function enroll(int $playerId, int $academyId, int $levelId, ?int $scheduleId = null): bool
{
throw new \RuntimeException('Enrollment features require the PlayerAffairs module.');
}
/**
* Promote an enrollment to a new level.
* Placeholder — full logic in PlayerAffairs Phase 19.
*/
public static function promote(int $enrollmentId, int $newLevelId): bool
{
throw new \RuntimeException('Enrollment features require the PlayerAffairs module.');
}
/**
* Suspend an enrollment.
* Placeholder — full logic in PlayerAffairs Phase 19.
*/
public static function suspend(int $enrollmentId): bool
{
throw new \RuntimeException('Enrollment features require the PlayerAffairs module.');
}
/**
* Drop an enrollment.
* Placeholder — full logic in PlayerAffairs Phase 19.
*/
public static function drop(int $enrollmentId): bool
{
throw new \RuntimeException('Enrollment features require the PlayerAffairs module.');
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إضافة أكاديمية جديدة<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/academies" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العودة للقائمة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/academies" id="academyForm">
<?= csrf_field() ?>
<!-- Basic Information -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="file-text" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">البيانات الأساسية</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="form-group">
<label class="form-label">الاسم بالعربي <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_ar" value="<?= e(old('name_ar')) ?>" class="form-input" required placeholder="مثال: أكاديمية كرة القدم">
</div>
<div class="form-group">
<label class="form-label">الاسم بالإنجليزي</label>
<input type="text" name="name_en" value="<?= e(old('name_en')) ?>" class="form-input" id="nameEn" placeholder="e.g. Football Academy" style="direction:ltr;text-align:left;">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;margin-top:15px;">
<div class="form-group">
<label class="form-label">كود الأكاديمية <span style="color:#DC2626;">*</span></label>
<input type="text" name="code" value="<?= e(old('code')) ?>" class="form-input" id="codeInput" required placeholder="مثال: ACAD_FOOTBALL" style="direction:ltr;text-align:left;text-transform:uppercase;">
<small style="color:#9CA3AF;font-size:11px;">يتم توليده تلقائيا من الاسم الإنجليزي</small>
</div>
<div class="form-group">
<label class="form-label">النشاط الرياضي <span style="color:#DC2626;">*</span></label>
<select name="discipline_id" class="form-select" required>
<option value="">-- اختر النشاط --</option>
<?php foreach ($disciplines as $disc): ?>
<option value="<?= (int) $disc['id'] ?>" <?= old('discipline_id') == $disc['id'] ? 'selected' : '' ?>><?= e($disc['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">نوع الأكاديمية <span style="color:#DC2626;">*</span></label>
<select name="academy_type" class="form-select" required>
<option value="">-- اختر النوع --</option>
<?php foreach ($academyTypes as $key => $label): ?>
<option value="<?= e($key) ?>" <?= old('academy_type') === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:3fr 1fr;gap:20px;margin-top:15px;">
<div class="form-group">
<label class="form-label">وصف الأكاديمية</label>
<textarea name="description_ar" class="form-input" rows="3" placeholder="وصف مختصر للأكاديمية..."><?= e(old('description_ar')) ?></textarea>
</div>
<div class="form-group">
<label class="form-label">ترتيب العرض</label>
<input type="number" name="sort_order" value="<?= e(old('sort_order', '0')) ?>" class="form-input" min="0" style="direction:ltr;text-align:left;">
</div>
</div>
</div>
</div>
<!-- Config Card -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="settings" style="width:18px;height:18px;color:#D97706;"></i>
<h3 style="margin:0;color:#D97706;font-size:15px;">الإعدادات</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;">
<div class="form-group">
<label class="form-label">الحد الأدنى للسن</label>
<input type="number" name="age_min" value="<?= e(old('age_min')) ?>" class="form-input" min="0" max="99" placeholder="مثال: 5" style="direction:ltr;text-align:left;">
</div>
<div class="form-group">
<label class="form-label">الحد الأقصى للسن</label>
<input type="number" name="age_max" value="<?= e(old('age_max')) ?>" class="form-input" min="0" max="99" placeholder="مثال: 18" style="direction:ltr;text-align:left;">
</div>
<div class="form-group">
<label class="form-label">السعة الاستيعابية</label>
<input type="number" name="capacity" value="<?= e(old('capacity')) ?>" class="form-input" min="1" placeholder="مثال: 100" style="direction:ltr;text-align:left;">
</div>
</div>
</div>
</div>
<!-- Submit Buttons -->
<div style="display:flex;gap:10px;justify-content:flex-start;">
<button type="submit" class="btn btn-primary" style="padding:12px 30px;font-size:15px;">
<i data-lucide="check" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> حفظ الأكاديمية
</button>
<a href="/academies" class="btn btn-outline" style="padding:12px 30px;font-size:15px;">إلغاء</a>
</div>
</form>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
// Auto-generate code from English name
var nameEnInput = document.getElementById('nameEn');
var codeInput = document.getElementById('codeInput');
if (nameEnInput && codeInput) {
nameEnInput.addEventListener('input', function() {
if (codeInput.dataset.manuallyEdited !== 'true') {
codeInput.value = this.value
.toUpperCase()
.replace(/[^A-Z0-9\s]/g, '')
.replace(/\s+/g, '_')
.substring(0, 30);
}
});
codeInput.addEventListener('input', function() {
this.dataset.manuallyEdited = 'true';
});
}
});
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تعديل: <?= e($academy->name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/academies/<?= (int) $academy->id ?>" class="btn btn-outline"><i data-lucide="eye" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> عرض</a>
<a href="/academies" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العودة للقائمة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$ageMin = $config['age_min'] ?? '';
$ageMax = $config['age_max'] ?? '';
$capacity = $config['capacity'] ?? '';
?>
<form method="POST" action="/academies/<?= (int) $academy->id ?>" id="academyForm">
<?= csrf_field() ?>
<!-- Basic Information -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="file-text" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">البيانات الأساسية</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="form-group">
<label class="form-label">الاسم بالعربي <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_ar" value="<?= e(old('name_ar') ?: $academy->name_ar) ?>" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">الاسم بالإنجليزي</label>
<input type="text" name="name_en" value="<?= e(old('name_en') ?: ($academy->name_en ?? '')) ?>" class="form-input" id="nameEn" style="direction:ltr;text-align:left;">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;margin-top:15px;">
<div class="form-group">
<label class="form-label">كود الأكاديمية <span style="color:#DC2626;">*</span></label>
<input type="text" name="code" value="<?= e(old('code') ?: $academy->code) ?>" class="form-input" id="codeInput" required style="direction:ltr;text-align:left;text-transform:uppercase;">
</div>
<div class="form-group">
<label class="form-label">النشاط الرياضي <span style="color:#DC2626;">*</span></label>
<select name="discipline_id" class="form-select" required>
<option value="">-- اختر النشاط --</option>
<?php foreach ($disciplines as $disc): ?>
<option value="<?= (int) $disc['id'] ?>" <?= (old('discipline_id') ?: $academy->discipline_id) == $disc['id'] ? 'selected' : '' ?>><?= e($disc['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">نوع الأكاديمية <span style="color:#DC2626;">*</span></label>
<select name="academy_type" class="form-select" required>
<option value="">-- اختر النوع --</option>
<?php foreach ($academyTypes as $key => $label): ?>
<option value="<?= e($key) ?>" <?= (old('academy_type') ?: ($academy->academy_type ?? '')) === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:3fr 1fr;gap:20px;margin-top:15px;">
<div class="form-group">
<label class="form-label">وصف الأكاديمية</label>
<textarea name="description_ar" class="form-input" rows="3"><?= e(old('description_ar') ?: ($academy->description_ar ?? '')) ?></textarea>
</div>
<div class="form-group">
<label class="form-label">ترتيب العرض</label>
<input type="number" name="sort_order" value="<?= e(old('sort_order') ?: (string)($academy->sort_order ?? 0)) ?>" class="form-input" min="0" style="direction:ltr;text-align:left;">
</div>
</div>
</div>
</div>
<!-- Config Card -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="settings" style="width:18px;height:18px;color:#D97706;"></i>
<h3 style="margin:0;color:#D97706;font-size:15px;">الإعدادات</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;">
<div class="form-group">
<label class="form-label">الحد الأدنى للسن</label>
<input type="number" name="age_min" value="<?= e(old('age_min') ?: (string) $ageMin) ?>" class="form-input" min="0" max="99" placeholder="مثال: 5" style="direction:ltr;text-align:left;">
</div>
<div class="form-group">
<label class="form-label">الحد الأقصى للسن</label>
<input type="number" name="age_max" value="<?= e(old('age_max') ?: (string) $ageMax) ?>" class="form-input" min="0" max="99" placeholder="مثال: 18" style="direction:ltr;text-align:left;">
</div>
<div class="form-group">
<label class="form-label">السعة الاستيعابية</label>
<input type="number" name="capacity" value="<?= e(old('capacity') ?: (string) $capacity) ?>" class="form-input" min="1" placeholder="مثال: 100" style="direction:ltr;text-align:left;">
</div>
</div>
</div>
</div>
<!-- Submit Buttons -->
<div style="display:flex;gap:10px;justify-content:flex-start;">
<button type="submit" class="btn btn-primary" style="padding:12px 30px;font-size:15px;">
<i data-lucide="check" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> حفظ التعديلات
</button>
<a href="/academies/<?= (int) $academy->id ?>" class="btn btn-outline" style="padding:12px 30px;font-size:15px;">إلغاء</a>
</div>
</form>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});
</script>
<?php $__template->endSection(); ?>
<?php
use App\Modules\Academies\Models\Academy;
use App\Modules\Academies\Models\AcademyLevel;
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>الأكاديميات<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/academies/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إضافة أكاديمية</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$currentType = $filters['academy_type'] ?? '';
$allTypes = Academy::getAcademyTypes();
?>
<!-- Academy Type Filter Tabs -->
<div class="card" style="margin-bottom:20px;padding:0;">
<div style="display:flex;align-items:center;gap:0;overflow-x:auto;border-bottom:2px solid #E5E7EB;">
<a href="/academies<?= ($filters['discipline_id'] ?? '') !== '' ? '?discipline_id=' . urlencode($filters['discipline_id']) : '' ?>"
style="padding:12px 20px;font-size:14px;font-weight:600;text-decoration:none;border-bottom:3px solid <?= $currentType === '' ? '#0D7377' : 'transparent' ?>;color:<?= $currentType === '' ? '#0D7377' : '#6B7280' ?>;white-space:nowrap;">
الكل
</a>
<?php foreach ($allTypes as $typeKey => $typeLabel): ?>
<a href="/academies?academy_type=<?= e($typeKey) ?><?= ($filters['q'] ?? '') !== '' ? '&q=' . urlencode($filters['q']) : '' ?><?= ($filters['discipline_id'] ?? '') !== '' ? '&discipline_id=' . urlencode($filters['discipline_id']) : '' ?>"
style="padding:12px 20px;font-size:14px;font-weight:600;text-decoration:none;border-bottom:3px solid <?= $currentType === $typeKey ? '#0D7377' : 'transparent' ?>;color:<?= $currentType === $typeKey ? '#0D7377' : '#6B7280' ?>;white-space:nowrap;">
<?= e($typeLabel) ?>
</a>
<?php endforeach; ?>
</div>
</div>
<!-- Search Bar -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/academies" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="flex:1;min-width:200px;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="q" value="<?= e($filters['q'] ?? '') ?>" placeholder="ابحث بالاسم أو الكود..." class="form-input" style="min-width:200px;">
</div>
<div style="min-width:180px;">
<label class="form-label" style="font-size:12px;">النشاط الرياضي</label>
<select name="discipline_id" class="form-input">
<option value="">الكل</option>
<?php foreach ($disciplines as $disc): ?>
<option value="<?= (int) $disc['id'] ?>" <?= ($filters['discipline_id'] ?? '') == $disc['id'] ? 'selected' : '' ?>><?= e($disc['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php if ($currentType !== ''): ?>
<input type="hidden" name="academy_type" value="<?= e($currentType) ?>">
<?php endif; ?>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> بحث</button>
<a href="/academies" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<!-- Academies Grid -->
<?php if (!empty($academies)): ?>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(320px, 1fr));gap:20px;margin-bottom:20px;">
<?php foreach ($academies as $a):
$typeColor = Academy::getTypeColor($a['academy_type'] ?? '');
$typeLabel = Academy::getTypeLabel($a['academy_type'] ?? '');
$isActive = (int) ($a['is_active'] ?? 0);
$levels = AcademyLevel::getForAcademy((int) $a['id']);
$levelCount = count($levels);
$descExcerpt = mb_substr($a['description_ar'] ?? '', 0, 80);
if (mb_strlen($a['description_ar'] ?? '') > 80) {
$descExcerpt .= '...';
}
// Find discipline name from the passed disciplines array
$discName = '';
foreach ($disciplines as $disc) {
if ((int) $disc['id'] === (int) ($a['discipline_id'] ?? 0)) {
$discName = $disc['name_ar'];
break;
}
}
?>
<div class="card" style="transition:box-shadow 0.2s;position:relative;overflow:hidden;<?= !$isActive ? 'opacity:0.65;' : '' ?>">
<?php if (!$isActive): ?>
<div style="position:absolute;top:12px;left:12px;z-index:2;">
<span class="badge" style="background:#FEE2E2;color:#DC2626;font-size:11px;padding:3px 10px;border-radius:10px;">معطّل</span>
</div>
<?php endif; ?>
<a href="/academies/<?= (int) $a['id'] ?>" style="text-decoration:none;color:inherit;display:block;">
<!-- Card Header -->
<div style="padding:20px 20px 15px;display:flex;align-items:start;gap:15px;">
<div style="width:52px;height:52px;border-radius:12px;background:linear-gradient(135deg, <?= $typeColor ?>15, <?= $typeColor ?>30);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i data-lucide="graduation-cap" style="width:26px;height:26px;color:<?= $typeColor ?>;"></i>
</div>
<div style="flex:1;min-width:0;">
<h3 style="margin:0 0 4px;font-size:16px;font-weight:700;color:#1A1A2E;"><?= e($a['name_ar']) ?></h3>
<?php if (!empty($a['name_en'])): ?>
<div style="font-size:12px;color:#9CA3AF;margin-bottom:6px;"><?= e($a['name_en']) ?></div>
<?php endif; ?>
<span style="display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600;background:<?= $typeColor ?>15;color:<?= $typeColor ?>;"><?= e($typeLabel) ?></span>
</div>
</div>
<!-- Card Body Stats -->
<div style="padding:0 20px 10px;display:flex;gap:15px;font-size:12px;color:#6B7280;">
<?php if ($discName !== ''): ?>
<span style="display:flex;align-items:center;gap:4px;">
<i data-lucide="activity" style="width:14px;height:14px;"></i>
<?= e($discName) ?>
</span>
<?php endif; ?>
<span style="display:flex;align-items:center;gap:4px;">
<i data-lucide="layers" style="width:14px;height:14px;"></i>
<?= $levelCount ?> مستوى
</span>
</div>
<!-- Description Excerpt -->
<?php if (!empty($descExcerpt)): ?>
<div style="padding:0 20px 15px;font-size:13px;color:#6B7280;line-height:1.6;">
<?= e($descExcerpt) ?>
</div>
<?php endif; ?>
</a>
<!-- Card Footer -->
<div style="padding:10px 20px;border-top:1px solid #F3F4F6;display:flex;justify-content:space-between;align-items:center;">
<code style="font-size:11px;color:#9CA3AF;background:#F9FAFB;padding:2px 6px;border-radius:4px;"><?= e($a['code'] ?? '') ?></code>
<div style="display:flex;gap:6px;">
<a href="/academies/<?= (int) $a['id'] ?>" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="eye" style="width:13px;height:13px;vertical-align:middle;"></i> عرض
</a>
<a href="/academies/<?= (int) $a['id'] ?>/edit" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="edit-3" style="width:13px;height:13px;vertical-align:middle;"></i> تعديل
</a>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if (isset($pagination) && ($pagination['last_page'] ?? 1) > 1): ?>
<div style="padding:15px;">
<?php $__template->include('Shared.Components.pagination', ['pagination' => $pagination]); ?>
</div>
<?php endif; ?>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="graduation-cap" style="width:48px;height:48px;color:#D1D5DB;"></i>
</div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا توجد أكاديميات</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0 0 20px;">
<?php if (!empty($filters['q']) || !empty($filters['academy_type']) || !empty($filters['discipline_id'])): ?>
لا توجد نتائج مطابقة لبحثك. جرب تغيير معايير البحث.
<?php else: ?>
ابدأ بإضافة أكاديمية جديدة للنادي.
<?php endif; ?>
</p>
<a href="/academies/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إضافة أكاديمية</a>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});
</script>
<?php $__template->endSection(); ?>
This diff is collapsed.
<?php
declare(strict_types=1);
use App\Core\Registries\PermissionRegistry;
// ────────────────────────────────────────────────────────────
// Academies — Permissions only (sidebar owned by Disciplines bootstrap.php)
// ────────────────────────────────────────────────────────────
PermissionRegistry::register('academies', [
'academy.view' => ['ar' => 'عرض الأكاديميات', 'en' => 'View Academies'],
'academy.manage' => ['ar' => 'إدارة الأكاديميات', 'en' => 'Manage Academies'],
'academy.enroll' => ['ar' => 'تسجيل في الأكاديمية', 'en' => 'Enroll in Academy'],
]);
<?php
declare(strict_types=1);
namespace App\Modules\ActivitySubscriptions\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\ActivitySubscriptions\Models\ActivitySubscription;
use App\Modules\ActivitySubscriptions\Models\ActivityPricing;
use App\Modules\ActivitySubscriptions\Services\ActivitySubscriptionService;
use App\Modules\ActivitySubscriptions\Services\ActivityPricingService;
use App\Modules\Payments\Services\PaymentService;
use App\Modules\Disciplines\Models\SportDiscipline;
class ActivitySubscriptionController extends Controller
{
/**
* List activity subscriptions with status tabs, month filter, search, and pagination.
*/
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->get('q', '')),
'status' => trim((string) $request->get('status', '')),
'subscription_month' => trim((string) $request->get('subscription_month', '')),
'discipline_id' => trim((string) $request->get('discipline_id', '')),
'player_type' => trim((string) $request->get('player_type', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = ActivitySubscription::search($filters, 25, $page);
return $this->view('ActivitySubscriptions.Views.index', [
'subscriptions' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'statuses' => ActivitySubscription::getStatuses(),
'disciplines' => SportDiscipline::allActive(),
]);
}
/**
* Show subscription detail with payment/exemption actions.
*/
public function show(Request $request, string $id): Response
{
$sub = ActivitySubscription::find((int) $id);
if (!$sub) {
return $this->redirect('/activity-subscriptions')->withError('الاشتراك غير موجود');
}
$db = App::getInstance()->db();
$player = $db->selectOne("SELECT * FROM `players` WHERE `id` = ?", [(int) $sub->player_id]);
$discipline = $sub->discipline_id ? SportDiscipline::find((int) $sub->discipline_id) : null;
return $this->view('ActivitySubscriptions.Views.show', [
'subscription' => $sub,
'player' => $player,
'discipline' => $discipline,
'statuses' => ActivitySubscription::getStatuses(),
]);
}
/**
* Process payment for a subscription.
*/
public function pay(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$sub = $db->selectOne(
"SELECT s.*, p.full_name_ar as player_name
FROM `activity_subscriptions` s
JOIN `players` p ON p.id = s.player_id
WHERE s.`id` = ?",
[(int) $id]
);
if (!$sub) {
return $this->redirect('/activity-subscriptions')->withError('الاشتراك غير موجود');
}
if ($sub['status'] === 'paid') {
return $this->redirect('/activity-subscriptions')->withError('الاشتراك مدفوع بالفعل');
}
$amount = (string) ($sub['total_amount'] ?? '0.00');
$data = $request->all();
$data['member_id'] = null;
$data['player_id'] = (int) $sub['player_id'];
$data['amount'] = $amount;
$data['payment_type'] = 'activity_subscription';
$data['payment_method'] = $data['payment_method'] ?? 'cash';
$data['related_entity_type'] = 'activity_subscriptions';
$data['related_entity_id'] = (int) $id;
$data['description'] = 'اشتراك نشاط ' . ($sub['subscription_month'] ?? '') . ' — ' . ($sub['player_name'] ?? '');
$result = PaymentService::processPayment($data);
if (!$result['success']) {
return $this->redirect('/activity-subscriptions/' . $id)->withError($result['error']);
}
ActivitySubscriptionService::paySubscription((int) $id, (int) $result['payment_id']);
return $this->redirect('/activity-subscriptions/' . $id)
->withSuccess('تم تسجيل الدفع بنجاح — إيصال: ' . $result['receipt_number']);
}
/**
* Grant an exemption on a subscription.
*/
public function exempt(Request $request, string $id): Response
{
$sub = ActivitySubscription::find((int) $id);
if (!$sub) {
return $this->redirect('/activity-subscriptions')->withError('الاشتراك غير موجود');
}
$reason = trim((string) $request->post('exemption_reason', ''));
if ($reason === '') {
return $this->redirect('/activity-subscriptions/' . $id)->withError('يجب إدخال سبب الإعفاء');
}
$employee = App::getInstance()->currentEmployee();
$exemptedBy = $employee ? (int) ($employee->id ?? ($employee['id'] ?? 0)) : 0;
ActivitySubscriptionService::grantExemption((int) $id, $exemptedBy, $reason);
return $this->redirect('/activity-subscriptions/' . $id)->withSuccess('تم إعفاء الاشتراك بنجاح');
}
/**
* Generate monthly subscriptions for a specified month.
*/
public function generate(Request $request): Response
{
$month = trim((string) $request->post('month', ''));
if ($month === '' || !preg_match('/^\d{4}-\d{2}$/', $month)) {
return $this->redirect('/activity-subscriptions')->withError('يجب تحديد شهر صحيح بصيغة YYYY-MM');
}
$count = ActivitySubscriptionService::generateMonthlySubscriptions($month);
return $this->redirect('/activity-subscriptions?subscription_month=' . urlencode($month))
->withSuccess('تم توليد ' . $count . ' اشتراك لشهر ' . $month);
}
/**
* List all pricing records with edit capability.
*/
public function pricing(Request $request): Response
{
$filters = [
'pricing_type' => trim((string) $request->get('pricing_type', '')),
'is_active' => $request->get('is_active', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = ActivityPricing::search($filters, 25, $page);
return $this->view('ActivitySubscriptions.Views.pricing', [
'pricings' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'pricingTypes' => ActivityPricing::getPricingTypes(),
]);
}
/**
* Update pricing rates.
*/
public function updatePricing(Request $request): Response
{
$pricingType = trim((string) $request->post('pricing_type', ''));
$referenceId = (int) $request->post('reference_id', 0);
if (!array_key_exists($pricingType, ActivityPricing::getPricingTypes())) {
return $this->redirect('/activity-subscriptions/pricing')->withError('نوع التسعير غير صالح');
}
if ($referenceId <= 0) {
return $this->redirect('/activity-subscriptions/pricing')->withError('المرجع غير صالح');
}
$rates = [
'member_rate' => trim((string) $request->post('member_rate', '0')),
'nonmember_rate' => trim((string) $request->post('nonmember_rate', '0')),
'member_rate_pm' => trim((string) $request->post('member_rate_pm', '0')),
'nonmember_rate_pm' => trim((string) $request->post('nonmember_rate_pm', '0')),
'effective_from' => trim((string) $request->post('effective_from', date('Y-m-d'))),
'effective_to' => $request->post('effective_to') ?: null,
];
ActivityPricingService::setRate($pricingType, $referenceId, $rates);
return $this->redirect('/activity-subscriptions/pricing')->withSuccess('تم تحديث التسعير بنجاح');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\ActivitySubscriptions\Models;
use App\Core\Model;
use App\Core\App;
class ActivityPricing extends Model
{
protected static string $table = 'activity_pricing';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'pricing_type',
'reference_id',
'member_rate',
'nonmember_rate',
'member_rate_pm',
'nonmember_rate_pm',
'effective_from',
'effective_to',
'is_active',
'created_by',
];
/**
* Get all pricing types with Arabic labels.
*/
public static function getPricingTypes(): array
{
return [
'academy' => 'أكاديمية',
'discipline' => 'نشاط',
'facility' => 'مرفق',
];
}
/**
* Get the Arabic label for a pricing type key.
*/
public static function getPricingTypeLabel(string $type): string
{
$types = self::getPricingTypes();
return $types[$type] ?? $type;
}
/**
* Get the effective rate for a pricing type and reference.
*
* Finds the active pricing record where effective_from <= today
* and (effective_to IS NULL OR effective_to >= today), then returns
* the appropriate rate based on membership status and time tier.
*/
public static function getEffectiveRate(
string $type,
int $refId,
bool $isMember,
?string $timeTier = null
): float {
$today = date('Y-m-d');
$query = static::query()
->where('pricing_type', '=', $type)
->where('reference_id', '=', $refId)
->where('is_active', '=', 1)
->whereRaw('`effective_from` <= ?', [$today])
->whereRaw('(`effective_to` IS NULL OR `effective_to` >= ?)', [$today])
->orderBy('effective_from', 'DESC')
->limit(1);
$rows = $query->get();
$row = $rows[0] ?? null;
if (!$row) {
return 0.0;
}
if ($timeTier === 'PM') {
return (float) ($isMember ? $row['member_rate_pm'] : $row['nonmember_rate_pm']);
}
return (float) ($isMember ? $row['member_rate'] : $row['nonmember_rate']);
}
/**
* Search pricing records with filters and pagination.
*/
public static function search(array $filters = [], int $perPage = 25, int $page = 1): array
{
$query = static::query();
if (!empty($filters['pricing_type'])) {
$query = $query->where('pricing_type', '=', $filters['pricing_type']);
}
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query = $query->where('is_active', '=', (int) $filters['is_active']);
}
$query = $query->orderBy('pricing_type', 'ASC')->orderBy('reference_id', 'ASC');
return $query->paginate($perPage, $page);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\ActivitySubscriptions\Models;
use App\Core\Model;
use App\Core\App;
class ActivitySubscription extends Model
{
protected static string $table = 'activity_subscriptions';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'player_id',
'enrollment_id',
'discipline_id',
'subscription_month',
'player_type',
'base_rate',
'is_half_month',
'applied_rate',
'discount',
'total_amount',
'payment_id',
'status',
'due_date',
'paid_at',
'revoked_at',
'exempted_by',
'exemption_reason',
'notes',
'created_by',
];
/**
* Get all subscription statuses with Arabic labels.
*/
public static function getStatuses(): array
{
return [
'pending' => 'مستحق',
'paid' => 'مدفوع',
'overdue' => 'متأخر',
'exempted' => 'معفى',
'revoked' => 'ملغى',
];
}
/**
* Get the Arabic label for a status key.
*/
public static function getStatusLabel(string $status): string
{
$statuses = self::getStatuses();
return $statuses[$status] ?? $status;
}
/**
* Get the badge color for a status.
*/
public static function getStatusColor(string $status): string
{
$colors = [
'pending' => '#D97706',
'paid' => '#059669',
'overdue' => '#DC2626',
'exempted' => '#7C3AED',
'revoked' => '#6B7280',
];
return $colors[$status] ?? '#6B7280';
}
/**
* Search activity subscriptions with filters and pagination.
*/
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$query = static::query()
->leftJoin('players', '`activity_subscriptions`.`player_id` = `players`.`id`')
->leftJoin('sport_disciplines', '`activity_subscriptions`.`discipline_id` = `sport_disciplines`.`id`')
->select('`activity_subscriptions`.*, `players`.`full_name_ar` as player_name, `sport_disciplines`.`name_ar` as discipline_name');
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$query = $query->whereRaw(
'(`players`.`full_name_ar` LIKE ? OR `players`.`code` LIKE ?)',
[$search, $search]
);
}
if (!empty($filters['status'])) {
$query = $query->where('status', '=', $filters['status']);
}
if (!empty($filters['subscription_month'])) {
$query = $query->where('subscription_month', '=', $filters['subscription_month']);
}
if (!empty($filters['discipline_id'])) {
$query = $query->where('discipline_id', '=', (int) $filters['discipline_id']);
}
if (!empty($filters['player_type'])) {
$query = $query->where('player_type', '=', $filters['player_type']);
}
$query = $query->orderBy('subscription_month', 'DESC')->orderBy('player_name', 'ASC');
return $query->paginate($perPage, $page);
}
/**
* Get all subscriptions for a specific player, ordered by month descending.
*/
public static function getForPlayer(int $playerId): array
{
return static::query()
->leftJoin('sport_disciplines', '`activity_subscriptions`.`discipline_id` = `sport_disciplines`.`id`')
->select('`activity_subscriptions`.*, `sport_disciplines`.`name_ar` as discipline_name')
->where('player_id', '=', $playerId)
->orderBy('subscription_month', 'DESC')
->get();
}
}
<?php
declare(strict_types=1);
return [
['GET', '/activity-subscriptions', 'ActivitySubscriptions\Controllers\ActivitySubscriptionController@index', ['auth'], 'activity_sub.view'],
['GET', '/activity-subscriptions/pricing', 'ActivitySubscriptions\Controllers\ActivitySubscriptionController@pricing', ['auth'], 'activity_sub.manage_pricing'],
['POST', '/activity-subscriptions/pricing', 'ActivitySubscriptions\Controllers\ActivitySubscriptionController@updatePricing', ['auth', 'csrf'], 'activity_sub.manage_pricing'],
['POST', '/activity-subscriptions/generate', 'ActivitySubscriptions\Controllers\ActivitySubscriptionController@generate', ['auth', 'csrf'], 'activity_sub.generate'],
['GET', '/activity-subscriptions/{id:\d+}', 'ActivitySubscriptions\Controllers\ActivitySubscriptionController@show', ['auth'], 'activity_sub.view'],
['POST', '/activity-subscriptions/{id:\d+}/pay', 'ActivitySubscriptions\Controllers\ActivitySubscriptionController@pay', ['auth', 'csrf'], 'activity_sub.collect'],
['POST', '/activity-subscriptions/{id:\d+}/exempt', 'ActivitySubscriptions\Controllers\ActivitySubscriptionController@exempt', ['auth', 'csrf'], 'activity_sub.exempt'],
];
<?php
declare(strict_types=1);
namespace App\Modules\ActivitySubscriptions\Services;
use App\Core\App;
use App\Modules\ActivitySubscriptions\Models\ActivityPricing;
class ActivityPricingService
{
/**
* Get the rate pair (member / nonmember) for a pricing type and reference.
*
* @return array{member_rate: string, nonmember_rate: string}
*/
public static function getRate(string $pricingType, int $referenceId): array
{
$today = date('Y-m-d');
$row = ActivityPricing::query()
->where('pricing_type', '=', $pricingType)
->where('reference_id', '=', $referenceId)
->where('is_active', '=', 1)
->whereRaw('`effective_from` <= ?', [$today])
->whereRaw('(`effective_to` IS NULL OR `effective_to` >= ?)', [$today])
->orderBy('effective_from', 'DESC')
->first();
if (!$row) {
return ['member_rate' => '0.00', 'nonmember_rate' => '0.00'];
}
return [
'member_rate' => (string) ($row['member_rate'] ?? '0.00'),
'nonmember_rate' => (string) ($row['nonmember_rate'] ?? '0.00'),
];
}
/**
* Get the effective rate for a specific pricing type, reference, membership, and time tier.
*/
public static function getEffectiveRate(
string $pricingType,
int $referenceId,
bool $isMember,
?string $timeTier = 'AM'
): float {
return ActivityPricing::getEffectiveRate($pricingType, $referenceId, $isMember, $timeTier);
}
/**
* Upsert a pricing record.
*
* If an active record exists for the same type + reference, deactivates it
* and creates a new one.
*
* @param array $rates Keys: member_rate, nonmember_rate, member_rate_pm, nonmember_rate_pm, effective_from, effective_to
*/
public static function setRate(string $pricingType, int $referenceId, array $rates): object
{
$db = App::getInstance()->db();
// Deactivate any current active pricing for this type + reference
$db->query(
"UPDATE `activity_pricing` SET `is_active` = 0, `updated_at` = ? WHERE `pricing_type` = ? AND `reference_id` = ? AND `is_active` = 1",
[date('Y-m-d H:i:s'), $pricingType, $referenceId]
);
$employee = App::getInstance()->currentEmployee();
return ActivityPricing::create([
'pricing_type' => $pricingType,
'reference_id' => $referenceId,
'member_rate' => $rates['member_rate'] ?? '0.00',
'nonmember_rate' => $rates['nonmember_rate'] ?? '0.00',
'member_rate_pm' => $rates['member_rate_pm'] ?? '0.00',
'nonmember_rate_pm' => $rates['nonmember_rate_pm'] ?? '0.00',
'effective_from' => $rates['effective_from'] ?? date('Y-m-d'),
'effective_to' => $rates['effective_to'] ?? null,
'is_active' => 1,
'created_by' => $employee ? (int) ($employee->id ?? ($employee['id'] ?? null)) : null,
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\ActivitySubscriptions\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\ActivitySubscriptions\Models\ActivitySubscription;
use App\Modules\ActivitySubscriptions\Models\ActivityPricing;
class ActivitySubscriptionService
{
/**
* Generate monthly subscriptions for all active enrollments in the given month.
*
* For each active enrollment, creates a subscription record. If the enrollment
* started after the 15th of its first month, marks it as half-month with half rate.
*
* @param string $month Format YYYY-MM
* @return int Number of subscriptions generated
*/
public static function generateMonthlySubscriptions(string $month): int
{
$db = App::getInstance()->db();
$count = 0;
// Get all active enrollments
$enrollments = $db->select(
"SELECT e.*, p.player_type, a.discipline_id
FROM `enrollments` e
JOIN `players` p ON p.id = e.player_id
JOIN `academy_levels` al ON al.id = e.level_id
JOIN `academies` a ON a.id = al.academy_id
WHERE e.`status` = 'active'
AND (e.`end_date` IS NULL OR e.`end_date` >= ?)
AND e.`start_date` <= ?",
[$month . '-01', $month . '-28']
);
foreach ($enrollments as $enrollment) {
$playerId = (int) $enrollment['player_id'];
$enrollmentId = (int) $enrollment['id'];
$disciplineId = (int) ($enrollment['discipline_id'] ?? 0);
// Skip if subscription already exists for this enrollment + month
$existing = ActivitySubscription::query()
->where('enrollment_id', '=', $enrollmentId)
->where('subscription_month', '=', $month)
->first();
if ($existing) {
continue;
}
// Check if this is the first month and enrollment started after the 15th
$enrollmentDay = (int) date('d', strtotime($enrollment['start_date'] ?? ''));
$enrollmentMonth = date('Y-m', strtotime($enrollment['start_date'] ?? ''));
$isHalfMonth = ($enrollmentMonth === $month && $enrollmentDay > 15);
// Calculate rate
$rate = self::calculateRate($enrollmentId, $isHalfMonth);
$baseRate = $rate['base'];
$appliedRate = $rate['applied'];
// Due date is the 7th of the subscription month
$dueDate = $month . '-07';
$sub = ActivitySubscription::create([
'player_id' => $playerId,
'enrollment_id' => $enrollmentId,
'discipline_id' => $disciplineId,
'subscription_month' => $month,
'player_type' => $enrollment['player_type'] ?? 'member',
'base_rate' => $baseRate,
'is_half_month' => $isHalfMonth ? 1 : 0,
'applied_rate' => $appliedRate,
'discount' => bcsub((string) $baseRate, (string) $appliedRate, 2),
'total_amount' => $appliedRate,
'status' => 'pending',
'due_date' => $dueDate,
]);
EventBus::dispatch('activity_sub.generated', [
'subscription_id' => (int) $sub->id,
'player_id' => $playerId,
'month' => $month,
]);
$count++;
}
return $count;
}
/**
* Calculate the rate for an enrollment.
*
* Looks up the enrollment's academy pricing and returns base and applied rates.
*
* @return array{base: string, applied: string}
*/
public static function calculateRate(int $enrollmentId, bool $isHalfMonth = false): array
{
$db = App::getInstance()->db();
$enrollment = $db->selectOne(
"SELECT e.*, p.player_type, a.id as academy_id, a.discipline_id
FROM `enrollments` e
JOIN `players` p ON p.id = e.player_id
JOIN `academy_levels` al ON al.id = e.level_id
JOIN `academies` a ON a.id = al.academy_id
WHERE e.`id` = ?",
[$enrollmentId]
);
if (!$enrollment) {
return ['base' => '0.00', 'applied' => '0.00'];
}
$isMember = ($enrollment['player_type'] ?? '') === 'member';
$academyId = (int) ($enrollment['academy_id'] ?? 0);
// Try academy pricing first, then discipline pricing
$rate = ActivityPricing::getEffectiveRate('academy', $academyId, $isMember);
if ($rate <= 0 && !empty($enrollment['discipline_id'])) {
$rate = ActivityPricing::getEffectiveRate('discipline', (int) $enrollment['discipline_id'], $isMember);
}
$baseRate = number_format($rate, 2, '.', '');
$appliedRate = $isHalfMonth
? number_format($rate / 2, 2, '.', '')
: $baseRate;
return ['base' => $baseRate, 'applied' => $appliedRate];
}
/**
* Mark a subscription as paid.
*/
public static function paySubscription(int $subscriptionId, int $paymentId): bool
{
$sub = ActivitySubscription::find($subscriptionId);
if (!$sub) {
return false;
}
$sub->update([
'status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'),
'payment_id' => $paymentId,
]);
EventBus::dispatch('activity_sub.paid', [
'subscription_id' => $subscriptionId,
'player_id' => (int) $sub->player_id,
'payment_id' => $paymentId,
]);
return true;
}
/**
* Revoke overdue subscriptions for a given month.
*
* Finds subscriptions where status is 'pending' or 'overdue' and
* due_date + 7 days < today, then sets them to 'revoked'.
*/
public static function revokeOverdue(string $month): int
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
$count = 0;
$rows = ActivitySubscription::query()
->where('subscription_month', '=', $month)
->whereRaw("`status` IN ('pending', 'overdue')")
->whereRaw('DATE_ADD(`due_date`, INTERVAL 7 DAY) < ?', [$today])
->get();
foreach ($rows as $row) {
$sub = ActivitySubscription::find((int) $row['id']);
if (!$sub) {
continue;
}
$sub->update([
'status' => 'revoked',
'revoked_at' => date('Y-m-d H:i:s'),
]);
EventBus::dispatch('activity_sub.revoked', [
'subscription_id' => (int) $row['id'],
'player_id' => (int) $row['player_id'],
'month' => $month,
]);
$count++;
}
return $count;
}
/**
* Grant an exemption on a subscription.
*/
public static function grantExemption(int $subscriptionId, int $exemptedBy, string $reason): bool
{
$sub = ActivitySubscription::find($subscriptionId);
if (!$sub) {
return false;
}
$sub->update([
'status' => 'exempted',
'exempted_by' => $exemptedBy,
'exemption_reason' => $reason,
]);
EventBus::dispatch('activity_sub.exempted', [
'subscription_id' => $subscriptionId,
'player_id' => (int) $sub->player_id,
'exempted_by' => $exemptedBy,
]);
return true;
}
}
<?php
use App\Modules\ActivitySubscriptions\Models\ActivitySubscription;
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>اشتراكات الأنشطة<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<form method="POST" action="/activity-subscriptions/generate" style="display:inline-flex;gap:6px;align-items:center;">
<?= csrf_field() ?>
<input type="month" name="month" value="<?= e(date('Y-m')) ?>" class="form-input" style="width:160px;font-size:13px;">
<button type="submit" class="btn btn-outline" onclick="return confirm('هل تريد توليد الاشتراكات لهذا الشهر؟')">
<i data-lucide="refresh-cw" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> توليد
</button>
</form>
<a href="/activity-subscriptions/pricing" class="btn btn-outline"><i data-lucide="settings" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> التسعير</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$currentStatus = $filters['status'] ?? '';
$allStatuses = ActivitySubscription::getStatuses();
?>
<!-- Status Filter Tabs -->
<div class="card" style="margin-bottom:20px;padding:0;">
<div style="display:flex;align-items:center;gap:0;overflow-x:auto;border-bottom:2px solid #E5E7EB;">
<a href="/activity-subscriptions?<?= http_build_query(array_merge($filters, ['status' => '', 'page' => 1])) ?>"
style="padding:12px 20px;font-size:14px;font-weight:600;text-decoration:none;border-bottom:3px solid <?= $currentStatus === '' ? '#0D7377' : 'transparent' ?>;color:<?= $currentStatus === '' ? '#0D7377' : '#6B7280' ?>;white-space:nowrap;">
الكل
</a>
<?php foreach ($allStatuses as $sKey => $sLabel): ?>
<a href="/activity-subscriptions?<?= http_build_query(array_merge($filters, ['status' => $sKey, 'page' => 1])) ?>"
style="padding:12px 20px;font-size:14px;font-weight:600;text-decoration:none;border-bottom:3px solid <?= $currentStatus === $sKey ? '#0D7377' : 'transparent' ?>;color:<?= $currentStatus === $sKey ? '#0D7377' : '#6B7280' ?>;white-space:nowrap;">
<?= e($sLabel) ?>
</a>
<?php endforeach; ?>
</div>
</div>
<!-- Search & Filters -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/activity-subscriptions" style="display:flex;flex-wrap:wrap;gap:10px;align-items:end;">
<div style="flex:1;min-width:200px;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="q" value="<?= e($filters['q'] ?? '') ?>" placeholder="اسم اللاعب أو الكود..." class="form-input">
</div>
<div style="min-width:160px;">
<label class="form-label" style="font-size:12px;">الشهر</label>
<input type="month" name="subscription_month" value="<?= e($filters['subscription_month'] ?? '') ?>" class="form-input">
</div>
<div style="min-width:160px;">
<label class="form-label" style="font-size:12px;">النشاط</label>
<select name="discipline_id" class="form-input">
<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>
<?php if ($currentStatus !== ''): ?>
<input type="hidden" name="status" value="<?= e($currentStatus) ?>">
<?php endif; ?>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> بحث</button>
<a href="/activity-subscriptions" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<!-- Subscriptions Table -->
<?php if (!empty($subscriptions)): ?>
<div class="card" style="padding:0;overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:14px;">
<thead>
<tr style="background:#F9FAFB;border-bottom:2px solid #E5E7EB;">
<th style="padding:12px 16px;text-align:right;font-weight:600;color:#374151;">اللاعب</th>
<th style="padding:12px 16px;text-align:right;font-weight:600;color:#374151;">الشهر</th>
<th style="padding:12px 16px;text-align:right;font-weight:600;color:#374151;">النشاط</th>
<th style="padding:12px 16px;text-align:right;font-weight:600;color:#374151;">نوع اللاعب</th>
<th style="padding:12px 16px;text-align:right;font-weight:600;color:#374151;">السعر الأساسي</th>
<th style="padding:12px 16px;text-align:right;font-weight:600;color:#374151;">السعر المطبق</th>
<th style="padding:12px 16px;text-align:right;font-weight:600;color:#374151;">الخصم</th>
<th style="padding:12px 16px;text-align:right;font-weight:600;color:#374151;">الإجمالي</th>
<th style="padding:12px 16px;text-align:center;font-weight:600;color:#374151;">الحالة</th>
<th style="padding:12px 16px;text-align:center;font-weight:600;color:#374151;">إجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($subscriptions as $s):
$statusColor = ActivitySubscription::getStatusColor($s['status'] ?? 'pending');
$statusLabel = ActivitySubscription::getStatusLabel($s['status'] ?? 'pending');
$canPay = in_array($s['status'] ?? '', ['pending', 'overdue']);
$canExempt = in_array($s['status'] ?? '', ['pending', 'overdue']);
?>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:12px 16px;">
<div style="font-weight:600;"><?= e($s['player_name'] ?? '—') ?></div>
</td>
<td style="padding:12px 16px;"><?= e($s['subscription_month'] ?? '') ?></td>
<td style="padding:12px 16px;"><?= e($s['discipline_name'] ?? '—') ?></td>
<td style="padding:12px 16px;"><?= e($s['player_type'] ?? '') ?></td>
<td style="padding:12px 16px;"><?= money($s['base_rate'] ?? 0) ?></td>
<td style="padding:12px 16px;"><?= money($s['applied_rate'] ?? 0) ?></td>
<td style="padding:12px 16px;">
<?php if (((float) ($s['discount'] ?? 0)) > 0): ?>
<span style="color:#059669;font-weight:600;"><?= money($s['discount'] ?? 0) ?>-</span>
<?php else: ?>
<span style="color:#9CA3AF;"></span>
<?php endif; ?>
</td>
<td style="padding:12px 16px;font-weight:700;"><?= money($s['total_amount'] ?? 0) ?></td>
<td style="padding:12px 16px;text-align:center;">
<span class="badge" style="background:<?= $statusColor ?>15;color:<?= $statusColor ?>;font-size:12px;padding:4px 12px;border-radius:10px;font-weight:600;">
<?= e($statusLabel) ?>
</span>
</td>
<td style="padding:12px 16px;text-align:center;">
<div style="display:flex;gap:4px;justify-content:center;">
<a href="/activity-subscriptions/<?= (int) $s['id'] ?>" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="eye" style="width:13px;height:13px;vertical-align:middle;"></i>
</a>
<?php if ($canPay): ?>
<form method="POST" action="/activity-subscriptions/<?= (int) $s['id'] ?>/pay" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-primary" style="font-size:12px;padding:4px 10px;" onclick="return confirm('تأكيد الدفع؟')">
<i data-lucide="credit-card" style="width:13px;height:13px;vertical-align:middle;"></i> دفع
</button>
</form>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if (isset($pagination) && ($pagination['last_page'] ?? 1) > 1): ?>
<div style="padding:15px;">
<?php $__template->include('Shared.Components.pagination', ['pagination' => $pagination]); ?>
</div>
<?php endif; ?>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="receipt" style="width:48px;height:48px;color:#D1D5DB;"></i>
</div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا توجد اشتراكات</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0 0 20px;">
<?php if (!empty($filters['q']) || !empty($filters['subscription_month']) || !empty($filters['discipline_id'])): ?>
لا توجد نتائج مطابقة لبحثك. جرب تغيير معايير البحث.
<?php else: ?>
لم يتم توليد اشتراكات بعد. استخدم زر "توليد" لإنشاء اشتراكات الشهر.
<?php endif; ?>
</p>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});
</script>
<?php $__template->endSection(); ?>
This diff is collapsed.
This diff is collapsed.
<?php
declare(strict_types=1);
use App\Core\Registries\PermissionRegistry;
PermissionRegistry::register('activity_subscriptions', [
'activity_sub.view' => ['ar' => 'عرض اشتراكات الأنشطة', 'en' => 'View Activity Subscriptions'],
'activity_sub.collect' => ['ar' => 'تحصيل اشتراكات الأنشطة', 'en' => 'Collect Activity Subscriptions'],
'activity_sub.exempt' => ['ar' => 'إعفاء اشتراكات الأنشطة', 'en' => 'Exempt Activity Subscriptions'],
'activity_sub.generate' => ['ar' => 'توليد اشتراكات الأنشطة', 'en' => 'Generate Activity Subscriptions'],
'activity_sub.manage_pricing' => ['ar' => 'إدارة تسعير الأنشطة', 'en' => 'Manage Activity Pricing'],
]);
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\Disciplines\Models;
use App\Core\Model;
use App\Core\App;
class SportDiscipline extends Model
{
protected static string $table = 'sport_disciplines';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'code',
'name_ar',
'name_en',
'category',
'icon',
'description_ar',
'config_json',
'sort_order',
'is_active',
];
/**
* Decode config_json into an associative array.
*/
public function getConfig(): array
{
$raw = $this->config_json;
if (empty($raw)) {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Extract age groups from the config.
*/
public function getAgeGroups(): array
{
$config = $this->getConfig();
return $config['age_groups'] ?? [];
}
/**
* Extract skill levels from the config.
*/
public function getSkillLevels(): array
{
$config = $this->getConfig();
return $config['skill_levels'] ?? [];
}
/**
* Get all discipline categories with Arabic labels.
*/
public static function getCategories(): array
{
return [
'individual' => 'الفردية',
'team' => 'الجماعية',
'racket' => 'الراكيت',
'leisure' => 'الترفيهية',
'emerging' => 'الناشئة',
];
}
/**
* Get the Arabic label for a category key.
*/
public static function getCategoryLabel(string $category): string
{
$categories = self::getCategories();
return $categories[$category] ?? $category;
}
/**
* Get the badge color for a category.
*/
public static function getCategoryColor(string $category): string
{
$colors = [
'individual' => '#0D7377',
'team' => '#0284C7',
'racket' => '#7C3AED',
'leisure' => '#D97706',
'emerging' => '#059669',
];
return $colors[$category] ?? '#6B7280';
}
/**
* Get all active disciplines ordered by sort_order.
*/
public static function allActive(): array
{
return static::query()
->where('is_active', '=', 1)
->orderBy('sort_order', 'ASC')
->orderBy('name_ar', 'ASC')
->get();
}
/**
* Get disciplines by category.
*/
public static function getByCategory(string $category): array
{
return static::query()
->where('is_active', '=', 1)
->where('category', '=', $category)
->orderBy('sort_order', 'ASC')
->orderBy('name_ar', 'ASC')
->get();
}
/**
* Search disciplines with filters and pagination.
*/
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$query = static::query();
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$query = $query->whereRaw(
'(`name_ar` LIKE ? OR `name_en` LIKE ? OR `code` LIKE ?)',
[$search, $search, $search]
);
}
if (!empty($filters['category'])) {
$query = $query->where('category', '=', $filters['category']);
}
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query = $query->where('is_active', '=', (int) $filters['is_active']);
}
$query = $query->orderBy('sort_order', 'ASC')->orderBy('name_ar', 'ASC');
return $query->paginate($perPage, $page);
}
}
<?php
declare(strict_types=1);
return [
['GET', '/disciplines', 'Disciplines\Controllers\DisciplineController@index', ['auth'], 'discipline.view'],
['GET', '/disciplines/create', 'Disciplines\Controllers\DisciplineController@create', ['auth'], 'discipline.manage'],
['POST', '/disciplines', 'Disciplines\Controllers\DisciplineController@store', ['auth', 'csrf'], 'discipline.manage'],
['GET', '/disciplines/{id:\d+}', 'Disciplines\Controllers\DisciplineController@show', ['auth'], 'discipline.view'],
['GET', '/disciplines/{id:\d+}/edit', 'Disciplines\Controllers\DisciplineController@edit', ['auth'], 'discipline.manage'],
['POST', '/disciplines/{id:\d+}', 'Disciplines\Controllers\DisciplineController@update', ['auth', 'csrf'], 'discipline.manage'],
['POST', '/disciplines/{id:\d+}/toggle','Disciplines\Controllers\DisciplineController@toggle', ['auth', 'csrf'], 'discipline.manage'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Disciplines\Services;
use App\Modules\Disciplines\Models\SportDiscipline;
class DisciplineService
{
/**
* Get all active disciplines.
*/
public static function getActive(): array
{
return SportDiscipline::allActive();
}
/**
* Get active disciplines filtered by category.
*/
public static function getByCategory(string $category): array
{
return SportDiscipline::getByCategory($category);
}
/**
* Get age groups for a specific discipline.
*/
public static function getAgeGroups(int $disciplineId): array
{
$discipline = SportDiscipline::find($disciplineId);
if (!$discipline) {
return [];
}
return $discipline->getAgeGroups();
}
/**
* Get skill levels for a specific discipline.
*/
public static function getSkillLevels(int $disciplineId): array
{
$discipline = SportDiscipline::find($disciplineId);
if (!$discipline) {
return [];
}
return $discipline->getSkillLevels();
}
/**
* Validate whether a given age is acceptable for a discipline.
*/
public static function validateAgeForDiscipline(int $disciplineId, int $age): bool
{
$ageGroups = self::getAgeGroups($disciplineId);
if (empty($ageGroups)) {
// No age groups defined — allow all ages
return true;
}
foreach ($ageGroups as $group) {
$minAge = (int) ($group['min_age'] ?? 0);
$maxAge = (int) ($group['max_age'] ?? 999);
if ($age >= $minAge && $age <= $maxAge) {
return true;
}
}
return false;
}
/**
* Get all active disciplines grouped by category.
*/
public static function getGroupedByCategory(): array
{
$disciplines = SportDiscipline::allActive();
$grouped = [];
foreach (SportDiscipline::getCategories() as $key => $label) {
$grouped[$key] = [];
}
foreach ($disciplines as $d) {
$cat = $d['category'] ?? 'individual';
if (!isset($grouped[$cat])) {
$grouped[$cat] = [];
}
$grouped[$cat][] = $d;
}
return $grouped;
}
}
This diff is collapsed.
This diff is collapsed.
<?php
use App\Modules\Disciplines\Models\SportDiscipline;
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>الأنشطة الرياضية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/disciplines/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إضافة نشاط رياضي</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$currentCategory = $filters['category'] ?? '';
$allCategories = SportDiscipline::getCategories();
?>
<!-- Category Filter Tabs -->
<div class="card" style="margin-bottom:20px;padding:0;">
<div style="display:flex;align-items:center;gap:0;overflow-x:auto;border-bottom:2px solid #E5E7EB;">
<a href="/disciplines"
style="padding:12px 20px;font-size:14px;font-weight:600;text-decoration:none;border-bottom:3px solid <?= $currentCategory === '' ? '#0D7377' : 'transparent' ?>;color:<?= $currentCategory === '' ? '#0D7377' : '#6B7280' ?>;white-space:nowrap;">
الكل
</a>
<?php foreach ($allCategories as $catKey => $catLabel): ?>
<a href="/disciplines?category=<?= e($catKey) ?><?= ($filters['q'] ?? '') !== '' ? '&q=' . urlencode($filters['q']) : '' ?>"
style="padding:12px 20px;font-size:14px;font-weight:600;text-decoration:none;border-bottom:3px solid <?= $currentCategory === $catKey ? '#0D7377' : 'transparent' ?>;color:<?= $currentCategory === $catKey ? '#0D7377' : '#6B7280' ?>;white-space:nowrap;">
<?= e($catLabel) ?>
</a>
<?php endforeach; ?>
</div>
</div>
<!-- Search Bar -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/disciplines" style="display:flex;gap:10px;align-items:end;">
<div style="flex:1;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="q" value="<?= e($filters['q'] ?? '') ?>" placeholder="ابحث بالاسم أو الكود..." class="form-input" style="min-width:250px;">
</div>
<?php if ($currentCategory !== ''): ?>
<input type="hidden" name="category" value="<?= e($currentCategory) ?>">
<?php endif; ?>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> بحث</button>
<a href="/disciplines" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<!-- Disciplines Grid -->
<?php if (!empty($disciplines)): ?>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(320px, 1fr));gap:20px;margin-bottom:20px;">
<?php foreach ($disciplines as $d):
$config = json_decode($d['config_json'] ?? '{}', true) ?: [];
$ageGroupsCount = count($config['age_groups'] ?? []);
$skillLevelsCount = count($config['skill_levels'] ?? []);
$catColor = SportDiscipline::getCategoryColor($d['category'] ?? '');
$catLabel = SportDiscipline::getCategoryLabel($d['category'] ?? '');
$isActive = (int) ($d['is_active'] ?? 0);
?>
<div class="card" style="transition:box-shadow 0.2s;position:relative;overflow:hidden;<?= !$isActive ? 'opacity:0.65;' : '' ?>">
<?php if (!$isActive): ?>
<div style="position:absolute;top:12px;left:12px;z-index:2;">
<span class="badge" style="background:#FEE2E2;color:#DC2626;font-size:11px;padding:3px 10px;border-radius:10px;">معطّل</span>
</div>
<?php endif; ?>
<a href="/disciplines/<?= (int) $d['id'] ?>" style="text-decoration:none;color:inherit;display:block;">
<!-- Card Header with Icon -->
<div style="padding:20px 20px 15px;display:flex;align-items:start;gap:15px;">
<div style="width:52px;height:52px;border-radius:12px;background:linear-gradient(135deg, <?= $catColor ?>15, <?= $catColor ?>30);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i data-lucide="<?= e($d['icon'] ?? 'activity') ?>" style="width:26px;height:26px;color:<?= $catColor ?>;"></i>
</div>
<div style="flex:1;min-width:0;">
<h3 style="margin:0 0 4px;font-size:16px;font-weight:700;color:#1A1A2E;"><?= e($d['name_ar']) ?></h3>
<?php if (!empty($d['name_en'])): ?>
<div style="font-size:12px;color:#9CA3AF;margin-bottom:6px;"><?= e($d['name_en']) ?></div>
<?php endif; ?>
<span style="display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600;background:<?= $catColor ?>15;color:<?= $catColor ?>;"><?= e($catLabel) ?></span>
</div>
</div>
<!-- Card Body Stats -->
<div style="padding:0 20px 15px;display:flex;gap:15px;font-size:12px;color:#6B7280;">
<span style="display:flex;align-items:center;gap:4px;">
<i data-lucide="users" style="width:14px;height:14px;"></i>
<?= $ageGroupsCount ?> فئة عمرية
</span>
<span style="display:flex;align-items:center;gap:4px;">
<i data-lucide="bar-chart-2" style="width:14px;height:14px;"></i>
<?= $skillLevelsCount ?> مستوى مهارة
</span>
</div>
</a>
<!-- Card Footer -->
<div style="padding:10px 20px;border-top:1px solid #F3F4F6;display:flex;justify-content:space-between;align-items:center;">
<code style="font-size:11px;color:#9CA3AF;background:#F9FAFB;padding:2px 6px;border-radius:4px;"><?= e($d['code'] ?? '') ?></code>
<div style="display:flex;gap:6px;">
<a href="/disciplines/<?= (int) $d['id'] ?>" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="eye" style="width:13px;height:13px;vertical-align:middle;"></i> عرض
</a>
<a href="/disciplines/<?= (int) $d['id'] ?>/edit" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="edit-3" style="width:13px;height:13px;vertical-align:middle;"></i> تعديل
</a>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if (isset($pagination) && ($pagination['last_page'] ?? 1) > 1): ?>
<div style="padding:15px;">
<?php $__template->include('Shared.Components.pagination', ['pagination' => $pagination]); ?>
</div>
<?php endif; ?>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="activity" style="width:48px;height:48px;color:#D1D5DB;"></i>
</div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا توجد أنشطة رياضية</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0 0 20px;">
<?php if (!empty($filters['q']) || !empty($filters['category'])): ?>
لا توجد نتائج مطابقة لبحثك. جرب تغيير معايير البحث.
<?php else: ?>
ابدأ بإضافة نشاط رياضي جديد للنادي.
<?php endif; ?>
</p>
<a href="/disciplines/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إضافة نشاط رياضي</a>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});
</script>
<?php $__template->endSection(); ?>
This diff is collapsed.
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
// ────────────────────────────────────────────────────────────
// Sports Activities — Parent sidebar menu with all 7 module children
// This file owns the entire sports_activities menu group.
// ────────────────────────────────────────────────────────────
MenuRegistry::register('sports_activities', [
'label_ar' => 'الأنشطة الرياضية',
'label_en' => 'Sports Activities',
'icon' => 'activity',
'route' => '/disciplines',
'permission' => 'discipline.view',
'parent' => null,
'order' => 395,
'children' => [
['label_ar' => 'الأنشطة الرياضية', 'label_en' => 'Disciplines', 'route' => '/disciplines', 'permission' => 'discipline.view', 'order' => 1],
['label_ar' => 'الملاعب والمرافق', 'label_en' => 'Facilities', 'route' => '/facilities', 'permission' => 'facility.view', 'order' => 2],
['label_ar' => 'الأكاديميات', 'label_en' => 'Academies', 'route' => '/academies', 'permission' => 'academy.view', 'order' => 3],
['label_ar' => 'شئون اللاعبين', 'label_en' => 'Players', 'route' => '/players', 'permission' => 'player.view', 'order' => 4],
['label_ar' => 'الحجوزات', 'label_en' => 'Reservations', 'route' => '/reservations', 'permission' => 'reservation.view', 'order' => 5],
['label_ar' => 'التأجير المؤسسي', 'label_en' => 'Corporate Rentals', 'route' => '/rentals', 'permission' => 'rental.view', 'order' => 6],
['label_ar' => 'اشتراكات الأنشطة', 'label_en' => 'Activity Subscriptions','route' => '/activity-subscriptions', 'permission' => 'activity_sub.view', 'order' => 7],
['label_ar' => 'تسعير الأنشطة', 'label_en' => 'Activity Pricing', 'route' => '/activity-subscriptions/pricing', 'permission' => 'activity_sub.manage_pricing', 'order' => 8],
],
]);
PermissionRegistry::register('disciplines', [
'discipline.view' => ['ar' => 'عرض الأنشطة الرياضية', 'en' => 'View Disciplines'],
'discipline.manage' => ['ar' => 'إدارة الأنشطة الرياضية', 'en' => 'Manage Disciplines'],
]);
......@@ -64,6 +64,14 @@ class Document extends Model
'photo' => 'صورة شخصية',
'disability_doc' => 'مستند إعاقة',
'championship_doc' => 'مستند بطولة',
'medical_certificate'=> 'شهادة طبية',
'fitness_cert' => 'شهادة لياقة بدنية',
'guardian_consent' => 'موافقة ولي الأمر',
'federation_card' => 'كارنيه الاتحاد',
'player_photo' => 'صورة اللاعب',
'rental_letter' => 'خطاب رسمي للتأجير',
'technical_report' => 'تقرير فني',
'contract_scan' => 'نسخة عقد',
'other' => 'مستند آخر',
];
}
......
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\Facilities\Models;
use App\Core\Model;
class Facility extends Model
{
protected static string $table = 'facilities';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'code',
'name_ar',
'name_en',
'facility_type',
'location',
'capacity',
'hourly_rate_member',
'hourly_rate_nonmember',
'hourly_rate_member_pm',
'hourly_rate_nonmember_pm',
'linked_discipline_id',
'config_json',
'is_active',
];
/**
* Decode config_json into an associative array.
*/
public function getConfig(): array
{
$raw = $this->config_json;
if (empty($raw)) {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Get all facility types with Arabic labels.
*/
public static function getTypes(): array
{
return [
'pitch' => 'ملعب',
'court' => 'كورت',
'hall' => 'قاعة',
'lane' => 'حارة',
'table' => 'طاولة',
'device' => 'جهاز',
'track' => 'مضمار',
'pool' => 'حمام سباحة',
];
}
/**
* Get location options with Arabic labels.
*/
public static function getLocations(): array
{
return [
'indoor' => 'داخلي',
'outdoor' => 'خارجي',
];
}
/**
* Get the Arabic label for a facility type.
*/
public static function getTypeLabel(string $type): string
{
$types = self::getTypes();
return $types[$type] ?? $type;
}
/**
* Get the badge color for a facility type.
*/
public static function getTypeColor(string $type): string
{
$colors = [
'pitch' => '#0D7377',
'court' => '#7C3AED',
'hall' => '#0284C7',
'lane' => '#059669',
'table' => '#D97706',
'device' => '#DC2626',
'track' => '#EA580C',
'pool' => '#2563EB',
];
return $colors[$type] ?? '#6B7280';
}
/**
* Get the Arabic label for a location key.
*/
public static function getLocationLabel(string $location): string
{
$locations = self::getLocations();
return $locations[$location] ?? $location;
}
/**
* Get all active facilities ordered by name_ar.
*/
public static function allActive(): array
{
return static::query()
->where('is_active', '=', 1)
->orderBy('name_ar', 'ASC')
->get();
}
/**
* Get active facilities filtered by facility_type.
*/
public static function getByType(string $type): array
{
return static::query()
->where('is_active', '=', 1)
->where('facility_type', '=', $type)
->orderBy('name_ar', 'ASC')
->get();
}
/**
* Search facilities with filters and pagination.
*/
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$query = static::query();
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$query = $query->whereRaw(
'(`name_ar` LIKE ? OR `name_en` LIKE ? OR `code` LIKE ?)',
[$search, $search, $search]
);
}
if (!empty($filters['facility_type'])) {
$query = $query->where('facility_type', '=', $filters['facility_type']);
}
if (!empty($filters['location'])) {
$query = $query->where('location', '=', $filters['location']);
}
if (!empty($filters['linked_discipline_id'])) {
$query = $query->where('linked_discipline_id', '=', (int) $filters['linked_discipline_id']);
}
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query = $query->where('is_active', '=', (int) $filters['is_active']);
}
$query = $query->orderBy('name_ar', 'ASC');
return $query->paginate($perPage, $page);
}
/**
* Get the correct hourly rate based on member status and time tier.
*
* @param bool $isMember Whether the person is a club member
* @param string $timeTier 'AM' for morning rates, 'PM' for evening rates
*/
public function getRate(bool $isMember, string $timeTier = 'AM'): float
{
if ($timeTier === 'PM') {
return (float) ($isMember ? $this->hourly_rate_member_pm : $this->hourly_rate_nonmember_pm);
}
return (float) ($isMember ? $this->hourly_rate_member : $this->hourly_rate_nonmember);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Facilities\Models;
use App\Core\Model;
class FacilityBlackoutDate extends Model
{
protected static string $table = 'facility_blackout_dates';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'facility_id',
'blackout_date',
'start_time',
'end_time',
'reason',
'created_by',
];
/**
* Get upcoming blackout dates for a facility (today and future), ordered by date.
*/
public static function getForFacility(int $facilityId): array
{
return static::query()
->where('facility_id', '=', $facilityId)
->whereRaw('`blackout_date` >= CURDATE()')
->orderBy('blackout_date', 'ASC')
->orderBy('start_time', 'ASC')
->get();
}
/**
* Check if a given date (and optionally time) is blocked for a facility.
*/
public static function isBlackedOut(int $facilityId, string $date, ?string $time = null): bool
{
$query = static::query()
->where('facility_id', '=', $facilityId)
->where('blackout_date', '=', $date);
$rows = $query->get();
if (empty($rows)) {
return false;
}
foreach ($rows as $row) {
// If the blackout has no start/end time, it blocks the entire day
if (empty($row['start_time']) && empty($row['end_time'])) {
return true;
}
// If no specific time to check, any blackout on this date counts
if ($time === null) {
return true;
}
// Check if the requested time falls within the blackout window
$start = $row['start_time'] ?? '00:00:00';
$end = $row['end_time'] ?? '23:59:59';
if ($time >= $start && $time <= $end) {
return true;
}
}
return false;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Facilities\Models;
use App\Core\Model;
class FacilityTimeSlot extends Model
{
protected static string $table = 'facility_time_slots';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'facility_id',
'day_of_week',
'start_time',
'end_time',
'slot_type',
'linked_academy_id',
'is_active',
];
/**
* Get all time slots for a specific facility, ordered by day_of_week then start_time.
*/
public static function getForFacility(int $facilityId): array
{
return static::query()
->where('facility_id', '=', $facilityId)
->orderBy('day_of_week', 'ASC')
->orderBy('start_time', 'ASC')
->get();
}
/**
* Get slot type options with Arabic labels.
*/
public static function getSlotTypes(): array
{
return [
'available' => 'متاح',
'maintenance' => 'صيانة',
'academy_reserved' => 'محجوز للأكاديمية',
'blocked' => 'محظور',
];
}
/**
* Get day-of-week labels in Arabic (0 = Sunday).
*/
public static function getDayLabels(): array
{
return [
0 => 'الأحد',
1 => 'الاثنين',
2 => 'الثلاثاء',
3 => 'الأربعاء',
4 => 'الخميس',
5 => 'الجمعة',
6 => 'السبت',
];
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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