Commit 8580e415 authored by Mahmoud Aglan's avatar Mahmoud Aglan

123 files created/modified implementing the full النشاط الرياضي

parent 1a203e26
......@@ -111,6 +111,14 @@ final class AccountCodes
const BANK_INTEREST_INCOME = '4205'; // فوائد دائنة
const MISCELLANEOUS_REVENUE = '4202'; // إعانات / متنوعة
// Facility & Pool Revenue (4111)
const FACILITY_ENTRY_REVENUE = '411101';
const POOL_ENTRY_REVENUE = '411102';
const GUEST_ENTRY_REVENUE = '411103';
// Tournament Revenue (4112)
const TOURNAMENT_FEE_REVENUE = '411201';
// Service Revenue (catch-all for deposits reclassification etc.)
const SERVICE_REVENUE = '4110';
......
......@@ -1157,6 +1157,96 @@ final class AccountingIntegrationService
);
}
// ────────────────────────────────────────────────────────────
// FACILITY & POOL ACCESS
// ────────────────────────────────────────────────────────────
public static function onFacilityEntry(array $data): void
{
$amount = (string) ($data['amount'] ?? '0.00');
if (bccomp($amount, '0.00', 2) <= 0) return;
$facilityId = (int) ($data['facility_id'] ?? 0);
$entryId = (int) ($data['entry_id'] ?? 0);
$bookerType = $data['booker_type'] ?? 'guest';
$revenueCode = AccountCodes::FACILITY_ENTRY_REVENUE;
$debitCode = AccountCodes::CASH_ON_HAND;
$debitAccount = self::getAccountByCode($debitCode);
$revenueAccount = self::getAccountByCode($revenueCode);
if (!$debitAccount || !$revenueAccount) return;
$description = 'إيرادات دخول مرفق #' . $facilityId . ' — ' . ($bookerType === 'member' ? 'عضو' : 'ضيف');
JournalService::createEntry([
'entry_date' => date('Y-m-d'),
'description_ar' => $description,
'reference_type' => 'facility_entry',
'reference_id' => $entryId,
'source_module' => 'facilities',
'is_auto_generated' => 1,
], [
['account_id' => (int) $debitAccount['id'], 'debit' => $amount, 'credit' => '0.00', 'description_ar' => $description],
['account_id' => (int) $revenueAccount['id'], 'debit' => '0.00', 'credit' => $amount, 'description_ar' => 'إيرادات مرافق'],
], true);
}
public static function onGuestEntry(array $data): void
{
$amount = (string) ($data['amount'] ?? '0.00');
if (bccomp($amount, '0.00', 2) <= 0) return;
$entryId = (int) ($data['entry_id'] ?? 0);
$debitAccount = self::getAccountByCode(AccountCodes::CASH_ON_HAND);
$revenueAccount = self::getAccountByCode(AccountCodes::GUEST_ENTRY_REVENUE);
if (!$debitAccount || !$revenueAccount) return;
JournalService::createEntry([
'entry_date' => date('Y-m-d'),
'description_ar' => 'إيرادات دخول ضيف (كارنيه دعوات) #' . $entryId,
'reference_type' => 'guest_entry',
'reference_id' => $entryId,
'source_module' => 'carnets',
'is_auto_generated' => 1,
], [
['account_id' => (int) $debitAccount['id'], 'debit' => $amount, 'credit' => '0.00', 'description_ar' => 'تحصيل دخول ضيف'],
['account_id' => (int) $revenueAccount['id'], 'debit' => '0.00', 'credit' => $amount, 'description_ar' => 'إيرادات دعوات'],
], true);
}
public static function onTournamentFee(array $data): void
{
$amount = (string) ($data['amount'] ?? '0.00');
if (bccomp($amount, '0.00', 2) <= 0) return;
$tournamentId = (int) ($data['tournament_id'] ?? 0);
$playerId = (int) ($data['player_id'] ?? 0);
$debitAccount = self::getAccountByCode(AccountCodes::CASH_ON_HAND);
$revenueAccount = self::getAccountByCode(AccountCodes::TOURNAMENT_FEE_REVENUE);
if (!$debitAccount || !$revenueAccount) return;
JournalService::createEntry([
'entry_date' => date('Y-m-d'),
'description_ar' => 'رسوم اشتراك بطولة #' . $tournamentId . ' — لاعب #' . $playerId,
'reference_type' => 'tournament_fee',
'reference_id' => $tournamentId,
'source_module' => 'tournaments',
'is_auto_generated' => 1,
], [
['account_id' => (int) $debitAccount['id'], 'debit' => $amount, 'credit' => '0.00', 'description_ar' => 'تحصيل رسوم بطولة'],
['account_id' => (int) $revenueAccount['id'], 'debit' => '0.00', 'credit' => $amount, 'description_ar' => 'إيرادات بطولات'],
], true);
}
// ────────────────────────────────────────────────────────────
// PRIVATE HELPERS
// ────────────────────────────────────────────────────────────
private static function getPaymentTypeLabel(string $type): string
{
return match ($type) {
......@@ -1177,6 +1267,9 @@ final class AccountingIntegrationService
'sports_conversion' => 'رسوم تحويل رياضي',
'inventory_sale' => 'مبيعات مخزون',
'activity_subscription' => 'اشتراك نشاط',
'facility_entry' => 'دخول مرفق',
'guest_entry' => 'دخول ضيف',
'tournament_fee' => 'رسوم بطولة',
default => $type,
};
}
......
......@@ -350,6 +350,36 @@ EventBus::listen('rental.deposit_refunded', function (array $data): void {
}
}, 50);
// ── Facility Entry ──────────────────────────────────────────
// When a facility entry payment is recorded (pool/gym access)
EventBus::listen('facility.entry_recorded', function (array $data): void {
try {
AccountingIntegrationService::onFacilityEntry($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (facility.entry_recorded): ' . $e->getMessage());
}
}, 50);
// ── Carnet Guest Entry ──────────────────────────────────────
// When a carnet guest entry is recorded (invitation book usage)
EventBus::listen('carnet.guest_entry_recorded', function (array $data): void {
try {
AccountingIntegrationService::onGuestEntry($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (carnet.guest_entry_recorded): ' . $e->getMessage());
}
}, 50);
// ── Tournament Fees ─────────────────────────────────────────
// When a tournament registration fee is collected
EventBus::listen('tournament.fee_collected', function (array $data): void {
try {
AccountingIntegrationService::onTournamentFee($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (tournament.fee_collected): ' . $e->getMessage());
}
}, 50);
// ── Statement Integration ───────────────────────────────────
// Auto-records customer/supplier transactions for account statements and credit limits
StatementIntegrationService::registerListeners();
......
<?php
declare(strict_types=1);
namespace App\Modules\Achievements\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Achievements\Models\AchievementDefinition;
use App\Modules\Achievements\Models\PlayerAchievement;
use App\Modules\Achievements\Services\AchievementEngine;
class AchievementController extends Controller
{
/**
* Show achievements for a player.
*/
public function index(Request $request): Response
{
$this->authorize('achievement.view');
$playerId = $request->get('player_id', '') !== '' ? (int) $request->get('player_id') : null;
$db = App::getInstance()->db();
$players = $db->select(
"SELECT id, full_name_ar, registration_serial FROM players WHERE is_archived = 0 ORDER BY full_name_ar ASC"
);
$achievements = [];
$totalPoints = 0;
$player = null;
if ($playerId) {
$achievements = PlayerAchievement::getForPlayer($playerId);
$totalPoints = PlayerAchievement::getTotalPoints($playerId);
$player = $db->selectOne("SELECT * FROM players WHERE id = ?", [$playerId]);
}
// Group by category
$grouped = [];
foreach ($achievements as $ach) {
$cat = $ach['category'] ?? 'special';
$grouped[$cat][] = $ach;
}
return $this->view('Achievements.Views.player_achievements', [
'achievements' => $achievements,
'grouped' => $grouped,
'totalPoints' => $totalPoints,
'player' => $player,
'playerId' => $playerId,
'players' => $players,
'categories' => AchievementDefinition::getCategories(),
]);
}
/**
* Show the leaderboard.
*/
public function leaderboard(Request $request): Response
{
$this->authorize('achievement.view');
$disciplineId = $request->get('discipline_id', '') !== '' ? (int) $request->get('discipline_id') : null;
$limit = min(100, max(10, (int) $request->get('limit', 50)));
$leaderboard = AchievementEngine::getLeaderboard($disciplineId, $limit);
$db = App::getInstance()->db();
$disciplines = $db->select(
"SELECT id, name_ar FROM sport_disciplines WHERE is_active = 1 AND is_archived = 0 ORDER BY name_ar ASC"
);
return $this->view('Achievements.Views.leaderboard', [
'leaderboard' => $leaderboard,
'disciplines' => $disciplines,
'disciplineId' => $disciplineId,
]);
}
/**
* Admin: List all achievement definitions.
*/
public function adminIndex(Request $request): Response
{
$this->authorize('achievement.manage');
$filters = [
'q' => trim((string) $request->get('q', '')),
'category' => trim((string) $request->get('category', '')),
'is_active' => $request->get('is_active', ''),
'discipline_id' => $request->get('discipline_id', '') !== '' ? (int) $request->get('discipline_id') : null,
];
$page = max(1, (int) $request->get('page', 1));
$result = AchievementDefinition::search($filters, 25, $page);
$db = App::getInstance()->db();
$disciplines = $db->select(
"SELECT id, name_ar FROM sport_disciplines WHERE is_active = 1 AND is_archived = 0 ORDER BY name_ar ASC"
);
return $this->view('Achievements.Views.admin_index', [
'definitions' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'categories' => AchievementDefinition::getCategories(),
'disciplines' => $disciplines,
]);
}
/**
* Admin: Show form to create a new achievement definition.
*/
public function adminCreate(Request $request): Response
{
$this->authorize('achievement.manage');
$db = App::getInstance()->db();
$disciplines = $db->select(
"SELECT id, name_ar FROM sport_disciplines WHERE is_active = 1 AND is_archived = 0 ORDER BY name_ar ASC"
);
return $this->view('Achievements.Views.admin_create', [
'categories' => AchievementDefinition::getCategories(),
'badgeColors' => AchievementDefinition::getBadgeColors(),
'disciplines' => $disciplines,
]);
}
/**
* Admin: Store a new achievement definition.
*/
public function adminStore(Request $request): Response
{
$this->authorize('achievement.manage');
$data = [
'code' => trim((string) $request->post('code', '')),
'name_ar' => trim((string) $request->post('name_ar', '')),
'name_en' => trim((string) $request->post('name_en', '')) ?: null,
'description_ar'=> trim((string) $request->post('description_ar', '')) ?: null,
'category' => trim((string) $request->post('category', '')),
'icon' => trim((string) $request->post('icon', '')) ?: 'award',
'badge_color' => trim((string) $request->post('badge_color', '')) ?: 'gold',
'discipline_id' => $request->post('discipline_id', '') !== '' ? (int) $request->post('discipline_id') : null,
'points' => (int) $request->post('points', 10),
'is_active' => (int) $request->post('is_active', 1),
];
// Build criteria JSON
$criteriaType = trim((string) $request->post('criteria_type', ''));
$criteria = ['type' => $criteriaType];
$trigger = trim((string) $request->post('criteria_trigger', ''));
if ($trigger !== '') {
$criteria['trigger'] = $trigger;
}
$criteriaCount = $request->post('criteria_count', '');
if ($criteriaCount !== '') {
$criteria['count'] = (int) $criteriaCount;
}
$criteriaMonths = $request->post('criteria_months', '');
if ($criteriaMonths !== '') {
$criteria['months'] = (int) $criteriaMonths;
}
$criteriaMinScore = $request->post('criteria_min_score', '');
if ($criteriaMinScore !== '') {
$criteria['min_score'] = (float) $criteriaMinScore;
}
$data['criteria_json'] = json_encode($criteria, JSON_UNESCAPED_UNICODE);
// Validation
$errors = [];
if (empty($data['code'])) $errors[] = 'يجب تحديد كود الإنجاز';
if (empty($data['name_ar'])) $errors[] = 'يجب تحديد اسم الإنجاز';
if (empty($data['category'])) $errors[] = 'يجب تحديد التصنيف';
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_old_input', $request->all());
$alerts = array_map(fn($msg) => ['type' => 'error', 'message' => $msg], $errors);
$session->flash('_alerts', $alerts);
return $this->redirect('/achievements/admin/create');
}
try {
AchievementDefinition::create($data);
return $this->redirect('/achievements/admin')->withSuccess('تم إنشاء الإنجاز بنجاح');
} catch (\Throwable $e) {
return $this->redirect('/achievements/admin/create')->withError('حدث خطأ: ' . $e->getMessage());
}
}
/**
* Admin: Toggle an achievement definition active/inactive.
*/
public function adminToggle(Request $request, int $id): Response
{
$this->authorize('achievement.manage');
$definition = AchievementDefinition::findOrFail($id);
$newState = (int) $definition->is_active === 1 ? 0 : 1;
$definition->update(['is_active' => $newState]);
$label = $newState ? 'تفعيل' : 'تعطيل';
return $this->redirect('/achievements/admin')->withSuccess("تم {$label} الإنجاز بنجاح");
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Achievements\Models;
use App\Core\Model;
use App\Core\App;
class AchievementDefinition extends Model
{
protected static string $table = 'achievement_definitions';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'code',
'name_ar',
'name_en',
'description_ar',
'category',
'icon',
'badge_color',
'discipline_id',
'criteria_json',
'points',
'is_active',
];
public static function getCategories(): array
{
return [
'attendance' => 'الحضور والانتظام',
'performance' => 'الأداء',
'competition' => 'المسابقات',
'milestone' => 'إنجازات زمنية',
'social' => 'اجتماعية',
'fitness' => 'اللياقة',
'special' => 'خاصة',
];
}
public static function getBadgeColors(): array
{
return [
'gold' => '#F59E0B',
'silver' => '#9CA3AF',
'bronze' => '#B45309',
'blue' => '#2563EB',
'green' => '#059669',
'purple' => '#7C3AED',
'red' => '#DC2626',
'teal' => '#0D7377',
];
}
/**
* Get all active achievement definitions.
*/
public static function allActive(): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT ad.*, sd.name_ar AS discipline_name
FROM achievement_definitions ad
LEFT JOIN sport_disciplines sd ON sd.id = ad.discipline_id
WHERE ad.is_active = 1
ORDER BY ad.category ASC, ad.points DESC"
);
}
/**
* Get achievements by category.
*/
public static function getByCategory(string $category): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM achievement_definitions WHERE category = ? AND is_active = 1 ORDER BY points DESC",
[$category]
);
}
/**
* Get achievements for a specific discipline (or global ones).
*/
public static function getForDiscipline(?int $disciplineId): array
{
$db = App::getInstance()->db();
if ($disciplineId === null) {
return $db->select(
"SELECT * FROM achievement_definitions WHERE discipline_id IS NULL AND is_active = 1 ORDER BY category, points DESC"
);
}
return $db->select(
"SELECT * FROM achievement_definitions WHERE (discipline_id = ? OR discipline_id IS NULL) AND is_active = 1 ORDER BY category, points DESC",
[$disciplineId]
);
}
/**
* Get criteria decoded from JSON.
*/
public function getCriteria(): array
{
$raw = $this->criteria_json;
if (empty($raw)) {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Search definitions with pagination.
*/
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = ['1=1'];
$params = [];
if (!empty($filters['category'])) {
$where[] = 'ad.category = ?';
$params[] = $filters['category'];
}
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$where[] = 'ad.is_active = ?';
$params[] = (int) $filters['is_active'];
}
if (!empty($filters['discipline_id'])) {
$where[] = 'ad.discipline_id = ?';
$params[] = (int) $filters['discipline_id'];
}
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$where[] = '(ad.name_ar LIKE ? OR ad.name_en LIKE ? OR ad.code LIKE ?)';
$params[] = $search;
$params[] = $search;
$params[] = $search;
}
$whereClause = implode(' AND ', $where);
$countSql = "SELECT COUNT(*) as total FROM achievement_definitions ad WHERE {$whereClause}";
$countRow = $db->selectOne($countSql, $params);
$total = (int) ($countRow['total'] ?? 0);
$lastPage = max(1, (int) ceil($total / $perPage));
$page = min($page, $lastPage);
$offset = ($page - 1) * $perPage;
$dataSql = "SELECT ad.*, sd.name_ar AS discipline_name
FROM achievement_definitions ad
LEFT JOIN sport_disciplines sd ON sd.id = ad.discipline_id
WHERE {$whereClause}
ORDER BY ad.category ASC, ad.points DESC
LIMIT {$perPage} OFFSET {$offset}";
$data = $db->select($dataSql, $params);
return [
'data' => $data,
'pagination' => [
'current_page' => $page,
'last_page' => $lastPage,
'per_page' => $perPage,
'total' => $total,
],
];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Achievements\Models;
use App\Core\Model;
use App\Core\App;
class PlayerAchievement extends Model
{
protected static string $table = 'player_achievements';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'player_id',
'achievement_id',
'earned_at',
'context_json',
'points_earned',
'notified',
];
/**
* Get all achievements for a player with definition details.
*/
public static function getForPlayer(int $playerId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT pa.*, ad.code, ad.name_ar, ad.name_en, ad.description_ar,
ad.category, ad.icon, ad.badge_color, ad.discipline_id,
sd.name_ar AS discipline_name
FROM player_achievements pa
INNER JOIN achievement_definitions ad ON ad.id = pa.achievement_id
LEFT JOIN sport_disciplines sd ON sd.id = ad.discipline_id
WHERE pa.player_id = ?
ORDER BY pa.earned_at DESC",
[$playerId]
);
}
/**
* Get total points for a player.
*/
public static function getTotalPoints(int $playerId): int
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COALESCE(SUM(points_earned), 0) as total FROM player_achievements WHERE player_id = ?",
[$playerId]
);
return (int) ($row['total'] ?? 0);
}
/**
* Check if a player has already earned a specific achievement.
*/
public static function hasEarned(int $playerId, int $achievementId): bool
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT id FROM player_achievements WHERE player_id = ? AND achievement_id = ?",
[$playerId, $achievementId]
);
return $row !== null;
}
/**
* Get recent achievements across all players.
*/
public static function getRecent(int $limit = 20): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT pa.*, ad.name_ar AS achievement_name, ad.icon, ad.badge_color, ad.category,
p.full_name_ar AS player_name, p.registration_serial
FROM player_achievements pa
INNER JOIN achievement_definitions ad ON ad.id = pa.achievement_id
INNER JOIN players p ON p.id = pa.player_id
ORDER BY pa.earned_at DESC
LIMIT ?",
[$limit]
);
}
/**
* Get achievement count per player for leaderboard.
*/
public static function getLeaderboard(?int $disciplineId = null, int $limit = 50): array
{
$db = App::getInstance()->db();
$join = '';
$where = 'WHERE 1=1';
$params = [];
if ($disciplineId !== null) {
$join = 'INNER JOIN achievement_definitions ad ON ad.id = pa.achievement_id';
$where .= ' AND (ad.discipline_id = ? OR ad.discipline_id IS NULL)';
$params[] = $disciplineId;
}
$params[] = $limit;
return $db->select(
"SELECT pa.player_id, p.full_name_ar AS player_name, p.registration_serial,
p.photo_path,
SUM(pa.points_earned) AS total_points,
COUNT(pa.id) AS total_achievements
FROM player_achievements pa
INNER JOIN players p ON p.id = pa.player_id AND p.is_archived = 0
{$join}
{$where}
GROUP BY pa.player_id, p.full_name_ar, p.registration_serial, p.photo_path
ORDER BY total_points DESC
LIMIT ?",
$params
);
}
/**
* Get context decoded from JSON.
*/
public function getContext(): array
{
$raw = $this->context_json;
if (empty($raw)) {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
}
<?php
declare(strict_types=1);
return [
// Player-facing achievement views
['GET', '/achievements', 'Achievements\Controllers\AchievementController@index', ['auth'], 'achievement.view'],
['GET', '/achievements/leaderboard', 'Achievements\Controllers\AchievementController@leaderboard', ['auth'], 'achievement.view'],
// Admin: manage achievement definitions
['GET', '/achievements/admin', 'Achievements\Controllers\AchievementController@adminIndex', ['auth'], 'achievement.manage'],
['GET', '/achievements/admin/create', 'Achievements\Controllers\AchievementController@adminCreate', ['auth'], 'achievement.manage'],
['POST', '/achievements/admin', 'Achievements\Controllers\AchievementController@adminStore', ['auth', 'csrf'], 'achievement.manage'],
['POST', '/achievements/admin/{id:\d+}/toggle', 'Achievements\Controllers\AchievementController@adminToggle', ['auth', 'csrf'], 'achievement.manage'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Achievements\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Achievements\Models\AchievementDefinition;
use App\Modules\Achievements\Models\PlayerAchievement;
final class AchievementEngine
{
/**
* Check all active achievement criteria against an event and award if met.
*/
public static function checkAndAward(int $playerId, string $eventType, array $context = []): array
{
$awarded = [];
$definitions = AchievementDefinition::allActive();
foreach ($definitions as $def) {
$achievementId = (int) $def['id'];
// Skip if already earned
if (PlayerAchievement::hasEarned($playerId, $achievementId)) {
continue;
}
$criteria = [];
if (!empty($def['criteria_json'])) {
$criteria = json_decode($def['criteria_json'], true) ?: [];
}
// Check if this achievement's trigger matches the event
$trigger = $criteria['trigger'] ?? '';
if ($trigger !== '' && $trigger !== $eventType) {
continue;
}
// Evaluate criteria
if (self::evaluateCriteria($playerId, $criteria, $context)) {
self::awardAchievement($playerId, $achievementId, $context);
$awarded[] = $def;
}
}
return $awarded;
}
/**
* Award an achievement to a player.
*/
public static function awardAchievement(int $playerId, int $achievementId, array $context = []): void
{
// Double-check not already awarded
if (PlayerAchievement::hasEarned($playerId, $achievementId)) {
return;
}
$definition = AchievementDefinition::find($achievementId);
if (!$definition) {
return;
}
$points = (int) ($definition->points ?? 0);
PlayerAchievement::create([
'player_id' => $playerId,
'achievement_id' => $achievementId,
'earned_at' => date('Y-m-d H:i:s'),
'context_json' => !empty($context) ? json_encode($context, JSON_UNESCAPED_UNICODE) : null,
'points_earned' => $points,
'notified' => 0,
]);
EventBus::dispatch('player.achievement_earned', [
'player_id' => $playerId,
'achievement_id' => $achievementId,
'achievement_code' => $definition->code,
'achievement_name' => $definition->name_ar,
'points' => $points,
]);
Logger::info("Achievement '{$definition->code}' awarded to player #{$playerId} (+{$points} pts)");
}
/**
* Get leaderboard of top players by points.
*/
public static function getLeaderboard(?int $disciplineId = null, int $limit = 50): array
{
return PlayerAchievement::getLeaderboard($disciplineId, $limit);
}
/**
* Check attendance-based achievements for a player.
*/
public static function checkAttendanceAchievements(int $playerId): array
{
$db = App::getInstance()->db();
// Get attendance stats
$totalAttendance = $db->selectOne(
"SELECT COUNT(*) as cnt FROM session_attendance WHERE player_id = ? AND status = 'present'",
[$playerId]
);
$total = (int) ($totalAttendance['cnt'] ?? 0);
// Check consecutive attendance streak
$streak = self::calculateAttendanceStreak($playerId);
$context = [
'total_attendance' => $total,
'streak' => $streak,
];
return self::checkAndAward($playerId, 'attendance_check', $context);
}
/**
* Check milestone achievements (time-based, membership duration, etc.).
*/
public static function checkMilestoneAchievements(int $playerId): array
{
$db = App::getInstance()->db();
$player = $db->selectOne(
"SELECT created_at, date_of_birth FROM players WHERE id = ?",
[$playerId]
);
if (!$player) {
return [];
}
// Calculate membership duration in months
$registeredAt = new \DateTime($player['created_at']);
$now = new \DateTime();
$diff = $registeredAt->diff($now);
$monthsMember = ($diff->y * 12) + $diff->m;
$context = [
'months_member' => $monthsMember,
'registered_at' => $player['created_at'],
];
return self::checkAndAward($playerId, 'milestone_check', $context);
}
/**
* Evaluate achievement criteria against player data.
*/
private static function evaluateCriteria(int $playerId, array $criteria, array $context): bool
{
if (empty($criteria)) {
return false;
}
$type = $criteria['type'] ?? '';
switch ($type) {
case 'attendance_total':
$required = (int) ($criteria['count'] ?? 0);
$actual = (int) ($context['total_attendance'] ?? 0);
if ($actual <= 0) {
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COUNT(*) as cnt FROM session_attendance WHERE player_id = ? AND status = 'present'",
[$playerId]
);
$actual = (int) ($row['cnt'] ?? 0);
}
return $actual >= $required;
case 'attendance_streak':
$required = (int) ($criteria['count'] ?? 0);
$streak = (int) ($context['streak'] ?? self::calculateAttendanceStreak($playerId));
return $streak >= $required;
case 'membership_months':
$required = (int) ($criteria['months'] ?? 0);
$actual = (int) ($context['months_member'] ?? 0);
return $actual >= $required;
case 'fitness_score':
$required = (float) ($criteria['min_score'] ?? 0);
$db = App::getInstance()->db();
$latest = $db->selectOne(
"SELECT overall_fitness_score FROM player_fitness_tests WHERE player_id = ? ORDER BY test_date DESC LIMIT 1",
[$playerId]
);
$score = (float) ($latest['overall_fitness_score'] ?? 0);
return $score >= $required;
case 'evaluation_score':
$required = (float) ($criteria['min_score'] ?? 0);
$db = App::getInstance()->db();
$latest = $db->selectOne(
"SELECT overall_score FROM player_evaluations WHERE player_id = ? AND status = 'approved' ORDER BY evaluation_date DESC LIMIT 1",
[$playerId]
);
$score = (float) ($latest['overall_score'] ?? 0);
return $score >= $required;
case 'tournament_wins':
$required = (int) ($criteria['count'] ?? 0);
$actual = (int) ($context['total_wins'] ?? 0);
return $actual >= $required;
case 'custom':
// Custom criteria evaluated from context
$field = $criteria['field'] ?? '';
$operator = $criteria['operator'] ?? '>=';
$value = $criteria['value'] ?? 0;
$actual = $context[$field] ?? 0;
return self::compareValues($actual, $operator, $value);
default:
return false;
}
}
/**
* Calculate the current consecutive attendance streak for a player.
*/
private static function calculateAttendanceStreak(int $playerId): int
{
$db = App::getInstance()->db();
$records = $db->select(
"SELECT attendance_date, status
FROM session_attendance
WHERE player_id = ?
ORDER BY attendance_date DESC
LIMIT 100",
[$playerId]
);
$streak = 0;
foreach ($records as $record) {
if ($record['status'] === 'present') {
$streak++;
} else {
break;
}
}
return $streak;
}
/**
* Compare two values with an operator.
*/
private static function compareValues($actual, string $operator, $expected): bool
{
$actual = is_numeric($actual) ? (float) $actual : $actual;
$expected = is_numeric($expected) ? (float) $expected : $expected;
return match ($operator) {
'>=' => $actual >= $expected,
'>' => $actual > $expected,
'<=' => $actual <= $expected,
'<' => $actual < $expected,
'==' => $actual == $expected,
'!=' => $actual != $expected,
default => false,
};
}
}
This diff is collapsed.
<?php
use App\Modules\Achievements\Models\AchievementDefinition;
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>إدارة الإنجازات<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/achievements/admin/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'] ?? ''; ?>
<!-- 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="/achievements/admin"
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 ($categories as $catKey => $catLabel): ?>
<a href="/achievements/admin?category=<?= e($catKey) ?>"
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 -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/achievements/admin" 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">
</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="/achievements/admin" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<!-- Definitions Table -->
<?php if (!empty($definitions)): ?>
<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 15px;text-align:right;font-weight:600;color:#374151;">الإنجاز</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">الكود</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">التصنيف</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">النشاط</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">النقاط</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">الحالة</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">إجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($definitions as $def):
$isActive = (int) ($def['is_active'] ?? 0);
$badgeColor = $def['badge_color'] ?? 'gold';
if (strpos($badgeColor, '#') !== 0) {
$badgeColor = AchievementDefinition::getBadgeColors()[$badgeColor] ?? '#F59E0B';
}
$catLabel = $categories[$def['category'] ?? ''] ?? ($def['category'] ?? '');
?>
<tr style="border-bottom:1px solid #F3F4F6;<?= !$isActive ? 'opacity:0.6;' : '' ?>" onmouseover="this.style.background='#F9FAFB'" onmouseout="this.style.background='transparent'">
<td style="padding:12px 15px;">
<div style="display:flex;align-items:center;gap:10px;">
<div style="width:36px;height:36px;border-radius:50%;background:<?= $badgeColor ?>20;display:flex;align-items:center;justify-content:center;">
<i data-lucide="<?= e($def['icon'] ?? 'award') ?>" style="width:18px;height:18px;color:<?= $badgeColor ?>;"></i>
</div>
<div>
<div style="font-weight:600;color:#1A1A2E;"><?= e($def['name_ar'] ?? '') ?></div>
<?php if (!empty($def['description_ar'])): ?>
<div style="font-size:11px;color:#6B7280;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><?= e($def['description_ar']) ?></div>
<?php endif; ?>
</div>
</div>
</td>
<td style="padding:12px 15px;">
<code style="font-size:11px;background:#F3F4F6;padding:2px 6px;border-radius:4px;"><?= e($def['code'] ?? '') ?></code>
</td>
<td style="padding:12px 15px;font-size:13px;"><?= e($catLabel) ?></td>
<td style="padding:12px 15px;font-size:13px;"><?= e($def['discipline_name'] ?? 'عام') ?></td>
<td style="padding:12px 15px;font-weight:600;color:#F59E0B;"><?= (int) ($def['points'] ?? 0) ?></td>
<td style="padding:12px 15px;">
<span style="display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600;background:<?= $isActive ? '#05966915' : '#DC262615' ?>;color:<?= $isActive ? '#059669' : '#DC2626' ?>;">
<?= $isActive ? 'مفعّل' : 'معطّل' ?>
</span>
</td>
<td style="padding:12px 15px;">
<form method="POST" action="/achievements/admin/<?= (int) $def['id'] ?>/toggle" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<?= $isActive ? 'تعطيل' : 'تفعيل' ?>
</button>
</form>
</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="award" 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;">ابدأ بإنشاء تعريفات الإنجازات التي يمكن للاعبين الحصول عليها.</p>
<a href="/achievements/admin/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(); ?>
<?php
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>لوحة المتصدرين<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/achievements" class="btn btn-outline"><i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> الإنجازات</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Discipline Filter -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/achievements/leaderboard" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:200px;">
<label class="form-label" style="font-size:12px;">النشاط الرياضي</label>
<select name="discipline_id" class="form-input" onchange="this.form.submit()">
<option value="">جميع الأنشطة</option>
<?php foreach ($disciplines as $d): ?>
<option value="<?= (int) $d['id'] ?>" <?= $disciplineId == $d['id'] ? 'selected' : '' ?>><?= e($d['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline">تصفية</button>
</form>
</div>
<?php if (!empty($leaderboard)): ?>
<div class="card" style="padding:0;overflow-x:auto;">
<!-- Top 3 Podium -->
<?php if (count($leaderboard) >= 3): ?>
<div style="padding:30px 20px;display:flex;justify-content:center;align-items:end;gap:20px;background:linear-gradient(135deg, #F9FAFB, #EFF6FF);">
<!-- 2nd Place -->
<div style="text-align:center;order:1;">
<div style="width:60px;height:60px;border-radius:50%;background:#E5E7EB;display:flex;align-items:center;justify-content:center;margin:0 auto 8px;border:3px solid #9CA3AF;">
<span style="font-size:20px;font-weight:700;color:#6B7280;">2</span>
</div>
<div style="font-size:13px;font-weight:600;color:#1A1A2E;"><?= e($leaderboard[1]['player_name'] ?? '') ?></div>
<div style="font-size:16px;font-weight:700;color:#9CA3AF;"><?= number_format((int) ($leaderboard[1]['total_points'] ?? 0)) ?></div>
<div style="font-size:10px;color:#6B7280;">نقطة</div>
</div>
<!-- 1st Place -->
<div style="text-align:center;order:2;">
<div style="width:80px;height:80px;border-radius:50%;background:#FEF3C7;display:flex;align-items:center;justify-content:center;margin:0 auto 8px;border:4px solid #F59E0B;">
<i data-lucide="crown" style="width:32px;height:32px;color:#F59E0B;"></i>
</div>
<div style="font-size:15px;font-weight:700;color:#1A1A2E;"><?= e($leaderboard[0]['player_name'] ?? '') ?></div>
<div style="font-size:22px;font-weight:700;color:#F59E0B;"><?= number_format((int) ($leaderboard[0]['total_points'] ?? 0)) ?></div>
<div style="font-size:11px;color:#6B7280;">نقطة</div>
</div>
<!-- 3rd Place -->
<div style="text-align:center;order:3;">
<div style="width:60px;height:60px;border-radius:50%;background:#FEF3C7;display:flex;align-items:center;justify-content:center;margin:0 auto 8px;border:3px solid #B45309;">
<span style="font-size:20px;font-weight:700;color:#B45309;">3</span>
</div>
<div style="font-size:13px;font-weight:600;color:#1A1A2E;"><?= e($leaderboard[2]['player_name'] ?? '') ?></div>
<div style="font-size:16px;font-weight:700;color:#B45309;"><?= number_format((int) ($leaderboard[2]['total_points'] ?? 0)) ?></div>
<div style="font-size:10px;color:#6B7280;">نقطة</div>
</div>
</div>
<?php endif; ?>
<!-- Full Ranking Table -->
<table style="width:100%;border-collapse:collapse;font-size:14px;">
<thead>
<tr style="background:#F9FAFB;border-bottom:2px solid #E5E7EB;">
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;width:60px;">المركز</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">اللاعب</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">الإنجازات</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">النقاط</th>
</tr>
</thead>
<tbody>
<?php foreach ($leaderboard as $i => $entry):
$rank = $i + 1;
$rankColor = match(true) {
$rank === 1 => '#F59E0B',
$rank === 2 => '#9CA3AF',
$rank === 3 => '#B45309',
default => '#6B7280',
};
?>
<tr style="border-bottom:1px solid #F3F4F6;<?= $rank <= 3 ? 'background:#FFFBEB;' : '' ?>" onmouseover="this.style.background='#F9FAFB'" onmouseout="this.style.background='<?= $rank <= 3 ? '#FFFBEB' : 'transparent' ?>'">
<td style="padding:12px 15px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;background:<?= $rankColor ?>15;color:<?= $rankColor ?>;font-weight:700;font-size:13px;"><?= $rank ?></span>
</td>
<td style="padding:12px 15px;">
<a href="/achievements?player_id=<?= (int) $entry['player_id'] ?>" style="text-decoration:none;color:#1A1A2E;font-weight:600;">
<?= e($entry['player_name'] ?? '') ?>
</a>
<div style="font-size:11px;color:#9CA3AF;"><?= e($entry['registration_serial'] ?? '') ?></div>
</td>
<td style="padding:12px 15px;color:#374151;"><?= (int) ($entry['total_achievements'] ?? 0) ?></td>
<td style="padding:12px 15px;font-weight:700;color:<?= $rankColor ?>;"><?= number_format((int) ($entry['total_points'] ?? 0)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="trophy" 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;">لم يحصل أي لاعب على إنجازات بعد.</p>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
<?php
use App\Modules\Achievements\Models\AchievementDefinition;
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>إنجازات اللاعبين<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/achievements/leaderboard" class="btn btn-outline"><i data-lucide="trophy" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> لوحة المتصدرين</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Player Selector -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/achievements" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="flex:1;min-width:250px;">
<label class="form-label" style="font-size:12px;">اختر اللاعب</label>
<select name="player_id" class="form-input" onchange="this.form.submit()">
<option value="">-- اختر لاعب لعرض إنجازاته --</option>
<?php foreach ($players as $p): ?>
<option value="<?= (int) $p['id'] ?>" <?= $playerId == $p['id'] ? 'selected' : '' ?>><?= e($p['full_name_ar']) ?> (<?= e($p['registration_serial'] ?? '') ?>)</option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> عرض</button>
</form>
</div>
<?php if ($player): ?>
<!-- Player Header -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:20px;display:flex;justify-content:space-between;align-items:center;">
<div>
<h2 style="margin:0 0 4px;font-size:20px;color:#1A1A2E;">
<i data-lucide="award" style="width:22px;height:22px;vertical-align:middle;margin-left:8px;color:#F59E0B;"></i>
إنجازات <?= e($player['full_name_ar'] ?? '') ?>
</h2>
<div style="font-size:13px;color:#6B7280;"><?= count($achievements) ?> إنجاز مكتسب</div>
</div>
<div style="text-align:center;">
<div style="font-size:32px;font-weight:700;color:#F59E0B;"><?= number_format($totalPoints) ?></div>
<div style="font-size:12px;color:#6B7280;">نقطة</div>
</div>
</div>
</div>
<?php if (!empty($achievements)): ?>
<!-- Achievements by Category -->
<?php foreach ($grouped as $category => $categoryAchievements): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:15px;color:#374151;"><?= e($categories[$category] ?? $category) ?></h3>
</div>
<div style="padding:20px;display:grid;grid-template-columns:repeat(auto-fill, minmax(250px, 1fr));gap:15px;">
<?php foreach ($categoryAchievements as $ach):
$badgeColor = $ach['badge_color'] ?? '#F59E0B';
if (strpos($badgeColor, '#') !== 0) {
$badgeColor = AchievementDefinition::getBadgeColors()[$badgeColor] ?? '#F59E0B';
}
?>
<div style="border:1px solid #E5E7EB;border-radius:12px;padding:15px;display:flex;gap:12px;align-items:start;transition:box-shadow 0.2s;" onmouseover="this.style.boxShadow='0 4px 12px rgba(0,0,0,0.08)'" onmouseout="this.style.boxShadow='none'">
<div style="width:48px;height:48px;border-radius:50%;background:<?= $badgeColor ?>20;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i data-lucide="<?= e($ach['icon'] ?? 'award') ?>" style="width:24px;height:24px;color:<?= $badgeColor ?>;"></i>
</div>
<div style="flex:1;min-width:0;">
<div style="font-weight:600;font-size:14px;color:#1A1A2E;margin-bottom:2px;"><?= e($ach['name_ar'] ?? '') ?></div>
<?php if (!empty($ach['description_ar'])): ?>
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;"><?= e($ach['description_ar']) ?></div>
<?php endif; ?>
<div style="display:flex;gap:8px;align-items:center;">
<span style="font-size:11px;color:#F59E0B;font-weight:600;">+<?= (int) ($ach['points_earned'] ?? 0) ?> نقطة</span>
<span style="font-size:10px;color:#9CA3AF;"><?= e(substr($ach['earned_at'] ?? '', 0, 10)) ?></span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="award" 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;">هذا اللاعب لم يحصل على أي إنجازات حتى الآن.</p>
</div>
<?php endif; ?>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="award" 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;">اختر لاعب من القائمة أعلاه لعرض إنجازاته.</p>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
<?php
declare(strict_types=1);
use App\Core\Registries\PermissionRegistry;
use App\Core\Registries\MenuRegistry;
use App\Core\EventBus;
use App\Core\App;
use App\Core\Logger;
PermissionRegistry::register('achievements', [
'achievement.view' => ['ar' => 'عرض الإنجازات', 'en' => 'View Achievements'],
'achievement.manage' => ['ar' => 'إدارة الإنجازات', 'en' => 'Manage Achievements'],
]);
MenuRegistry::register('achievements', [
'label_ar' => 'الإنجازات',
'icon' => 'award',
'route' => '/achievements',
'permission' => 'achievement.view',
'order' => 420,
'parent' => 'sports_activities',
]);
// ─────────────────────────────────────────────────────────────
// Event Listeners for Achievement Checks
// ─────────────────────────────────────────────────────────────
// Attendance recorded → check attendance achievements
EventBus::listen('session_attendance.recorded', function (array $data) {
try {
$playerId = (int) ($data['player_id'] ?? 0);
if ($playerId <= 0) return;
\App\Modules\Achievements\Services\AchievementEngine::checkAttendanceAchievements($playerId);
} catch (\Throwable $e) {
Logger::error("Achievement attendance check error: " . $e->getMessage());
}
}, 10);
// Player evaluation submitted → check performance achievements
EventBus::listen('player.evaluation_submitted', function (array $data) {
try {
$playerId = (int) ($data['player_id'] ?? 0);
if ($playerId <= 0) return;
\App\Modules\Achievements\Services\AchievementEngine::checkAndAward($playerId, 'evaluation_submitted', $data);
} catch (\Throwable $e) {
Logger::error("Achievement evaluation check error: " . $e->getMessage());
}
}, 10);
// Tournament match won → check competition achievements
EventBus::listen('tournament.match_won', function (array $data) {
try {
$playerId = (int) ($data['player_id'] ?? 0);
if ($playerId <= 0) return;
\App\Modules\Achievements\Services\AchievementEngine::checkAndAward($playerId, 'tournament_win', $data);
} catch (\Throwable $e) {
Logger::error("Achievement tournament check error: " . $e->getMessage());
}
}, 10);
// Fitness test recorded → check fitness achievements
EventBus::listen('player.fitness_test_recorded', function (array $data) {
try {
$playerId = (int) ($data['player_id'] ?? 0);
if ($playerId <= 0) return;
\App\Modules\Achievements\Services\AchievementEngine::checkAndAward($playerId, 'fitness_test', $data);
} catch (\Throwable $e) {
Logger::error("Achievement fitness check error: " . $e->getMessage());
}
}, 10);
// Injury recovered → check recovery achievements
EventBus::listen('player.injury_recovered', function (array $data) {
try {
$playerId = (int) ($data['player_id'] ?? 0);
if ($playerId <= 0) return;
\App\Modules\Achievements\Services\AchievementEngine::checkAndAward($playerId, 'injury_recovered', $data);
} catch (\Throwable $e) {
Logger::error("Achievement recovery check error: " . $e->getMessage());
}
}, 10);
<?php
declare(strict_types=1);
namespace App\Modules\Carnets\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Carnets\Models\CarnetGuestEntry;
use App\Modules\Carnets\Services\GuestEntryService;
class GuestEntryController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('carnet.view_guests');
$filters = [
'search' => trim((string) $request->get('q', '')),
'member_id' => $request->get('member_id', ''),
'facility_id' => $request->get('facility_id', ''),
'activity_type' => $request->get('activity_type', ''),
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = CarnetGuestEntry::search($filters, 25, $page);
$db = App::getInstance()->db();
$facilities = $db->select("SELECT id, name_ar FROM facilities WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('Carnets.Views.guest_entries', [
'rows' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'facilities' => $facilities,
'activityTypes' => CarnetGuestEntry::getActivityTypes(),
]);
}
public function create(Request $request): Response
{
$this->authorize('carnet.record_guest');
$db = App::getInstance()->db();
$facilities = $db->select("SELECT id, name_ar FROM facilities WHERE is_archived = 0 ORDER BY name_ar");
$memberId = $request->get('member_id');
$carnet = null;
$member = null;
if ($memberId) {
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]);
if ($member) {
$carnet = $db->selectOne("SELECT * FROM carnets WHERE member_id = ? AND is_active = 1 ORDER BY id DESC LIMIT 1", [(int) $memberId]);
}
}
return $this->view('Carnets.Views.guest_entry_create', [
'facilities' => $facilities,
'activityTypes' => CarnetGuestEntry::getActivityTypes(),
'member' => $member,
'carnet' => $carnet,
]);
}
public function store(Request $request): Response
{
$this->authorize('carnet.record_guest');
$rules = [
'carnet_id' => 'required|numeric',
'guest_name' => 'required|string|max:200',
'activity_type' => 'required|string',
];
$errors = $this->validate($request->all(), $rules);
if ($errors) {
return $this->redirect('/carnets/guest-entries/create?member_id=' . $request->post('member_id', ''))
->withError('يرجى تصحيح الأخطاء');
}
$carnetId = (int) $request->post('carnet_id');
$eligibilityErrors = GuestEntryService::checkEligibility($carnetId);
if (!empty($eligibilityErrors)) {
return $this->redirect('/carnets/guest-entries/create?member_id=' . $request->post('member_id', ''))
->withError(implode(' | ', $eligibilityErrors));
}
$db = App::getInstance()->db();
$carnet = $db->selectOne("SELECT * FROM carnets WHERE id = ?", [$carnetId]);
$employee = App::getInstance()->currentEmployee();
$entry = GuestEntryService::recordEntry([
'carnet_id' => $carnetId,
'member_id' => (int) $carnet['member_id'],
'guest_name' => trim((string) $request->post('guest_name')),
'guest_phone' => trim((string) $request->post('guest_phone', '')),
'guest_national_id'=> trim((string) $request->post('guest_national_id', '')),
'guest_count' => max(1, (int) $request->post('guest_count', 1)),
'entry_date' => date('Y-m-d'),
'entry_time' => date('H:i:s'),
'facility_id' => $request->post('facility_id') ? (int) $request->post('facility_id') : null,
'activity_type' => $request->post('activity_type'),
'amount_paid' => (float) $request->post('amount_paid', 0),
'notes' => trim((string) $request->post('notes', '')),
'recorded_by' => $employee ? (int) $employee->id : null,
'status' => 'active',
]);
$remaining = GuestEntryService::getRemainingInvitations($carnetId);
return $this->redirect('/carnets/guest-entries')
->withSuccess("تم تسجيل دخول الضيف: {$request->post('guest_name')} — متبقي {$remaining} دعوة");
}
public function checkout(Request $request, string $id): Response
{
$this->authorize('carnet.record_guest');
$success = GuestEntryService::checkOut((int) $id);
if (!$success) {
return $this->redirect('/carnets/guest-entries')->withError('لم يتم العثور على سجل دخول نشط');
}
return $this->redirect('/carnets/guest-entries')->withSuccess('تم تسجيل خروج الضيف');
}
public function cancel(Request $request, string $id): Response
{
$this->authorize('carnet.record_guest');
$reason = trim((string) $request->post('reason', ''));
$success = GuestEntryService::cancelEntry((int) $id, $reason);
if (!$success) {
return $this->redirect('/carnets/guest-entries')->withError('لم يتم العثور على سجل دخول نشط');
}
return $this->redirect('/carnets/guest-entries')->withSuccess('تم إلغاء دخول الضيف وإرجاع الدعوة');
}
public function memberSummary(Request $request, string $memberId): Response
{
$this->authorize('carnet.view_guests');
$summary = GuestEntryService::getSummaryForMember((int) $memberId);
return $this->json($summary);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Carnets\Models;
use App\Core\Model;
use App\Core\App;
class CarnetGuestEntry extends Model
{
protected static string $table = 'carnet_guest_entries';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static array $fillable = [
'carnet_id', 'member_id', 'guest_name', 'guest_phone', 'guest_national_id',
'guest_count', 'entry_date', 'entry_time', 'exit_time', 'facility_id',
'activity_type', 'reservation_id', 'pool_booking_id', 'amount_paid',
'payment_id', 'status', 'notes', 'recorded_by',
];
public static function getActivityTypes(): array
{
return [
'free_swim' => 'سباحة ترفيهية',
'court_booking' => 'حجز ملعب',
'recreation' => 'ترفيه عام',
'ping_pong' => 'تنس طاولة',
'bowling' => 'بولينج',
'playstation' => 'بلاي ستيشن',
'tennis' => 'تنس',
'paddle' => 'بادل',
'gym' => 'جيم',
];
}
public static function getForCarnet(int $carnetId, ?string $dateFrom = null, ?string $dateTo = null): array
{
$db = App::getInstance()->db();
$sql = "SELECT cge.*, f.name_ar AS facility_name
FROM carnet_guest_entries cge
LEFT JOIN facilities f ON f.id = cge.facility_id
WHERE cge.carnet_id = ?";
$params = [$carnetId];
if ($dateFrom) {
$sql .= " AND cge.entry_date >= ?";
$params[] = $dateFrom;
}
if ($dateTo) {
$sql .= " AND cge.entry_date <= ?";
$params[] = $dateTo;
}
$sql .= " ORDER BY cge.entry_date DESC, cge.entry_time DESC";
return $db->select($sql, $params);
}
public static function getForMember(int $memberId, int $limit = 50): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT cge.*, f.name_ar AS facility_name, c.carnet_number
FROM carnet_guest_entries cge
LEFT JOIN facilities f ON f.id = cge.facility_id
LEFT JOIN carnets c ON c.id = cge.carnet_id
WHERE cge.member_id = ?
ORDER BY cge.entry_date DESC, cge.entry_time DESC
LIMIT ?",
[$memberId, $limit]
);
}
public static function getTodayCount(int $carnetId): int
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COALESCE(SUM(guest_count), 0) AS cnt FROM carnet_guest_entries WHERE carnet_id = ? AND entry_date = CURDATE() AND status != 'cancelled'",
[$carnetId]
);
return (int) ($row['cnt'] ?? 0);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = ["1=1"];
$params = [];
if (!empty($filters['member_id'])) {
$where[] = "cge.member_id = ?";
$params[] = (int) $filters['member_id'];
}
if (!empty($filters['facility_id'])) {
$where[] = "cge.facility_id = ?";
$params[] = (int) $filters['facility_id'];
}
if (!empty($filters['activity_type'])) {
$where[] = "cge.activity_type = ?";
$params[] = $filters['activity_type'];
}
if (!empty($filters['date_from'])) {
$where[] = "cge.entry_date >= ?";
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where[] = "cge.entry_date <= ?";
$params[] = $filters['date_to'];
}
if (!empty($filters['search'])) {
$where[] = "(cge.guest_name LIKE ? OR cge.guest_phone LIKE ?)";
$params[] = '%' . $filters['search'] . '%';
$params[] = '%' . $filters['search'] . '%';
}
$whereClause = implode(' AND ', $where);
$countRow = $db->selectOne(
"SELECT COUNT(*) AS total FROM carnet_guest_entries cge WHERE {$whereClause}",
$params
);
$total = (int) ($countRow['total'] ?? 0);
$offset = ($page - 1) * $perPage;
$data = $db->select(
"SELECT cge.*, f.name_ar AS facility_name, c.carnet_number, m.full_name_ar AS member_name
FROM carnet_guest_entries cge
LEFT JOIN facilities f ON f.id = cge.facility_id
LEFT JOIN carnets c ON c.id = cge.carnet_id
LEFT JOIN members m ON m.id = cge.member_id
WHERE {$whereClause}
ORDER BY cge.entry_date DESC, cge.entry_time DESC
LIMIT ? OFFSET ?",
array_merge($params, [$perPage, $offset])
);
return [
'data' => $data,
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => (int) ceil($total / $perPage),
],
];
}
}
......@@ -8,4 +8,12 @@ return [
['GET', '/carnets/{id}/print', 'Carnets\Controllers\CarnetController@print', ['auth'], 'carnet.print'],
['POST', '/carnets/{id}/deactivate', 'Carnets\Controllers\CarnetController@deactivate', ['auth', 'csrf'], 'carnet.deactivate'],
['GET', '/carnets/replace/{memberId}', 'Carnets\Controllers\CarnetController@replace', ['auth'], 'carnet.issue'],
// Guest Entries (Invitation Usage)
['GET', '/carnets/guest-entries', 'Carnets\Controllers\GuestEntryController@index', ['auth'], 'carnet.view_guests'],
['GET', '/carnets/guest-entries/create', 'Carnets\Controllers\GuestEntryController@create', ['auth'], 'carnet.record_guest'],
['POST', '/carnets/guest-entries', 'Carnets\Controllers\GuestEntryController@store', ['auth', 'csrf'], 'carnet.record_guest'],
['POST', '/carnets/guest-entries/{id:\d+}/checkout', 'Carnets\Controllers\GuestEntryController@checkout', ['auth', 'csrf'], 'carnet.record_guest'],
['POST', '/carnets/guest-entries/{id:\d+}/cancel', 'Carnets\Controllers\GuestEntryController@cancel', ['auth', 'csrf'], 'carnet.record_guest'],
['GET', '/api/carnets/member/{memberId:\d+}/summary','Carnets\Controllers\GuestEntryController@memberSummary', ['auth'], 'carnet.view_guests'],
];
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Carnets\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\Carnets\Models\Carnet;
use App\Modules\Carnets\Models\CarnetGuestEntry;
class GuestEntryService
{
public static function checkEligibility(int $carnetId): array
{
$db = App::getInstance()->db();
$carnet = $db->selectOne("SELECT * FROM carnets WHERE id = ? AND is_active = 1", [$carnetId]);
if (!$carnet) {
return ['الكارنيه غير موجود أو غير نشط'];
}
$errors = [];
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $carnet['member_id']]);
if (!$member) {
$errors[] = 'العضو صاحب الكارنيه غير موجود';
return $errors;
}
if (($member['membership_status'] ?? '') !== 'active') {
$errors[] = 'عضوية صاحب الكارنيه غير نشطة';
}
$used = (int) ($carnet['used_invitations'] ?? 0);
$total = (int) ($carnet['total_invitations'] ?? 10);
if ($used >= $total) {
$errors[] = "تم استنفاذ جميع الدعوات ({$total}/{$total})";
}
return $errors;
}
public static function getRemainingInvitations(int $carnetId): int
{
$db = App::getInstance()->db();
$carnet = $db->selectOne("SELECT total_invitations, used_invitations FROM carnets WHERE id = ?", [$carnetId]);
if (!$carnet) return 0;
return max(0, (int) $carnet['total_invitations'] - (int) $carnet['used_invitations']);
}
public static function recordEntry(array $data): CarnetGuestEntry
{
$db = App::getInstance()->db();
$carnetId = (int) $data['carnet_id'];
$entry = CarnetGuestEntry::create($data);
$guestCount = (int) ($data['guest_count'] ?? 1);
$db->query(
"UPDATE carnets SET used_invitations = used_invitations + ? WHERE id = ?",
[$guestCount, $carnetId]
);
EventBus::dispatch('carnet_guest.entry_recorded', [
'entry_id' => (int) $entry->id,
'carnet_id' => $carnetId,
'member_id' => (int) $data['member_id'],
'guest_name' => $data['guest_name'],
'facility_id'=> $data['facility_id'] ?? null,
'activity' => $data['activity_type'],
'amount' => (float) ($data['amount_paid'] ?? 0),
]);
$remaining = self::getRemainingInvitations($carnetId);
if ($remaining <= 2 && $remaining > 0) {
EventBus::dispatch('carnet.low_balance', [
'carnet_id' => $carnetId,
'member_id' => (int) $data['member_id'],
'remaining' => $remaining,
]);
}
return $entry;
}
public static function checkOut(int $entryId): bool
{
$db = App::getInstance()->db();
$entry = $db->selectOne("SELECT * FROM carnet_guest_entries WHERE id = ? AND status = 'active'", [$entryId]);
if (!$entry) return false;
$db->update('carnet_guest_entries', [
'exit_time' => date('H:i:s'),
'status' => 'checked_out',
'updated_at'=> date('Y-m-d H:i:s'),
], '`id` = ?', [$entryId]);
return true;
}
public static function cancelEntry(int $entryId, string $reason = ''): bool
{
$db = App::getInstance()->db();
$entry = $db->selectOne("SELECT * FROM carnet_guest_entries WHERE id = ? AND status = 'active'", [$entryId]);
if (!$entry) return false;
$db->update('carnet_guest_entries', [
'status' => 'cancelled',
'notes' => $reason,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$entryId]);
$guestCount = (int) ($entry['guest_count'] ?? 1);
$db->query(
"UPDATE carnets SET used_invitations = GREATEST(0, used_invitations - ?) WHERE id = ?",
[$guestCount, (int) $entry['carnet_id']]
);
return true;
}
public static function getSummaryForMember(int $memberId): array
{
$db = App::getInstance()->db();
$carnet = $db->selectOne(
"SELECT * FROM carnets WHERE member_id = ? AND is_active = 1 ORDER BY id DESC LIMIT 1",
[$memberId]
);
if (!$carnet) {
return ['carnet' => null, 'remaining' => 0, 'total_used' => 0, 'entries' => []];
}
$entries = CarnetGuestEntry::getForCarnet((int) $carnet['id']);
return [
'carnet' => $carnet,
'remaining' => max(0, (int) $carnet['total_invitations'] - (int) $carnet['used_invitations']),
'total_used' => (int) $carnet['used_invitations'],
'entries' => $entries,
];
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>سجل دخول الضيوف<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/carnets/guest-entries/create" class="btn btn-primary">+ تسجيل دخول ضيف</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/carnets/guest-entries" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div><label class="form-label" style="font-size:12px;">بحث</label><input type="text" name="q" value="<?= e($filters['search'] ?? '') ?>" placeholder="اسم الضيف أو الهاتف..." class="form-input" style="min-width:180px;"></div>
<div><label class="form-label" style="font-size:12px;">المرفق</label><select name="facility_id" class="form-select"><option value="">الكل</option><?php foreach ($facilities as $f): ?><option value="<?= (int) $f['id'] ?>" <?= ($filters['facility_id'] ?? '') == $f['id'] ? 'selected' : '' ?>><?= e($f['name_ar']) ?></option><?php endforeach; ?></select></div>
<div><label class="form-label" style="font-size:12px;">النشاط</label><select name="activity_type" class="form-select"><option value="">الكل</option><?php foreach ($activityTypes as $k => $v): ?><option value="<?= e($k) ?>" <?= ($filters['activity_type'] ?? '') === $k ? 'selected' : '' ?>><?= e($v) ?></option><?php endforeach; ?></select></div>
<div><label class="form-label" style="font-size:12px;">من</label><input type="date" name="date_from" value="<?= e($filters['date_from'] ?? '') ?>" class="form-input"></div>
<div><label class="form-label" style="font-size:12px;">إلى</label><input type="date" name="date_to" value="<?= e($filters['date_to'] ?? '') ?>" class="form-input"></div>
<button type="submit" class="btn btn-outline">بحث</button>
<a href="/carnets/guest-entries" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<div class="card"><div class="table-responsive"><table class="data-table"><thead><tr>
<th>التاريخ</th><th>الضيف</th><th>الهاتف</th><th>العضو</th><th>الكارنيه</th><th>المرفق</th><th>النشاط</th><th>عدد</th><th>المبلغ</th><th>الحالة</th><th>الإجراءات</th>
</tr></thead><tbody>
<?php if (empty($rows)): ?>
<tr><td colspan="11" style="text-align:center;padding:40px;color:#6B7280;">لا توجد سجلات دخول ضيوف</td></tr>
<?php else: ?>
<?php foreach ($rows as $r): ?>
<tr>
<td style="font-size:13px;white-space:nowrap;"><?= e($r['entry_date']) ?><br><small style="color:#6B7280;"><?= e(substr($r['entry_time'], 0, 5)) ?></small></td>
<td style="font-weight:600;"><?= e($r['guest_name']) ?></td>
<td style="font-size:13px;direction:ltr;text-align:right;"><?= e($r['guest_phone'] ?? '—') ?></td>
<td><a href="/members/<?= (int) $r['member_id'] ?>" style="color:#0D7377;"><?= e($r['member_name'] ?? '—') ?></a></td>
<td style="font-size:12px;direction:ltr;text-align:right;"><?= e($r['carnet_number'] ?? '') ?></td>
<td style="font-size:13px;"><?= e($r['facility_name'] ?? '—') ?></td>
<td style="font-size:13px;"><?= e($activityTypes[$r['activity_type']] ?? $r['activity_type']) ?></td>
<td style="text-align:center;"><?= (int) $r['guest_count'] ?></td>
<td style="font-weight:600;"><?= money($r['amount_paid'] ?? 0) ?></td>
<td>
<?php if ($r['status'] === 'active'): ?>
<span style="color:#059669;font-weight:600;">● داخل</span>
<?php elseif ($r['status'] === 'checked_out'): ?>
<span style="color:#6B7280;">● خرج</span>
<?php else: ?>
<span style="color:#DC2626;">● ملغى</span>
<?php endif; ?>
</td>
<td>
<?php if ($r['status'] === 'active'): ?>
<div style="display:flex;gap:4px;">
<form method="POST" action="/carnets/guest-entries/<?= (int) $r['id'] ?>/checkout" style="display:inline;"><?= csrf_field() ?><button type="submit" class="btn btn-sm btn-outline">خروج</button></form>
<form method="POST" action="/carnets/guest-entries/<?= (int) $r['id'] ?>/cancel" style="display:inline;"><?= csrf_field() ?><input type="hidden" name="reason" value="إلغاء يدوي"><button type="submit" class="btn btn-sm btn-outline" style="color:#DC2626;" onclick="return confirm('إلغاء الدخول؟')">إلغاء</button></form>
</div>
<?php else: ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody></table></div>
<?php if (($pagination['last_page'] ?? 1) > 1): ?>
<div style="display:flex;justify-content:center;gap:5px;margin-top:15px;">
<?php for ($p = 1; $p <= $pagination['last_page']; $p++): ?>
<a href="?page=<?= $p ?>&<?= http_build_query(array_filter($filters)) ?>" class="btn btn-sm <?= $p === $pagination['current_page'] ? 'btn-primary' : 'btn-outline' ?>"><?= $p ?></a>
<?php endfor; ?>
</div>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تسجيل دخول ضيف<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="max-width:700px;margin:0 auto;padding:25px;">
<h3 style="margin-bottom:20px;color:#0D7377;">تسجيل دخول ضيف بالدعوة</h3>
<?php if (!$carnet): ?>
<div style="margin-bottom:20px;">
<label class="form-label">بحث عن العضو صاحب الكارنيه</label>
<div style="display:flex;gap:10px;">
<input type="text" id="member-search" placeholder="اسم العضو أو رقم العضوية..." class="form-input" style="flex:1;">
<button type="button" id="search-btn" class="btn btn-outline">بحث</button>
</div>
<div id="search-results" style="margin-top:10px;"></div>
</div>
<?php endif; ?>
<?php if ($carnet): ?>
<?php
$remaining = max(0, (int) ($carnet['total_invitations'] ?? 10) - (int) ($carnet['used_invitations'] ?? 0));
$total = (int) ($carnet['total_invitations'] ?? 10);
?>
<div style="background:#F0FDF4;border:1px solid #BBF7D0;border-radius:8px;padding:15px;margin-bottom:20px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<strong><?= e($member['full_name_ar'] ?? '') ?></strong>
<span style="margin-right:10px;font-size:13px;color:#6B7280;">كارنيه: <?= e($carnet['carnet_number']) ?></span>
</div>
<div style="background:#fff;border-radius:20px;padding:5px 15px;font-weight:700;color:<?= $remaining <= 2 ? '#DC2626' : '#059669' ?>;">
<?= $remaining ?> / <?= $total ?> دعوة متبقية
</div>
</div>
</div>
<?php if ($remaining <= 0): ?>
<div style="background:#FEF2F2;border:1px solid #FECACA;border-radius:8px;padding:15px;text-align:center;color:#DC2626;font-weight:600;">
تم استنفاذ جميع الدعوات لهذا الكارنيه
</div>
<?php else: ?>
<form method="POST" action="/carnets/guest-entries">
<?= csrf_field() ?>
<input type="hidden" name="carnet_id" value="<?= (int) $carnet['id'] ?>">
<input type="hidden" name="member_id" value="<?= (int) $member['id'] ?>">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div style="grid-column:span 2;">
<label class="form-label">اسم الضيف <span style="color:#DC2626;">*</span></label>
<input type="text" name="guest_name" value="<?= e(old('guest_name')) ?>" class="form-input" required>
</div>
<div>
<label class="form-label">هاتف الضيف</label>
<input type="text" name="guest_phone" value="<?= e(old('guest_phone')) ?>" class="form-input" style="direction:ltr;text-align:right;">
</div>
<div>
<label class="form-label">الرقم القومي</label>
<input type="text" name="guest_national_id" value="<?= e(old('guest_national_id')) ?>" class="form-input" style="direction:ltr;text-align:right;">
</div>
<div>
<label class="form-label">عدد الضيوف</label>
<input type="number" name="guest_count" value="1" min="1" max="<?= $remaining ?>" class="form-input" style="direction:ltr;text-align:right;">
</div>
<div>
<label class="form-label">النشاط <span style="color:#DC2626;">*</span></label>
<select name="activity_type" class="form-select" required>
<option value="">— اختر —</option>
<?php foreach ($activityTypes as $k => $v): ?>
<option value="<?= e($k) ?>"><?= e($v) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">المرفق</label>
<select name="facility_id" class="form-select">
<option value="">— اختياري —</option>
<?php foreach ($facilities as $f): ?>
<option value="<?= (int) $f['id'] ?>"><?= e($f['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">المبلغ المدفوع</label>
<input type="number" name="amount_paid" value="0" min="0" step="0.01" class="form-input" style="direction:ltr;text-align:right;">
</div>
<div style="grid-column:span 2;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-textarea" rows="2"><?= e(old('notes')) ?></textarea>
</div>
</div>
<div style="margin-top:20px;display:flex;gap:10px;justify-content:flex-start;">
<button type="submit" class="btn btn-primary">تسجيل الدخول</button>
<a href="/carnets/guest-entries" class="btn btn-outline">إلغاء</a>
</div>
</form>
<?php endif; ?>
<?php endif; ?>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('member-search');
const searchBtn = document.getElementById('search-btn');
const resultsDiv = document.getElementById('search-results');
if (!searchBtn) return;
searchBtn.addEventListener('click', function() {
const q = (searchInput.value || '').trim();
if (q.length < 2) return;
fetch('/api/carnets/member-search?q=' + encodeURIComponent(q))
.then(r => r.json())
.then(data => {
if (!data.length) { resultsDiv.innerHTML = '<p style="color:#6B7280;">لا توجد نتائج</p>'; return; }
resultsDiv.innerHTML = data.map(m =>
'<a href="/carnets/guest-entries/create?member_id=' + m.id + '" style="display:block;padding:8px 12px;border:1px solid #E5E7EB;border-radius:6px;margin-bottom:5px;text-decoration:none;color:#1F2937;">' +
'<strong>' + m.full_name_ar + '</strong> <span style="color:#6B7280;font-size:13px;">' + (m.membership_number || '') + '</span></a>'
).join('');
});
});
searchInput.addEventListener('keypress', function(e) { if (e.key === 'Enter') { e.preventDefault(); searchBtn.click(); } });
});
</script>
<?php $__template->endSection(); ?>
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
// Menu registered centrally via Members/bootstrap.php under "membership" parent.
MenuRegistry::register('carnets', [
'label_ar' => 'الكارنيهات والدعوات',
'label_en' => 'Carnets & Invitations',
'icon' => 'id-card',
'route' => '/carnets',
'permission' => 'carnet.view',
'parent' => null,
'order' => 340,
'children' => [
['label_ar' => 'الكارنيهات', 'label_en' => 'Carnets', 'route' => '/carnets', 'permission' => 'carnet.view', 'order' => 1],
['label_ar' => 'دخول الضيوف', 'label_en' => 'Guest Entries', 'route' => '/carnets/guest-entries', 'permission' => 'carnet.view_guests', 'order' => 2],
['label_ar' => 'تسجيل دخول ضيف', 'label_en' => 'Record Entry', 'route' => '/carnets/guest-entries/create', 'permission' => 'carnet.record_guest', 'order' => 3],
],
]);
PermissionRegistry::register('carnets', [
'carnet.print' => ['ar' => 'طباعة كارنيه', 'en' => 'Print Carnet'],
'carnet.replace' => ['ar' => 'بدل فاقد كارنيه', 'en' => 'Replace Carnet'],
'carnet.view_log' => ['ar' => 'عرض سجل الكارنيهات','en' => 'View Carnet Log'],
'carnet.view' => ['ar' => 'عرض الكارنيهات', 'en' => 'View Carnets'],
'carnet.issue' => ['ar' => 'إصدار كارنيه', 'en' => 'Issue Carnet'],
'carnet.print' => ['ar' => 'طباعة كارنيه', 'en' => 'Print Carnet'],
'carnet.deactivate' => ['ar' => 'إلغاء كارنيه', 'en' => 'Deactivate Carnet'],
'carnet.replace' => ['ar' => 'بدل فاقد كارنيه', 'en' => 'Replace Carnet'],
'carnet.view_log' => ['ar' => 'عرض سجل الكارنيهات', 'en' => 'View Carnet Log'],
'carnet.view_guests' => ['ar' => 'عرض سجل دخول الضيوف', 'en' => 'View Guest Entries'],
'carnet.record_guest' => ['ar' => 'تسجيل دخول ضيف', 'en' => 'Record Guest Entry'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Coaches\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Coaches\Models\Coach;
use App\Modules\Coaches\Models\CoachPerformanceMetric;
use App\Modules\Coaches\Services\CoachPerformanceService;
class CoachPerformanceController extends Controller
{
/**
* Performance dashboard - overview of all coaches.
*/
public function dashboard(Request $request): Response
{
$this->authorize('coach.view_performance');
$yearMonth = trim((string) $request->get('month', date('Y-m')));
// Get ranking for the month
$ranking = CoachPerformanceService::getRanking($yearMonth);
// Available months (last 12)
$months = [];
for ($i = 0; $i < 12; $i++) {
$m = date('Y-m', strtotime("-{$i} months"));
$months[] = $m;
}
return $this->view('Coaches.Views.performance_dashboard', [
'ranking' => $ranking,
'yearMonth' => $yearMonth,
'months' => $months,
]);
}
/**
* Detailed performance view for a single coach.
*/
public function detail(Request $request, int $id): Response
{
$this->authorize('coach.view_performance');
$coach = Coach::findOrFail($id);
$fromMonth = trim((string) $request->get('from', date('Y-m', strtotime('-6 months'))));
$toMonth = trim((string) $request->get('to', date('Y-m')));
$report = CoachPerformanceService::getPerformanceReport($id, $fromMonth, $toMonth);
$summary = CoachPerformanceService::getCoachSummary($id);
// Get rating averages
$db = App::getInstance()->db();
$ratingAverages = $db->selectOne(
"SELECT AVG(rating) AS avg_rating, COUNT(*) AS total_ratings
FROM coach_ratings WHERE coach_id = ? AND status = 'published'",
[$id]
);
return $this->view('Coaches.Views.performance_detail', [
'coach' => $coach,
'report' => $report,
'summary' => $summary,
'fromMonth' => $fromMonth,
'toMonth' => $toMonth,
'ratingAverages' => $ratingAverages,
]);
}
/**
* Performance ranking view.
*/
public function ranking(Request $request): Response
{
$this->authorize('coach.view_performance');
$yearMonth = trim((string) $request->get('month', date('Y-m')));
$ranking = CoachPerformanceService::getRanking($yearMonth);
$months = [];
for ($i = 0; $i < 12; $i++) {
$months[] = date('Y-m', strtotime("-{$i} months"));
}
return $this->view('Coaches.Views.performance_ranking', [
'ranking' => $ranking,
'yearMonth' => $yearMonth,
'months' => $months,
]);
}
/**
* Trigger performance recalculation for a month (POST).
*/
public function calculate(Request $request): Response
{
$this->authorize('coach.view_performance');
$yearMonth = trim((string) $request->post('month', date('Y-m')));
$coachId = $request->post('coach_id', '') !== '' ? (int) $request->post('coach_id') : null;
try {
if ($coachId) {
CoachPerformanceService::calculateMonthlyMetrics($coachId, $yearMonth);
return $this->redirect('/coach-performance/' . $coachId)->withSuccess("تم إعادة حساب أداء المدرب لشهر {$yearMonth}");
} else {
$count = CoachPerformanceService::calculateAllCoachesMetrics($yearMonth);
return $this->redirect('/coach-performance?month=' . $yearMonth)->withSuccess("تم حساب أداء {$count} مدرب لشهر {$yearMonth}");
}
} catch (\Throwable $e) {
return $this->back()->withError('حدث خطأ: ' . $e->getMessage());
}
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Coaches\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Coaches\Models\Coach;
use App\Modules\Coaches\Models\CoachRating;
use App\Modules\Coaches\Services\CoachRatingService;
class CoachRatingController extends Controller
{
/**
* List ratings for a coach.
*/
public function index(Request $request): Response
{
$this->authorize('coach.rate');
$coachId = $request->get('coach_id', '') !== '' ? (int) $request->get('coach_id') : null;
$filters = [
'coach_id' => $coachId,
'status' => trim((string) $request->get('status', '')),
'min_rating' => $request->get('min_rating', '') !== '' ? (int) $request->get('min_rating') : null,
];
$page = max(1, (int) $request->get('page', 1));
$result = CoachRating::search($filters, 25, $page);
$db = App::getInstance()->db();
$coaches = $db->select(
"SELECT id, full_name_ar FROM coaches WHERE is_active = 1 AND is_archived = 0 ORDER BY full_name_ar ASC"
);
$averages = null;
$coach = null;
if ($coachId) {
$averages = CoachRating::getAverages($coachId);
$coach = $db->selectOne("SELECT * FROM coaches WHERE id = ?", [$coachId]);
}
return $this->view('Coaches.Views.ratings', [
'ratings' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'coaches' => $coaches,
'coach' => $coach,
'averages' => $averages,
'coachId' => $coachId,
]);
}
/**
* Show rating form for a coach.
*/
public function create(Request $request): Response
{
$this->authorize('coach.rate');
$coachId = (int) $request->get('coach_id', 0);
$sessionId = $request->get('session_id', '') !== '' ? (int) $request->get('session_id') : null;
$db = App::getInstance()->db();
$coach = null;
if ($coachId) {
$coach = $db->selectOne("SELECT * FROM coaches WHERE id = ?", [$coachId]);
}
$coaches = $db->select(
"SELECT id, full_name_ar FROM coaches WHERE is_active = 1 AND is_archived = 0 ORDER BY full_name_ar ASC"
);
$players = $db->select(
"SELECT id, full_name_ar, registration_serial FROM players WHERE is_archived = 0 ORDER BY full_name_ar ASC"
);
return $this->view('Coaches.Views.rate_coach', [
'coach' => $coach,
'coaches' => $coaches,
'players' => $players,
'coachId' => $coachId,
'sessionId' => $sessionId,
]);
}
/**
* Store a new rating.
*/
public function store(Request $request): Response
{
$this->authorize('coach.rate');
$data = [
'coach_id' => (int) $request->post('coach_id', 0),
'player_id' => (int) $request->post('player_id', 0),
'session_id' => $request->post('session_id', '') !== '' ? (int) $request->post('session_id') : null,
'punctuality_score' => (int) $request->post('punctuality_score', 0),
'communication_score' => (int) $request->post('communication_score', 0),
'technique_score' => (int) $request->post('technique_score', 0),
'motivation_score' => (int) $request->post('motivation_score', 0),
'feedback_ar' => trim((string) $request->post('feedback_ar', '')),
'is_anonymous' => (int) $request->post('is_anonymous', 0),
];
// Validation
$errors = [];
if ($data['coach_id'] <= 0) $errors[] = 'يجب اختيار المدرب';
if ($data['player_id'] <= 0) $errors[] = 'يجب اختيار اللاعب';
if ($data['punctuality_score'] < 1 || $data['punctuality_score'] > 5) $errors[] = 'تقييم الالتزام بالمواعيد يجب أن يكون من 1 إلى 5';
if ($data['communication_score'] < 1 || $data['communication_score'] > 5) $errors[] = 'تقييم التواصل يجب أن يكون من 1 إلى 5';
if ($data['technique_score'] < 1 || $data['technique_score'] > 5) $errors[] = 'تقييم الأسلوب الفني يجب أن يكون من 1 إلى 5';
if ($data['motivation_score'] < 1 || $data['motivation_score'] > 5) $errors[] = 'تقييم التحفيز يجب أن يكون من 1 إلى 5';
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_old_input', $request->all());
$alerts = array_map(fn($msg) => ['type' => 'error', 'message' => $msg], $errors);
$session->flash('_alerts', $alerts);
return $this->redirect('/coach-ratings/create?coach_id=' . $data['coach_id']);
}
try {
CoachRatingService::submitRating($data);
return $this->redirect('/coach-ratings?coach_id=' . $data['coach_id'])->withSuccess('تم إرسال التقييم بنجاح');
} catch (\Throwable $e) {
return $this->redirect('/coach-ratings/create?coach_id=' . $data['coach_id'])->withError($e->getMessage());
}
}
/**
* Flag a rating as inappropriate (POST).
*/
public function flagRating(Request $request, int $id): Response
{
$this->authorize('coach.manage_ratings');
$reason = trim((string) $request->post('reason', 'محتوى غير مناسب'));
try {
CoachRatingService::flagRating($id, $reason);
return $this->back()->withSuccess('تم الإبلاغ عن التقييم');
} catch (\Throwable $e) {
return $this->back()->withError('حدث خطأ: ' . $e->getMessage());
}
}
/**
* Admin respond to a rating (POST).
*/
public function respond(Request $request, int $id): Response
{
$this->authorize('coach.manage_ratings');
$response = trim((string) $request->post('admin_response_ar', ''));
if (empty($response)) {
return $this->back()->withError('يجب كتابة الرد');
}
try {
CoachRatingService::respondToRating($id, $response);
return $this->back()->withSuccess('تم إضافة الرد');
} catch (\Throwable $e) {
return $this->back()->withError('حدث خطأ: ' . $e->getMessage());
}
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Coaches\Models;
use App\Core\Model;
use App\Core\App;
class CoachPerformanceMetric extends Model
{
protected static string $table = 'coach_performance_metrics';
protected static string $primaryKey = 'id';
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'coach_id',
'period_month',
'total_sessions_assigned',
'total_sessions_conducted',
'total_sessions_cancelled',
'avg_attendance_rate',
'avg_rating',
'total_ratings_count',
'total_players_trained',
'player_retention_rate',
'punctuality_rate',
'revenue_generated',
'calculated_at',
];
/**
* Get performance metrics for a coach over N months.
*/
public static function getForCoach(int $coachId, int $months = 12): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM coach_performance_metrics
WHERE coach_id = ?
ORDER BY period_month DESC
LIMIT ?",
[$coachId, $months]
);
}
/**
* Get the latest performance metrics for a coach.
*/
public static function getLatest(int $coachId): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT * FROM coach_performance_metrics
WHERE coach_id = ?
ORDER BY period_month DESC
LIMIT 1",
[$coachId]
);
}
/**
* Calculate and store metrics for a coach for a specific month.
*/
public static function calculateForMonth(int $coachId, string $month): array
{
$db = App::getInstance()->db();
$monthStart = $month . '-01';
$monthEnd = date('Y-m-t', strtotime($monthStart));
// Sessions assigned and conducted
$sessions = $db->selectOne(
"SELECT
COUNT(*) AS total_assigned,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS total_conducted,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS total_cancelled
FROM training_sessions
WHERE coach_id = ? AND session_date BETWEEN ? AND ?",
[$coachId, $monthStart, $monthEnd]
);
$totalAssigned = (int) ($sessions['total_assigned'] ?? 0);
$totalConducted = (int) ($sessions['total_conducted'] ?? 0);
$totalCancelled = (int) ($sessions['total_cancelled'] ?? 0);
// Average attendance rate for conducted sessions
$attendance = $db->selectOne(
"SELECT AVG(sub.rate) AS avg_rate FROM (
SELECT ts.id,
CASE WHEN COUNT(sa.id) > 0
THEN SUM(CASE WHEN sa.status = 'present' THEN 1 ELSE 0 END) * 100.0 / COUNT(sa.id)
ELSE 0
END AS rate
FROM training_sessions ts
LEFT JOIN session_attendance sa ON sa.session_id = ts.id
WHERE ts.coach_id = ? AND ts.session_date BETWEEN ? AND ? AND ts.status = 'completed'
GROUP BY ts.id
) sub",
[$coachId, $monthStart, $monthEnd]
);
$avgAttendanceRate = $attendance['avg_rate'] !== null ? round((float) $attendance['avg_rate'], 2) : 0;
// Ratings for the month
$ratings = $db->selectOne(
"SELECT AVG(rating) AS avg_rating, COUNT(*) AS total_count
FROM coach_ratings
WHERE coach_id = ? AND status = 'published'
AND created_at BETWEEN ? AND ?",
[$coachId, $monthStart . ' 00:00:00', $monthEnd . ' 23:59:59']
);
$avgRating = $ratings['avg_rating'] !== null ? round((float) $ratings['avg_rating'], 2) : null;
$totalRatingsCount = (int) ($ratings['total_count'] ?? 0);
// Total unique players trained
$players = $db->selectOne(
"SELECT COUNT(DISTINCT sa.player_id) AS total_players
FROM session_attendance sa
INNER JOIN training_sessions ts ON ts.id = sa.session_id
WHERE ts.coach_id = ? AND ts.session_date BETWEEN ? AND ?",
[$coachId, $monthStart, $monthEnd]
);
$totalPlayersTrained = (int) ($players['total_players'] ?? 0);
// Punctuality rate (sessions started on time)
$punctualityRate = $totalAssigned > 0 ? round(($totalConducted / $totalAssigned) * 100, 2) : 0;
// Revenue generated (from payments linked to sessions)
$revenue = $db->selectOne(
"SELECT COALESCE(SUM(cp.amount), 0) AS total_revenue
FROM coach_payments cp
WHERE cp.coach_id = ? AND cp.period_month = ?",
[$coachId, $month]
);
$revenueGenerated = (string) ($revenue['total_revenue'] ?? '0.00');
// Player retention rate (players from last month who are still active)
$lastMonth = date('Y-m', strtotime($monthStart . ' -1 month'));
$lastMonthStart = $lastMonth . '-01';
$lastMonthEnd = date('Y-m-t', strtotime($lastMonthStart));
$lastMonthPlayers = $db->selectOne(
"SELECT COUNT(DISTINCT sa.player_id) AS cnt
FROM session_attendance sa
INNER JOIN training_sessions ts ON ts.id = sa.session_id
WHERE ts.coach_id = ? AND ts.session_date BETWEEN ? AND ?",
[$coachId, $lastMonthStart, $lastMonthEnd]
);
$lastMonthCount = (int) ($lastMonthPlayers['cnt'] ?? 0);
$retentionRate = 0;
if ($lastMonthCount > 0) {
$retained = $db->selectOne(
"SELECT COUNT(DISTINCT sa_curr.player_id) AS cnt
FROM session_attendance sa_curr
INNER JOIN training_sessions ts_curr ON ts_curr.id = sa_curr.session_id
WHERE ts_curr.coach_id = ? AND ts_curr.session_date BETWEEN ? AND ?
AND sa_curr.player_id IN (
SELECT DISTINCT sa_prev.player_id
FROM session_attendance sa_prev
INNER JOIN training_sessions ts_prev ON ts_prev.id = sa_prev.session_id
WHERE ts_prev.coach_id = ? AND ts_prev.session_date BETWEEN ? AND ?
)",
[$coachId, $monthStart, $monthEnd, $coachId, $lastMonthStart, $lastMonthEnd]
);
$retentionRate = round(((int) ($retained['cnt'] ?? 0) / $lastMonthCount) * 100, 2);
}
$metrics = [
'coach_id' => $coachId,
'period_month' => $month,
'total_sessions_assigned' => $totalAssigned,
'total_sessions_conducted' => $totalConducted,
'total_sessions_cancelled' => $totalCancelled,
'avg_attendance_rate' => $avgAttendanceRate,
'avg_rating' => $avgRating,
'total_ratings_count' => $totalRatingsCount,
'total_players_trained' => $totalPlayersTrained,
'player_retention_rate' => $retentionRate,
'punctuality_rate' => $punctualityRate,
'revenue_generated' => $revenueGenerated,
'calculated_at' => date('Y-m-d H:i:s'),
];
// Upsert: delete existing, then insert
$existing = $db->selectOne(
"SELECT id FROM coach_performance_metrics WHERE coach_id = ? AND period_month = ?",
[$coachId, $month]
);
if ($existing) {
$db->update('coach_performance_metrics', $metrics, 'id = ?', [(int) $existing['id']]);
} else {
$db->insert('coach_performance_metrics', $metrics);
}
return $metrics;
}
/**
* Get composite performance score for ranking.
*/
public static function getCompositeScore(array $metrics): float
{
$avgRating = (float) ($metrics['avg_rating'] ?? 0);
$attendanceRate = (float) ($metrics['avg_attendance_rate'] ?? 0);
$conductionRate = 0;
if ((int) ($metrics['total_sessions_assigned'] ?? 0) > 0) {
$conductionRate = ((int) ($metrics['total_sessions_conducted'] ?? 0) / (int) $metrics['total_sessions_assigned']) * 100;
}
$retentionRate = (float) ($metrics['player_retention_rate'] ?? 0);
// Weighted composite: Rating 40%, Attendance 25%, Conduction 20%, Retention 15%
$score = ($avgRating / 5 * 100) * 0.40
+ $attendanceRate * 0.25
+ $conductionRate * 0.20
+ $retentionRate * 0.15;
return round($score, 2);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Coaches\Models;
use App\Core\Model;
use App\Core\App;
class CoachRating extends Model
{
protected static string $table = 'coach_ratings';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'coach_id',
'player_id',
'member_id',
'session_id',
'rating',
'punctuality_score',
'communication_score',
'technique_score',
'motivation_score',
'feedback_ar',
'is_anonymous',
'status',
'admin_response_ar',
];
public static function getRatingStatuses(): array
{
return [
'published' => 'منشور',
'flagged' => 'مبلغ عنه',
'hidden' => 'مخفي',
];
}
/**
* Get ratings for a specific coach.
*/
public static function getForCoach(int $coachId, int $limit = 50): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT cr.*, p.full_name_ar AS player_name, p.registration_serial
FROM coach_ratings cr
LEFT JOIN players p ON p.id = cr.player_id
WHERE cr.coach_id = ? AND cr.status = 'published'
ORDER BY cr.created_at DESC
LIMIT ?",
[$coachId, $limit]
);
}
/**
* Get average scores for a coach.
*/
public static function getAverages(int $coachId): array
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT
COUNT(*) AS total_ratings,
AVG(rating) AS avg_rating,
AVG(punctuality_score) AS avg_punctuality,
AVG(communication_score) AS avg_communication,
AVG(technique_score) AS avg_technique,
AVG(motivation_score) AS avg_motivation
FROM coach_ratings
WHERE coach_id = ? AND status = 'published'",
[$coachId]
);
return [
'total_ratings' => (int) ($row['total_ratings'] ?? 0),
'avg_rating' => $row['avg_rating'] !== null ? round((float) $row['avg_rating'], 2) : null,
'avg_punctuality' => $row['avg_punctuality'] !== null ? round((float) $row['avg_punctuality'], 2) : null,
'avg_communication' => $row['avg_communication'] !== null ? round((float) $row['avg_communication'], 2) : null,
'avg_technique' => $row['avg_technique'] !== null ? round((float) $row['avg_technique'], 2) : null,
'avg_motivation' => $row['avg_motivation'] !== null ? round((float) $row['avg_motivation'], 2) : null,
];
}
/**
* Search ratings with filters and pagination.
*/
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = ['1=1'];
$params = [];
if (!empty($filters['coach_id'])) {
$where[] = 'cr.coach_id = ?';
$params[] = (int) $filters['coach_id'];
}
if (!empty($filters['player_id'])) {
$where[] = 'cr.player_id = ?';
$params[] = (int) $filters['player_id'];
}
if (!empty($filters['status'])) {
$where[] = 'cr.status = ?';
$params[] = $filters['status'];
}
if (!empty($filters['min_rating'])) {
$where[] = 'cr.rating >= ?';
$params[] = (int) $filters['min_rating'];
}
if (!empty($filters['date_from'])) {
$where[] = 'cr.created_at >= ?';
$params[] = $filters['date_from'] . ' 00:00:00';
}
if (!empty($filters['date_to'])) {
$where[] = 'cr.created_at <= ?';
$params[] = $filters['date_to'] . ' 23:59:59';
}
$whereClause = implode(' AND ', $where);
$countSql = "SELECT COUNT(*) as total FROM coach_ratings cr WHERE {$whereClause}";
$countRow = $db->selectOne($countSql, $params);
$total = (int) ($countRow['total'] ?? 0);
$lastPage = max(1, (int) ceil($total / $perPage));
$page = min($page, $lastPage);
$offset = ($page - 1) * $perPage;
$dataSql = "SELECT cr.*, c.full_name_ar AS coach_name, p.full_name_ar AS player_name
FROM coach_ratings cr
LEFT JOIN coaches c ON c.id = cr.coach_id
LEFT JOIN players p ON p.id = cr.player_id
WHERE {$whereClause}
ORDER BY cr.created_at DESC
LIMIT {$perPage} OFFSET {$offset}";
$data = $db->select($dataSql, $params);
return [
'data' => $data,
'pagination' => [
'current_page' => $page,
'last_page' => $lastPage,
'per_page' => $perPage,
'total' => $total,
],
];
}
/**
* Check if a player has already rated a coach for a specific session.
*/
public static function hasRated(int $playerId, int $coachId, ?int $sessionId = null): bool
{
$db = App::getInstance()->db();
if ($sessionId !== null) {
$row = $db->selectOne(
"SELECT id FROM coach_ratings WHERE player_id = ? AND coach_id = ? AND session_id = ?",
[$playerId, $coachId, $sessionId]
);
} else {
// Check for rating in the last 24 hours without session
$row = $db->selectOne(
"SELECT id FROM coach_ratings WHERE player_id = ? AND coach_id = ? AND session_id IS NULL AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)",
[$playerId, $coachId]
);
}
return $row !== null;
}
}
......@@ -12,4 +12,17 @@ return [
['POST', '/coaches/{id:\d+}/assign', 'Coaches\Controllers\CoachController@assign', ['auth', 'csrf'], 'coach.manage'],
['POST', '/coaches/{id:\d+}/unassign', 'Coaches\Controllers\CoachController@unassign', ['auth', 'csrf'], 'coach.manage'],
['POST', '/coaches/{id:\d+}/availability', 'Coaches\Controllers\CoachController@saveAvailability', ['auth', 'csrf'], 'coach.manage'],
// Coach Ratings
['GET', '/coach-ratings', 'Coaches\Controllers\CoachRatingController@index', ['auth'], 'coach.rate'],
['GET', '/coach-ratings/create', 'Coaches\Controllers\CoachRatingController@create', ['auth'], 'coach.rate'],
['POST', '/coach-ratings', 'Coaches\Controllers\CoachRatingController@store', ['auth', 'csrf'], 'coach.rate'],
['POST', '/coach-ratings/{id:\d+}/flag', 'Coaches\Controllers\CoachRatingController@flagRating', ['auth', 'csrf'], 'coach.manage_ratings'],
['POST', '/coach-ratings/{id:\d+}/respond', 'Coaches\Controllers\CoachRatingController@respond', ['auth', 'csrf'], 'coach.manage_ratings'],
// Coach Performance
['GET', '/coach-performance', 'Coaches\Controllers\CoachPerformanceController@dashboard', ['auth'], 'coach.view_performance'],
['GET', '/coach-performance/ranking', 'Coaches\Controllers\CoachPerformanceController@ranking', ['auth'], 'coach.view_performance'],
['GET', '/coach-performance/{id:\d+}', 'Coaches\Controllers\CoachPerformanceController@detail', ['auth'], 'coach.view_performance'],
['POST', '/coach-performance/calculate', 'Coaches\Controllers\CoachPerformanceController@calculate', ['auth', 'csrf'], 'coach.view_performance'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Coaches\Services;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Coaches\Models\Coach;
use App\Modules\Coaches\Models\CoachPerformanceMetric;
final class CoachPerformanceService
{
/**
* Calculate monthly metrics for a specific coach.
*/
public static function calculateMonthlyMetrics(int $coachId, string $yearMonth): array
{
return CoachPerformanceMetric::calculateForMonth($coachId, $yearMonth);
}
/**
* Batch calculation for all active coaches (called by cron).
*/
public static function calculateAllCoachesMetrics(string $yearMonth): int
{
$coaches = Coach::allActive();
$count = 0;
foreach ($coaches as $coach) {
try {
CoachPerformanceMetric::calculateForMonth((int) $coach['id'], $yearMonth);
$count++;
} catch (\Throwable $e) {
Logger::error("Failed to calculate metrics for coach #{$coach['id']}: " . $e->getMessage());
}
}
Logger::info("Calculated performance metrics for {$count} coaches for {$yearMonth}");
return $count;
}
/**
* Get performance report for a coach over a date range.
*/
public static function getPerformanceReport(int $coachId, string $fromMonth, string $toMonth): array
{
$db = App::getInstance()->db();
$metrics = $db->select(
"SELECT * FROM coach_performance_metrics
WHERE coach_id = ? AND period_month >= ? AND period_month <= ?
ORDER BY period_month ASC",
[$coachId, $fromMonth, $toMonth]
);
// Build trend data
$trend = [
'months' => [],
'sessions' => [],
'attendance_rate' => [],
'ratings' => [],
'players' => [],
'retention' => [],
'revenue' => [],
'composite_scores' => [],
];
foreach ($metrics as $m) {
$trend['months'][] = $m['period_month'];
$trend['sessions'][] = (int) $m['total_sessions_conducted'];
$trend['attendance_rate'][] = (float) $m['avg_attendance_rate'];
$trend['ratings'][] = $m['avg_rating'] !== null ? (float) $m['avg_rating'] : null;
$trend['players'][] = (int) $m['total_players_trained'];
$trend['retention'][] = (float) $m['player_retention_rate'];
$trend['revenue'][] = (float) $m['revenue_generated'];
$trend['composite_scores'][] = CoachPerformanceMetric::getCompositeScore($m);
}
// Summary stats
$totalSessions = array_sum($trend['sessions']);
$avgAttendance = !empty($trend['attendance_rate'])
? round(array_sum($trend['attendance_rate']) / count($trend['attendance_rate']), 2) : 0;
$validRatings = array_filter($trend['ratings'], fn($r) => $r !== null);
$avgRating = !empty($validRatings) ? round(array_sum($validRatings) / count($validRatings), 2) : null;
$totalRevenue = array_sum($trend['revenue']);
return [
'metrics' => $metrics,
'trend' => $trend,
'summary' => [
'total_sessions' => $totalSessions,
'avg_attendance' => $avgAttendance,
'avg_rating' => $avgRating,
'total_revenue' => $totalRevenue,
'months_covered' => count($metrics),
],
];
}
/**
* Get all coaches ranked by composite score for a specific month.
*/
public static function getRanking(string $yearMonth): array
{
$db = App::getInstance()->db();
$metrics = $db->select(
"SELECT cpm.*, c.full_name_ar AS coach_name, c.code AS coach_code, c.photo_path
FROM coach_performance_metrics cpm
INNER JOIN coaches c ON c.id = cpm.coach_id AND c.is_active = 1
WHERE cpm.period_month = ?
ORDER BY cpm.avg_rating DESC",
[$yearMonth]
);
// Calculate composite score and sort
$ranked = [];
foreach ($metrics as $m) {
$m['composite_score'] = CoachPerformanceMetric::getCompositeScore($m);
$ranked[] = $m;
}
usort($ranked, fn($a, $b) => $b['composite_score'] <=> $a['composite_score']);
return $ranked;
}
/**
* Get performance summary for a single coach (current state).
*/
public static function getCoachSummary(int $coachId): array
{
$latest = CoachPerformanceMetric::getLatest($coachId);
$last6Months = CoachPerformanceMetric::getForCoach($coachId, 6);
$compositeScore = $latest ? CoachPerformanceMetric::getCompositeScore($latest) : 0;
// Determine performance level
$level = match(true) {
$compositeScore >= 85 => 'excellent',
$compositeScore >= 70 => 'good',
$compositeScore >= 55 => 'average',
$compositeScore >= 40 => 'below_average',
default => 'poor',
};
$levelLabels = [
'excellent' => 'ممتاز',
'good' => 'جيد',
'average' => 'متوسط',
'below_average' => 'أقل من المتوسط',
'poor' => 'ضعيف',
];
$levelColors = [
'excellent' => '#059669',
'good' => '#0D7377',
'average' => '#D97706',
'below_average' => '#EA580C',
'poor' => '#DC2626',
];
return [
'latest_metrics' => $latest,
'last_6_months' => $last6Months,
'composite_score' => $compositeScore,
'level' => $level,
'level_label' => $levelLabels[$level] ?? $level,
'level_color' => $levelColors[$level] ?? '#6B7280',
];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Coaches\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Coaches\Models\CoachRating;
use App\Modules\Coaches\Models\Coach;
final class CoachRatingService
{
/**
* Submit a rating for a coach.
*/
public static function submitRating(array $data): CoachRating
{
$playerId = (int) ($data['player_id'] ?? 0);
$coachId = (int) ($data['coach_id'] ?? 0);
$sessionId = !empty($data['session_id']) ? (int) $data['session_id'] : null;
// Validate one rating per player per session
if (CoachRating::hasRated($playerId, $coachId, $sessionId)) {
throw new \RuntimeException('لقد قمت بتقييم هذا المدرب بالفعل لهذه الحصة');
}
// Calculate overall rating from dimension scores
$scores = [
(int) ($data['punctuality_score'] ?? 0),
(int) ($data['communication_score'] ?? 0),
(int) ($data['technique_score'] ?? 0),
(int) ($data['motivation_score'] ?? 0),
];
$validScores = array_filter($scores, fn($s) => $s > 0);
$avgRating = !empty($validScores) ? (int) round(array_sum($validScores) / count($validScores)) : (int) ($data['rating'] ?? 3);
$ratingData = [
'coach_id' => $coachId,
'player_id' => $playerId,
'member_id' => !empty($data['member_id']) ? (int) $data['member_id'] : null,
'session_id' => $sessionId,
'rating' => $avgRating,
'punctuality_score' => (int) ($data['punctuality_score'] ?? 0),
'communication_score' => (int) ($data['communication_score'] ?? 0),
'technique_score' => (int) ($data['technique_score'] ?? 0),
'motivation_score' => (int) ($data['motivation_score'] ?? 0),
'feedback_ar' => trim((string) ($data['feedback_ar'] ?? '')) ?: null,
'is_anonymous' => (int) ($data['is_anonymous'] ?? 0),
'status' => 'published',
];
$rating = CoachRating::create($ratingData);
// Dispatch event
EventBus::dispatch('coach.rated', [
'rating_id' => (int) $rating->id,
'coach_id' => $coachId,
'player_id' => $playerId,
'rating' => $avgRating,
]);
Logger::info("Coach #{$coachId} rated {$avgRating}/5 by player #{$playerId}");
return $rating;
}
/**
* Get full coach profile with ratings and averages.
*/
public static function getCoachProfile(int $coachId): array
{
$coach = Coach::findOrFail($coachId);
$averages = CoachRating::getAverages($coachId);
$recentRatings = CoachRating::getForCoach($coachId, 20);
$db = App::getInstance()->db();
// Rating distribution (1-5 stars)
$distribution = $db->select(
"SELECT rating, COUNT(*) AS cnt FROM coach_ratings
WHERE coach_id = ? AND status = 'published'
GROUP BY rating ORDER BY rating DESC",
[$coachId]
);
$ratingDist = [5 => 0, 4 => 0, 3 => 0, 2 => 0, 1 => 0];
foreach ($distribution as $d) {
$ratingDist[(int) $d['rating']] = (int) $d['cnt'];
}
return [
'coach' => $coach,
'averages' => $averages,
'recent_ratings' => $recentRatings,
'distribution' => $ratingDist,
];
}
/**
* Flag a rating as inappropriate.
*/
public static function flagRating(int $ratingId, string $reason): bool
{
$rating = CoachRating::findOrFail($ratingId);
$rating->update(['status' => 'flagged']);
Logger::info("Rating #{$ratingId} flagged: {$reason}");
return true;
}
/**
* Admin responds to a rating.
*/
public static function respondToRating(int $ratingId, string $response): bool
{
$rating = CoachRating::findOrFail($ratingId);
$rating->update(['admin_response_ar' => $response]);
Logger::info("Admin responded to rating #{$ratingId}");
return true;
}
}
<?php
use App\Modules\Coaches\Models\CoachPerformanceMetric;
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>أداء المدربين<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<form method="POST" action="/coach-performance/calculate" style="display:inline;">
<?= csrf_field() ?>
<input type="hidden" name="month" value="<?= e($yearMonth) ?>">
<button type="submit" class="btn btn-outline" onclick="return confirm('هل تريد إعادة حساب الأداء لجميع المدربين؟')">
<i data-lucide="calculator" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إعادة الحساب
</button>
</form>
<a href="/coach-performance/ranking?month=<?= e($yearMonth) ?>" class="btn btn-primary">
<i data-lucide="trophy" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> ترتيب المدربين
</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Month Selector -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/coach-performance" style="display:flex;gap:10px;align-items:end;">
<div style="min-width:180px;">
<label class="form-label" style="font-size:12px;">الشهر</label>
<select name="month" class="form-input" onchange="this.form.submit()">
<?php foreach ($months as $m): ?>
<option value="<?= e($m) ?>" <?= $yearMonth === $m ? 'selected' : '' ?>><?= e($m) ?></option>
<?php endforeach; ?>
</select>
</div>
</form>
</div>
<?php if (!empty($ranking)): ?>
<!-- Top Performers -->
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(300px, 1fr));gap:20px;margin-bottom:20px;">
<?php foreach ($ranking as $i => $coach):
$rank = $i + 1;
$compositeScore = (float) ($coach['composite_score'] ?? 0);
$scoreColor = match(true) {
$compositeScore >= 85 => '#059669',
$compositeScore >= 70 => '#0D7377',
$compositeScore >= 55 => '#D97706',
default => '#DC2626',
};
$avgRating = $coach['avg_rating'] !== null ? (float) $coach['avg_rating'] : 0;
?>
<div class="card" style="position:relative;overflow:hidden;">
<!-- Rank Badge -->
<div style="position:absolute;top:12px;left:12px;width:28px;height:28px;border-radius:50%;background:<?= $rank <= 3 ? '#F59E0B' : '#E5E7EB' ?>;color:<?= $rank <= 3 ? '#fff' : '#6B7280' ?>;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;">
<?= $rank ?>
</div>
<a href="/coach-performance/<?= (int) $coach['coach_id'] ?>" style="text-decoration:none;color:inherit;display:block;padding:20px;">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
<div style="width:48px;height:48px;border-radius:50%;background:<?= $scoreColor ?>15;display:flex;align-items:center;justify-content:center;">
<i data-lucide="user" style="width:24px;height:24px;color:<?= $scoreColor ?>;"></i>
</div>
<div>
<div style="font-weight:600;font-size:15px;color:#1A1A2E;"><?= e($coach['coach_name'] ?? '') ?></div>
<code style="font-size:10px;color:#9CA3AF;"><?= e($coach['coach_code'] ?? '') ?></code>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(3, 1fr);gap:10px;text-align:center;">
<div style="background:#F9FAFB;border-radius:6px;padding:8px;">
<div style="font-size:18px;font-weight:700;color:<?= $scoreColor ?>;"><?= number_format($compositeScore, 0) ?>%</div>
<div style="font-size:10px;color:#6B7280;">الأداء</div>
</div>
<div style="background:#F9FAFB;border-radius:6px;padding:8px;">
<div style="font-size:18px;font-weight:700;color:#F59E0B;"><?= $avgRating > 0 ? number_format($avgRating, 1) : '—' ?></div>
<div style="font-size:10px;color:#6B7280;">التقييم</div>
</div>
<div style="background:#F9FAFB;border-radius:6px;padding:8px;">
<div style="font-size:18px;font-weight:700;color:#1A1A2E;"><?= (int) ($coach['total_sessions_conducted'] ?? 0) ?></div>
<div style="font-size:10px;color:#6B7280;">حصص</div>
</div>
</div>
<div style="margin-top:10px;display:flex;gap:10px;font-size:11px;color:#6B7280;">
<span>حضور: <?= number_format((float) ($coach['avg_attendance_rate'] ?? 0), 0) ?>%</span>
<span>لاعبين: <?= (int) ($coach['total_players_trained'] ?? 0) ?></span>
<span>استبقاء: <?= number_format((float) ($coach['player_retention_rate'] ?? 0), 0) ?>%</span>
</div>
</a>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="bar-chart-3" 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;">لم يتم حساب أداء المدربين لهذا الشهر بعد.</p>
<form method="POST" action="/coach-performance/calculate" style="display:inline;">
<?= csrf_field() ?>
<input type="hidden" name="month" value="<?= e($yearMonth) ?>">
<button type="submit" class="btn btn-primary">
<i data-lucide="calculator" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> حساب الأداء الآن
</button>
</form>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
<?php
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>أداء المدرب<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/coach-performance" class="btn btn-outline"><i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> جميع المدربين</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$levelColor = $summary['level_color'] ?? '#6B7280';
$levelLabel = $summary['level_label'] ?? '';
$compositeScore = $summary['composite_score'] ?? 0;
$latest = $summary['latest_metrics'];
?>
<!-- Coach Header -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:20px;display:flex;justify-content:space-between;align-items:center;">
<div>
<h2 style="margin:0 0 6px;font-size:20px;color:#1A1A2E;">
<i data-lucide="bar-chart-3" style="width:22px;height:22px;vertical-align:middle;margin-left:8px;color:<?= $levelColor ?>;"></i>
<?= e($coach->full_name_ar ?? '') ?>
</h2>
<div style="display:flex;gap:10px;align-items:center;">
<span style="display:inline-block;padding:3px 12px;border-radius:10px;font-size:12px;font-weight:600;background:<?= $levelColor ?>15;color:<?= $levelColor ?>;"><?= e($levelLabel) ?></span>
<span style="font-size:13px;color:#6B7280;">
<?= e($fromMonth) ?><?= e($toMonth) ?>
</span>
</div>
</div>
<div style="text-align:center;">
<div style="width:90px;height:90px;border-radius:50%;border:5px solid <?= $levelColor ?>;display:flex;align-items:center;justify-content:center;">
<span style="font-size:26px;font-weight:700;color:<?= $levelColor ?>;"><?= number_format($compositeScore, 0) ?>%</span>
</div>
<div style="font-size:11px;color:#6B7280;margin-top:6px;">الأداء الكلي</div>
</div>
</div>
</div>
<!-- Key Metrics Cards -->
<?php if ($latest): ?>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(150px, 1fr));gap:15px;margin-bottom:20px;">
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#1A1A2E;"><?= (int) ($latest['total_sessions_conducted'] ?? 0) ?></div>
<div style="font-size:11px;color:#6B7280;">حصص منفذة</div>
<div style="font-size:10px;color:#9CA3AF;">من <?= (int) ($latest['total_sessions_assigned'] ?? 0) ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#0D7377;"><?= number_format((float) ($latest['avg_attendance_rate'] ?? 0), 0) ?>%</div>
<div style="font-size:11px;color:#6B7280;">نسبة الحضور</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#F59E0B;">
<?= $latest['avg_rating'] !== null ? number_format((float) $latest['avg_rating'], 1) : '—' ?>
</div>
<div style="font-size:11px;color:#6B7280;">التقييم</div>
<div style="font-size:10px;color:#9CA3AF;">(<?= (int) ($latest['total_ratings_count'] ?? 0) ?> تقييم)</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#2563EB;"><?= (int) ($latest['total_players_trained'] ?? 0) ?></div>
<div style="font-size:11px;color:#6B7280;">لاعبين</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#059669;"><?= number_format((float) ($latest['player_retention_rate'] ?? 0), 0) ?>%</div>
<div style="font-size:11px;color:#6B7280;">الاستبقاء</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#7C3AED;"><?= number_format((float) ($latest['revenue_generated'] ?? 0), 0) ?></div>
<div style="font-size:11px;color:#6B7280;">الإيرادات (ج.م)</div>
</div>
</div>
<?php endif; ?>
<!-- Trend Chart (Bar visualization) -->
<?php $trend = $report['trend'] ?? []; ?>
<?php if (!empty($trend['months'])): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:15px;color:#374151;">تطور الأداء</h3>
</div>
<div style="padding:20px;">
<!-- Sessions Trend -->
<div style="margin-bottom:20px;">
<div style="font-size:12px;color:#6B7280;margin-bottom:8px;">الحصص المنفذة</div>
<div style="display:flex;align-items:end;gap:6px;height:60px;">
<?php
$maxSessions = !empty($trend['sessions']) ? max($trend['sessions']) : 1;
foreach ($trend['months'] as $idx => $month):
$sessions = (int) ($trend['sessions'][$idx] ?? 0);
$height = $maxSessions > 0 ? ($sessions / $maxSessions) * 50 : 0;
?>
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;">
<div style="font-size:9px;color:#6B7280;"><?= $sessions ?></div>
<div style="width:100%;height:<?= $height ?>px;background:#0D737730;border-radius:3px 3px 0 0;border-top:2px solid #0D7377;min-height:2px;"></div>
<div style="font-size:8px;color:#9CA3AF;"><?= substr($month, 5) ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- Composite Score Trend -->
<div>
<div style="font-size:12px;color:#6B7280;margin-bottom:8px;">الدرجة الكلية (%)</div>
<div style="display:flex;align-items:end;gap:6px;height:60px;">
<?php foreach ($trend['months'] as $idx => $month):
$score = (float) ($trend['composite_scores'][$idx] ?? 0);
$height = ($score / 100) * 50;
$barColor = match(true) {
$score >= 85 => '#059669',
$score >= 70 => '#0D7377',
$score >= 55 => '#D97706',
default => '#DC2626',
};
?>
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;">
<div style="font-size:9px;color:#6B7280;"><?= number_format($score, 0) ?></div>
<div style="width:100%;height:<?= $height ?>px;background:<?= $barColor ?>30;border-radius:3px 3px 0 0;border-top:2px solid <?= $barColor ?>;min-height:2px;"></div>
<div style="font-size:8px;color:#9CA3AF;"><?= substr($month, 5) ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<?php endif; ?>
<!-- Summary Period -->
<?php $summaryData = $report['summary'] ?? []; ?>
<?php if (!empty($summaryData)): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:15px;color:#374151;">ملخص الفترة (<?= (int) ($summaryData['months_covered'] ?? 0) ?> أشهر)</h3>
</div>
<div style="padding:20px;">
<table style="width:100%;font-size:14px;">
<tr>
<td style="padding:8px 0;color:#6B7280;">إجمالي الحصص المنفذة:</td>
<td style="padding:8px 0;font-weight:600;"><?= (int) ($summaryData['total_sessions'] ?? 0) ?></td>
</tr>
<tr>
<td style="padding:8px 0;color:#6B7280;">متوسط نسبة الحضور:</td>
<td style="padding:8px 0;font-weight:600;"><?= number_format((float) ($summaryData['avg_attendance'] ?? 0), 1) ?>%</td>
</tr>
<tr>
<td style="padding:8px 0;color:#6B7280;">متوسط التقييم:</td>
<td style="padding:8px 0;font-weight:600;color:#F59E0B;">
<?= $summaryData['avg_rating'] !== null ? number_format((float) $summaryData['avg_rating'], 2) . ' / 5' : '—' ?>
</td>
</tr>
<tr>
<td style="padding:8px 0;color:#6B7280;">إجمالي الإيرادات:</td>
<td style="padding:8px 0;font-weight:600;"><?= number_format((float) ($summaryData['total_revenue'] ?? 0), 2) ?> ج.م</td>
</tr>
</table>
</div>
</div>
<?php endif; ?>
<!-- Recalculate button -->
<div class="card" style="padding:15px;text-align:center;">
<form method="POST" action="/coach-performance/calculate" style="display:inline;">
<?= csrf_field() ?>
<input type="hidden" name="coach_id" value="<?= (int) $coach->id ?>">
<input type="hidden" name="month" value="<?= e(date('Y-m')) ?>">
<button type="submit" class="btn btn-outline">
<i data-lucide="refresh-cw" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> إعادة حساب الشهر الحالي
</button>
</form>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
<?php
use App\Modules\Coaches\Models\CoachPerformanceMetric;
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>ترتيب المدربين<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/coach-performance" class="btn btn-outline"><i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> لوحة الأداء</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Month Selector -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/coach-performance/ranking" style="display:flex;gap:10px;align-items:end;">
<div style="min-width:180px;">
<label class="form-label" style="font-size:12px;">الشهر</label>
<select name="month" class="form-input" onchange="this.form.submit()">
<?php foreach ($months as $m): ?>
<option value="<?= e($m) ?>" <?= $yearMonth === $m ? 'selected' : '' ?>><?= e($m) ?></option>
<?php endforeach; ?>
</select>
</div>
</form>
</div>
<?php if (!empty($ranking)): ?>
<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 15px;text-align:right;font-weight:600;color:#374151;width:60px;">المركز</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">المدرب</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">الدرجة الكلية</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">التقييم</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">الحصص</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">نسبة الحضور</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">اللاعبين</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">الاستبقاء</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">إجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($ranking as $i => $coach):
$rank = $i + 1;
$compositeScore = (float) ($coach['composite_score'] ?? 0);
$scoreColor = match(true) {
$compositeScore >= 85 => '#059669',
$compositeScore >= 70 => '#0D7377',
$compositeScore >= 55 => '#D97706',
default => '#DC2626',
};
$rankColor = match(true) {
$rank === 1 => '#F59E0B',
$rank === 2 => '#9CA3AF',
$rank === 3 => '#B45309',
default => '#6B7280',
};
?>
<tr style="border-bottom:1px solid #F3F4F6;<?= $rank <= 3 ? 'background:#FFFBEB;' : '' ?>" onmouseover="this.style.background='#F9FAFB'" onmouseout="this.style.background='<?= $rank <= 3 ? '#FFFBEB' : 'transparent' ?>'">
<td style="padding:12px 15px;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;background:<?= $rankColor ?>15;color:<?= $rankColor ?>;font-weight:700;font-size:13px;"><?= $rank ?></span>
</td>
<td style="padding:12px 15px;">
<div style="font-weight:600;color:#1A1A2E;"><?= e($coach['coach_name'] ?? '') ?></div>
<div style="font-size:11px;color:#9CA3AF;"><?= e($coach['coach_code'] ?? '') ?></div>
</td>
<td style="padding:12px 15px;">
<span style="font-weight:700;color:<?= $scoreColor ?>;font-size:16px;"><?= number_format($compositeScore, 0) ?>%</span>
</td>
<td style="padding:12px 15px;">
<?php $avgRating = $coach['avg_rating'] !== null ? (float) $coach['avg_rating'] : 0; ?>
<span style="color:#F59E0B;"><?= $avgRating > 0 ? number_format($avgRating, 1) . ' &#9733;' : '—' ?></span>
<div style="font-size:10px;color:#9CA3AF;">(<?= (int) ($coach['total_ratings_count'] ?? 0) ?>)</div>
</td>
<td style="padding:12px 15px;color:#374151;"><?= (int) ($coach['total_sessions_conducted'] ?? 0) ?>/<?= (int) ($coach['total_sessions_assigned'] ?? 0) ?></td>
<td style="padding:12px 15px;color:#374151;"><?= number_format((float) ($coach['avg_attendance_rate'] ?? 0), 0) ?>%</td>
<td style="padding:12px 15px;color:#374151;"><?= (int) ($coach['total_players_trained'] ?? 0) ?></td>
<td style="padding:12px 15px;color:#374151;"><?= number_format((float) ($coach['player_retention_rate'] ?? 0), 0) ?>%</td>
<td style="padding:12px 15px;">
<a href="/coach-performance/<?= (int) $coach['coach_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>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="trophy" 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;">لم يتم حساب الأداء لهذا الشهر.</p>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
<?php
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>تقييم مدرب<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="max-width:700px;margin:0 auto;">
<div style="padding:20px;border-bottom:1px solid #E5E7EB;">
<h2 style="margin:0;font-size:18px;color:#1A1A2E;">
<i data-lucide="star" style="width:20px;height:20px;vertical-align:middle;margin-left:8px;color:#F59E0B;"></i>
تقييم مدرب
<?php if ($coach): ?>
<?= e($coach['full_name_ar'] ?? '') ?>
<?php endif; ?>
</h2>
</div>
<form method="POST" action="/coach-ratings" style="padding:20px;">
<?= csrf_field() ?>
<?php if ($sessionId): ?>
<input type="hidden" name="session_id" value="<?= (int) $sessionId ?>">
<?php endif; ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:20px;">
<!-- Coach -->
<div>
<label class="form-label">المدرب <span style="color:#DC2626;">*</span></label>
<select name="coach_id" class="form-input" required>
<option value="">-- اختر المدرب --</option>
<?php foreach ($coaches as $c): ?>
<option value="<?= (int) $c['id'] ?>" <?= $coachId == $c['id'] ? 'selected' : '' ?>><?= e($c['full_name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<!-- Player -->
<div>
<label class="form-label">اللاعب <span style="color:#DC2626;">*</span></label>
<select name="player_id" class="form-input" required>
<option value="">-- اختر اللاعب --</option>
<?php foreach ($players as $p): ?>
<option value="<?= (int) $p['id'] ?>" <?= old('player_id') == $p['id'] ? 'selected' : '' ?>><?= e($p['full_name_ar']) ?> (<?= e($p['registration_serial'] ?? '') ?>)</option>
<?php endforeach; ?>
</select>
</div>
</div>
<!-- Rating Dimensions -->
<div style="background:#F9FAFB;border-radius:8px;padding:20px;margin-bottom:20px;">
<h4 style="margin:0 0 15px;font-size:14px;color:#374151;">التقييم التفصيلي (1-5 نجوم)</h4>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<!-- Punctuality -->
<div>
<label class="form-label" style="font-size:13px;">الالتزام بالمواعيد</label>
<div class="star-rating" data-name="punctuality_score">
<?php for ($s = 1; $s <= 5; $s++): ?>
<label style="cursor:pointer;font-size:28px;color:#D1D5DB;transition:color 0.2s;" data-star="<?= $s ?>">&#9733;</label>
<?php endfor; ?>
<input type="hidden" name="punctuality_score" value="<?= e(old('punctuality_score') ?? '0') ?>">
</div>
</div>
<!-- Communication -->
<div>
<label class="form-label" style="font-size:13px;">التواصل والشرح</label>
<div class="star-rating" data-name="communication_score">
<?php for ($s = 1; $s <= 5; $s++): ?>
<label style="cursor:pointer;font-size:28px;color:#D1D5DB;transition:color 0.2s;" data-star="<?= $s ?>">&#9733;</label>
<?php endfor; ?>
<input type="hidden" name="communication_score" value="<?= e(old('communication_score') ?? '0') ?>">
</div>
</div>
<!-- Technique -->
<div>
<label class="form-label" style="font-size:13px;">الأسلوب الفني</label>
<div class="star-rating" data-name="technique_score">
<?php for ($s = 1; $s <= 5; $s++): ?>
<label style="cursor:pointer;font-size:28px;color:#D1D5DB;transition:color 0.2s;" data-star="<?= $s ?>">&#9733;</label>
<?php endfor; ?>
<input type="hidden" name="technique_score" value="<?= e(old('technique_score') ?? '0') ?>">
</div>
</div>
<!-- Motivation -->
<div>
<label class="form-label" style="font-size:13px;">التحفيز والتشجيع</label>
<div class="star-rating" data-name="motivation_score">
<?php for ($s = 1; $s <= 5; $s++): ?>
<label style="cursor:pointer;font-size:28px;color:#D1D5DB;transition:color 0.2s;" data-star="<?= $s ?>">&#9733;</label>
<?php endfor; ?>
<input type="hidden" name="motivation_score" value="<?= e(old('motivation_score') ?? '0') ?>">
</div>
</div>
</div>
</div>
<!-- Feedback -->
<div style="margin-bottom:20px;">
<label class="form-label">ملاحظات وتعليقات</label>
<textarea name="feedback_ar" class="form-input" rows="3" placeholder="اكتب ملاحظاتك عن أداء المدرب..."><?= e(old('feedback_ar') ?? '') ?></textarea>
</div>
<!-- Anonymous -->
<div style="margin-bottom:20px;">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
<input type="checkbox" name="is_anonymous" value="1" <?= old('is_anonymous') ? 'checked' : '' ?> style="width:16px;height:16px;">
<span style="font-size:13px;color:#374151;">تقييم مجهول (لن يظهر اسم اللاعب)</span>
</label>
</div>
<!-- Actions -->
<div style="display:flex;gap:10px;justify-content:flex-start;padding-top:15px;border-top:1px solid #E5E7EB;">
<button type="submit" class="btn btn-primary">
<i data-lucide="send" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إرسال التقييم
</button>
<a href="/coach-ratings" class="btn btn-outline">إلغاء</a>
</div>
</form>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
// Star rating interactive
document.querySelectorAll('.star-rating').forEach(function(container) {
var labels = container.querySelectorAll('label[data-star]');
var input = container.querySelector('input[type="hidden"]');
labels.forEach(function(label) {
label.addEventListener('click', function() {
var value = parseInt(this.getAttribute('data-star'));
input.value = value;
updateStars(container, value);
});
label.addEventListener('mouseover', function() {
var value = parseInt(this.getAttribute('data-star'));
highlightStars(container, value);
});
});
container.addEventListener('mouseleave', function() {
var currentValue = parseInt(input.value) || 0;
updateStars(container, currentValue);
});
// Initialize from old value
var initValue = parseInt(input.value) || 0;
if (initValue > 0) updateStars(container, initValue);
});
function updateStars(container, value) {
container.querySelectorAll('label[data-star]').forEach(function(l) {
var star = parseInt(l.getAttribute('data-star'));
l.style.color = star <= value ? '#F59E0B' : '#D1D5DB';
});
}
function highlightStars(container, value) {
container.querySelectorAll('label[data-star]').forEach(function(l) {
var star = parseInt(l.getAttribute('data-star'));
l.style.color = star <= value ? '#FBBF24' : '#D1D5DB';
});
}
});
</script>
<?php $__template->endSection(); ?>
<?php
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>تقييمات المدربين<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/coach-ratings/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'); ?>
<!-- Coach Selector & Averages -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/coach-ratings" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:250px;">
<label class="form-label" style="font-size:12px;">المدرب</label>
<select name="coach_id" class="form-input" onchange="this.form.submit()">
<option value="">-- جميع المدربين --</option>
<?php foreach ($coaches as $c): ?>
<option value="<?= (int) $c['id'] ?>" <?= $coachId == $c['id'] ? 'selected' : '' ?>><?= e($c['full_name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:120px;">
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-input">
<option value="">الكل</option>
<option value="published" <?= ($filters['status'] ?? '') === 'published' ? 'selected' : '' ?>>منشور</option>
<option value="flagged" <?= ($filters['status'] ?? '') === 'flagged' ? 'selected' : '' ?>>مبلغ عنه</option>
<option value="hidden" <?= ($filters['status'] ?? '') === 'hidden' ? 'selected' : '' ?>>مخفي</option>
</select>
</div>
<button type="submit" class="btn btn-outline">تصفية</button>
</form>
</div>
<!-- Coach Average Scores -->
<?php if ($averages && $averages['total_ratings'] > 0): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:20px;display:flex;justify-content:space-around;align-items:center;flex-wrap:wrap;gap:15px;">
<div style="text-align:center;">
<div style="font-size:32px;font-weight:700;color:#F59E0B;"><?= number_format($averages['avg_rating'], 1) ?></div>
<div style="font-size:12px;color:#6B7280;">التقييم العام</div>
<div style="color:#F59E0B;font-size:18px;">
<?php for ($s = 1; $s <= 5; $s++): ?>
<?= $s <= round($averages['avg_rating']) ? '&#9733;' : '&#9734;' ?>
<?php endfor; ?>
</div>
<div style="font-size:11px;color:#9CA3AF;">(<?= $averages['total_ratings'] ?> تقييم)</div>
</div>
<div style="display:grid;grid-template-columns:repeat(4, 1fr);gap:20px;">
<div style="text-align:center;">
<div style="font-size:20px;font-weight:700;color:#1A1A2E;"><?= $averages['avg_punctuality'] ? number_format($averages['avg_punctuality'], 1) : '—' ?></div>
<div style="font-size:11px;color:#6B7280;">الالتزام</div>
</div>
<div style="text-align:center;">
<div style="font-size:20px;font-weight:700;color:#1A1A2E;"><?= $averages['avg_communication'] ? number_format($averages['avg_communication'], 1) : '—' ?></div>
<div style="font-size:11px;color:#6B7280;">التواصل</div>
</div>
<div style="text-align:center;">
<div style="font-size:20px;font-weight:700;color:#1A1A2E;"><?= $averages['avg_technique'] ? number_format($averages['avg_technique'], 1) : '—' ?></div>
<div style="font-size:11px;color:#6B7280;">الأسلوب</div>
</div>
<div style="text-align:center;">
<div style="font-size:20px;font-weight:700;color:#1A1A2E;"><?= $averages['avg_motivation'] ? number_format($averages['avg_motivation'], 1) : '—' ?></div>
<div style="font-size:11px;color:#6B7280;">التحفيز</div>
</div>
</div>
</div>
</div>
<?php endif; ?>
<!-- Ratings List -->
<?php if (!empty($ratings)): ?>
<div class="card" style="padding:0;">
<?php foreach ($ratings as $r):
$ratingValue = (int) ($r['rating'] ?? 0);
$status = $r['status'] ?? 'published';
$isAnonymous = (int) ($r['is_anonymous'] ?? 0);
?>
<div style="padding:15px 20px;border-bottom:1px solid #F3F4F6;">
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:8px;">
<div>
<div style="font-weight:600;color:#1A1A2E;font-size:14px;">
<?php if (!$coachId): ?>
<?= e($r['coach_name'] ?? '') ?>
<?php endif; ?>
<?= $isAnonymous ? 'مجهول' : e($r['player_name'] ?? '') ?>
</div>
<div style="font-size:11px;color:#9CA3AF;"><?= e(substr($r['created_at'] ?? '', 0, 16)) ?></div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<div style="color:#F59E0B;font-size:16px;">
<?php for ($s = 1; $s <= 5; $s++): ?>
<?= $s <= $ratingValue ? '&#9733;' : '&#9734;' ?>
<?php endfor; ?>
</div>
<?php if ($status === 'flagged'): ?>
<span style="padding:2px 8px;border-radius:8px;font-size:10px;font-weight:600;background:#FEE2E2;color:#DC2626;">مبلغ عنه</span>
<?php endif; ?>
</div>
</div>
<?php if (!empty($r['feedback_ar'])): ?>
<p style="margin:0 0 8px;font-size:13px;color:#4B5563;line-height:1.5;"><?= e($r['feedback_ar']) ?></p>
<?php endif; ?>
<?php if (!empty($r['admin_response_ar'])): ?>
<div style="background:#F0F9FF;border-radius:6px;padding:8px 12px;font-size:12px;color:#0369A1;margin-top:6px;">
<strong>رد الإدارة:</strong> <?= e($r['admin_response_ar']) ?>
</div>
<?php endif; ?>
<div style="display:flex;gap:12px;margin-top:8px;font-size:11px;color:#6B7280;">
<span>الالتزام: <?= (int) ($r['punctuality_score'] ?? 0) ?>/5</span>
<span>التواصل: <?= (int) ($r['communication_score'] ?? 0) ?>/5</span>
<span>الأسلوب: <?= (int) ($r['technique_score'] ?? 0) ?>/5</span>
<span>التحفيز: <?= (int) ($r['motivation_score'] ?? 0) ?>/5</span>
</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="star" 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;">
<?= $coachId ? 'لم يتم تقييم هذا المدرب بعد.' : 'لا توجد تقييمات مسجلة.' ?>
</p>
<a href="/coach-ratings/create<?= $coachId ? '?coach_id=' . $coachId : '' ?>" 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(); ?>
......@@ -5,8 +5,11 @@ use App\Core\Registries\PermissionRegistry;
use App\Core\Registries\MenuRegistry;
PermissionRegistry::register('coaches', [
'coach.view' => ['ar' => 'عرض المدربين', 'en' => 'View Coaches'],
'coach.manage' => ['ar' => 'إدارة المدربين', 'en' => 'Manage Coaches'],
'coach.view' => ['ar' => 'عرض المدربين', 'en' => 'View Coaches'],
'coach.manage' => ['ar' => 'إدارة المدربين', 'en' => 'Manage Coaches'],
'coach.rate' => ['ar' => 'تقييم المدربين', 'en' => 'Rate Coaches'],
'coach.manage_ratings' => ['ar' => 'إدارة تقييمات المدربين','en' => 'Manage Coach Ratings'],
'coach.view_performance' => ['ar' => 'عرض أداء المدربين', 'en' => 'View Coach Performance'],
]);
MenuRegistry::register('coaches', [
......@@ -17,3 +20,21 @@ MenuRegistry::register('coaches', [
'order' => 397,
'parent' => 'sports_activities',
]);
MenuRegistry::register('coach_ratings', [
'label_ar' => 'تقييمات المدربين',
'icon' => 'star',
'route' => '/coach-ratings',
'permission' => 'coach.rate',
'order' => 398,
'parent' => 'sports_activities',
]);
MenuRegistry::register('coach_performance', [
'label_ar' => 'أداء المدربين',
'icon' => 'bar-chart-3',
'route' => '/coach-performance',
'permission' => 'coach.view_performance',
'order' => 399,
'parent' => 'sports_activities',
]);
<?php
declare(strict_types=1);
namespace App\Modules\Disciplines\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Disciplines\Models\GroupWaitlist;
use App\Modules\Disciplines\Services\WaitlistService;
class WaitlistController extends Controller
{
/**
* List all waitlist entries with optional group filter.
*/
public function index(Request $request): Response
{
$this->authorize('waitlist.view');
$filters = [
'group_id' => trim((string) $request->get('group_id', '')),
'status' => trim((string) $request->get('status', '')),
'q' => trim((string) $request->get('q', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = GroupWaitlist::search($filters, 25, $page);
// Fetch groups for filter dropdown
$db = App::getInstance()->db();
$groups = $db->select("SELECT id, name_ar FROM training_groups WHERE is_archived = 0 ORDER BY name_ar ASC", []);
return $this->view('Disciplines.Views.waitlist', [
'entries' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'groups' => $groups,
]);
}
/**
* Show form to add a player to a group's waitlist.
*/
public function addPlayer(Request $request): Response
{
$this->authorize('waitlist.manage');
$db = App::getInstance()->db();
$groups = $db->select("SELECT id, name_ar FROM training_groups WHERE is_archived = 0 ORDER BY name_ar ASC", []);
$preselectedGroup = (int) $request->get('group_id', 0);
return $this->view('Disciplines.Views.waitlist_add', [
'groups' => $groups,
'preselectedGroup' => $preselectedGroup,
]);
}
/**
* Store a new waitlist entry.
*/
public function store(Request $request): Response
{
$this->authorize('waitlist.manage');
$groupId = (int) $request->post('group_id', 0);
$playerId = (int) $request->post('player_id', 0);
$notes = trim((string) $request->post('notes', ''));
// Validation
$errors = [];
if ($groupId <= 0) {
$errors[] = 'يرجى اختيار المجموعة';
}
if ($playerId <= 0) {
$errors[] = 'يرجى اختيار اللاعب';
}
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect('/waitlist/add');
}
$result = WaitlistService::addToWaitlist($groupId, $playerId, $notes);
if ($result['success']) {
return $this->redirect('/waitlist?group_id=' . $groupId)->withSuccess($result['message']);
}
return $this->redirect('/waitlist/add?group_id=' . $groupId)->withError($result['message']);
}
/**
* Offer a spot to a waitlisted player.
*/
public function offerSpot(Request $request, string $id): Response
{
$this->authorize('waitlist.manage');
$result = WaitlistService::offerSpot((int) $id);
if ($result['success']) {
return $this->back()->withSuccess($result['message']);
}
return $this->back()->withError($result['message']);
}
/**
* Accept an offered spot.
*/
public function acceptOffer(Request $request, string $id): Response
{
$this->authorize('waitlist.manage');
$result = WaitlistService::acceptOffer((int) $id);
if ($result['success']) {
return $this->back()->withSuccess($result['message']);
}
return $this->back()->withError($result['message']);
}
/**
* Remove a player from the waitlist.
*/
public function remove(Request $request, string $id): Response
{
$this->authorize('waitlist.manage');
$result = WaitlistService::removeFromWaitlist((int) $id);
if ($result['success']) {
return $this->back()->withSuccess($result['message']);
}
return $this->back()->withError($result['message']);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Disciplines\Models;
use App\Core\Model;
use App\Core\App;
class GroupWaitlist extends Model
{
protected static string $table = 'group_waitlist';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static array $fillable = [
'group_id',
'player_id',
'priority',
'status',
'requested_at',
'offered_at',
'expires_at',
'notes',
];
/**
* Get all waitlist entries for a specific group, ordered by priority.
*/
public static function getForGroup(int $groupId, ?string $status = null): array
{
$query = static::query()
->where('group_id', '=', $groupId)
->orderBy('priority', 'ASC')
->orderBy('requested_at', 'ASC');
if ($status !== null) {
$query = $query->where('status', '=', $status);
}
return $query->get();
}
/**
* Get all waitlist entries for a specific player.
*/
public static function getForPlayer(int $playerId): array
{
return static::query()
->where('player_id', '=', $playerId)
->orderBy('requested_at', 'DESC')
->get();
}
/**
* Get the next person in line for a specific group.
*/
public static function getNextInLine(int $groupId): ?array
{
$result = static::query()
->where('group_id', '=', $groupId)
->where('status', '=', 'waiting')
->orderBy('priority', 'ASC')
->orderBy('requested_at', 'ASC')
->first();
return $result ?: null;
}
/**
* Search waitlist entries with filters and pagination.
*/
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = [];
$params = [];
if (!empty($filters['group_id'])) {
$where[] = 'gw.group_id = ?';
$params[] = (int) $filters['group_id'];
}
if (!empty($filters['status'])) {
$where[] = 'gw.status = ?';
$params[] = $filters['status'];
}
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$where[] = '(m.name_ar LIKE ? OR m.membership_number LIKE ?)';
$params[] = $search;
$params[] = $search;
}
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
// Count total
$countSql = "SELECT COUNT(*) as total
FROM group_waitlist gw
LEFT JOIN members m ON m.id = gw.player_id
LEFT JOIN training_groups tg ON tg.id = gw.group_id
{$whereClause}";
$countResult = $db->selectOne($countSql, $params);
$total = (int) ($countResult['total'] ?? 0);
$lastPage = max(1, (int) ceil($total / $perPage));
$page = min($page, $lastPage);
$offset = ($page - 1) * $perPage;
// Fetch data
$sql = "SELECT gw.*,
m.name_ar AS player_name, m.membership_number,
tg.name_ar AS group_name
FROM group_waitlist gw
LEFT JOIN members m ON m.id = gw.player_id
LEFT JOIN training_groups tg ON tg.id = gw.group_id
{$whereClause}
ORDER BY gw.group_id ASC, gw.priority ASC, gw.requested_at ASC
LIMIT {$perPage} OFFSET {$offset}";
$data = $db->select($sql, $params);
return [
'data' => $data,
'pagination' => [
'current_page' => $page,
'last_page' => $lastPage,
'per_page' => $perPage,
'total' => $total,
],
];
}
}
......@@ -10,4 +10,12 @@ return [
['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'],
// Waitlist
['GET', '/waitlist', 'Disciplines\Controllers\WaitlistController@index', ['auth'], 'waitlist.view'],
['GET', '/waitlist/add', 'Disciplines\Controllers\WaitlistController@addPlayer', ['auth'], 'waitlist.manage'],
['POST', '/waitlist', 'Disciplines\Controllers\WaitlistController@store', ['auth', 'csrf'], 'waitlist.manage'],
['POST', '/waitlist/{id:\d+}/offer', 'Disciplines\Controllers\WaitlistController@offerSpot', ['auth', 'csrf'], 'waitlist.manage'],
['POST', '/waitlist/{id:\d+}/accept', 'Disciplines\Controllers\WaitlistController@acceptOffer',['auth', 'csrf'], 'waitlist.manage'],
['POST', '/waitlist/{id:\d+}/remove', 'Disciplines\Controllers\WaitlistController@remove', ['auth', 'csrf'], 'waitlist.manage'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Disciplines\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\Disciplines\Models\GroupWaitlist;
class WaitlistService
{
/**
* Add a player to a group's waitlist.
*/
public static function addToWaitlist(int $groupId, int $playerId, string $notes = ''): array
{
$db = App::getInstance()->db();
// Check if already on waitlist for this group
$existing = GroupWaitlist::query()
->where('group_id', '=', $groupId)
->where('player_id', '=', $playerId)
->whereIn('status', ['waiting', 'offered'])
->first();
if ($existing) {
return ['success' => false, 'message' => 'اللاعب موجود بالفعل في قائمة الانتظار لهذه المجموعة'];
}
// Determine next priority
$maxPriority = $db->selectOne(
"SELECT MAX(priority) as max_p FROM group_waitlist WHERE group_id = ? AND status = 'waiting'",
[$groupId]
);
$nextPriority = ((int) ($maxPriority['max_p'] ?? 0)) + 1;
$entry = GroupWaitlist::create([
'group_id' => $groupId,
'player_id' => $playerId,
'priority' => $nextPriority,
'status' => 'waiting',
'requested_at' => date('Y-m-d H:i:s'),
'notes' => $notes ?: null,
]);
return ['success' => true, 'entry' => $entry, 'message' => 'تمت الإضافة لقائمة الانتظار بنجاح'];
}
/**
* Offer a spot to a waitlisted player.
*/
public static function offerSpot(int $waitlistId): array
{
$entry = GroupWaitlist::find($waitlistId);
if (!$entry) {
return ['success' => false, 'message' => 'السجل غير موجود'];
}
if ($entry->status !== 'waiting') {
return ['success' => false, 'message' => 'لا يمكن تقديم العرض — الحالة الحالية: ' . $entry->status];
}
$expiresAt = date('Y-m-d H:i:s', strtotime('+48 hours'));
$entry->update([
'status' => 'offered',
'offered_at' => date('Y-m-d H:i:s'),
'expires_at' => $expiresAt,
]);
EventBus::dispatch('waitlist.spot_offered', [
'waitlist_id' => $waitlistId,
'group_id' => $entry->group_id,
'player_id' => $entry->player_id,
'expires_at' => $expiresAt,
]);
return ['success' => true, 'message' => 'تم تقديم العرض للاعب بنجاح'];
}
/**
* Accept an offered spot.
*/
public static function acceptOffer(int $waitlistId): array
{
$entry = GroupWaitlist::find($waitlistId);
if (!$entry) {
return ['success' => false, 'message' => 'السجل غير موجود'];
}
if ($entry->status !== 'offered') {
return ['success' => false, 'message' => 'لا يوجد عرض مفتوح لهذا السجل'];
}
// Check if expired
if ($entry->expires_at && strtotime($entry->expires_at) < time()) {
$entry->update(['status' => 'expired']);
return ['success' => false, 'message' => 'انتهت صلاحية العرض'];
}
$entry->update(['status' => 'accepted']);
EventBus::dispatch('waitlist.spot_accepted', [
'waitlist_id' => $waitlistId,
'group_id' => $entry->group_id,
'player_id' => $entry->player_id,
]);
return ['success' => true, 'message' => 'تم قبول العرض بنجاح'];
}
/**
* Expire an offered spot.
*/
public static function expireOffer(int $waitlistId): array
{
$entry = GroupWaitlist::find($waitlistId);
if (!$entry) {
return ['success' => false, 'message' => 'السجل غير موجود'];
}
if ($entry->status !== 'offered') {
return ['success' => false, 'message' => 'لا يوجد عرض مفتوح لهذا السجل'];
}
$entry->update(['status' => 'expired']);
return ['success' => true, 'message' => 'تم إنهاء صلاحية العرض'];
}
/**
* Remove a player from the waitlist.
*/
public static function removeFromWaitlist(int $waitlistId): array
{
$db = App::getInstance()->db();
$entry = GroupWaitlist::find($waitlistId);
if (!$entry) {
return ['success' => false, 'message' => 'السجل غير موجود'];
}
$db->delete('group_waitlist', 'id = ?', [$waitlistId]);
return ['success' => true, 'message' => 'تم إزالة اللاعب من قائمة الانتظار'];
}
/**
* Process all expired offers (cron job).
*/
public static function processExpiredOffers(): int
{
$db = App::getInstance()->db();
$expired = $db->select(
"SELECT id FROM group_waitlist WHERE status = 'offered' AND expires_at < NOW()",
[]
);
$count = 0;
foreach ($expired as $row) {
$db->update('group_waitlist', ['status' => 'expired'], 'id = ?', [(int) $row['id']]);
$count++;
}
return $count;
}
/**
* Get the position of a player in a group's waitlist.
*/
public static function getGroupPosition(int $groupId, int $playerId): ?int
{
$db = App::getInstance()->db();
$rows = $db->select(
"SELECT player_id FROM group_waitlist
WHERE group_id = ? AND status = 'waiting'
ORDER BY priority ASC, requested_at ASC",
[$groupId]
);
$position = 1;
foreach ($rows as $row) {
if ((int) $row['player_id'] === $playerId) {
return $position;
}
$position++;
}
return null;
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>قائمة الانتظار<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/waitlist/add" 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'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/waitlist" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:200px;">
<label class="form-label" style="font-size:12px;">المجموعة</label>
<select name="group_id" class="form-select">
<option value="">-- كل المجموعات --</option>
<?php foreach ($groups as $g): ?>
<option value="<?= (int) $g['id'] ?>" <?= ($filters['group_id'] ?? '') == $g['id'] ? 'selected' : '' ?>><?= e($g['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:150px;">
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-select">
<option value="">-- الكل --</option>
<option value="waiting" <?= ($filters['status'] ?? '') === 'waiting' ? 'selected' : '' ?>>في الانتظار</option>
<option value="offered" <?= ($filters['status'] ?? '') === 'offered' ? 'selected' : '' ?>>تم العرض</option>
<option value="accepted" <?= ($filters['status'] ?? '') === 'accepted' ? 'selected' : '' ?>>مقبول</option>
<option value="expired" <?= ($filters['status'] ?? '') === 'expired' ? 'selected' : '' ?>>منتهي</option>
</select>
</div>
<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>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> بحث</button>
<a href="/waitlist" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<!-- Waitlist Table -->
<?php if (!empty($entries)): ?>
<div class="card" style="overflow-x:auto;">
<table class="table" style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#F9FAFB;">
<th style="padding:12px 15px;text-align:right;font-size:13px;font-weight:600;color:#6B7280;">#</th>
<th style="padding:12px 15px;text-align:right;font-size:13px;font-weight:600;color:#6B7280;">المجموعة</th>
<th style="padding:12px 15px;text-align:right;font-size:13px;font-weight:600;color:#6B7280;">اللاعب</th>
<th style="padding:12px 15px;text-align:right;font-size:13px;font-weight:600;color:#6B7280;">الترتيب</th>
<th style="padding:12px 15px;text-align:right;font-size:13px;font-weight:600;color:#6B7280;">الحالة</th>
<th style="padding:12px 15px;text-align:right;font-size:13px;font-weight:600;color:#6B7280;">تاريخ الطلب</th>
<th style="padding:12px 15px;text-align:right;font-size:13px;font-weight:600;color:#6B7280;">الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($entries as $entry):
$statusColors = [
'waiting' => ['bg' => '#FEF3C7', 'color' => '#D97706', 'label' => 'في الانتظار'],
'offered' => ['bg' => '#DBEAFE', 'color' => '#2563EB', 'label' => 'تم العرض'],
'accepted' => ['bg' => '#D1FAE5', 'color' => '#059669', 'label' => 'مقبول'],
'expired' => ['bg' => '#FEE2E2', 'color' => '#DC2626', 'label' => 'منتهي'],
];
$st = $statusColors[$entry['status']] ?? $statusColors['waiting'];
?>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:12px 15px;font-size:13px;"><?= (int) $entry['id'] ?></td>
<td style="padding:12px 15px;font-size:13px;font-weight:600;"><?= e($entry['group_name'] ?? '—') ?></td>
<td style="padding:12px 15px;font-size:13px;">
<?= e($entry['player_name'] ?? '—') ?>
<?php if (!empty($entry['membership_number'])): ?>
<br><small style="color:#9CA3AF;"><?= e($entry['membership_number']) ?></small>
<?php endif; ?>
</td>
<td style="padding:12px 15px;font-size:13px;font-weight:700;color:#0D7377;"><?= (int) $entry['priority'] ?></td>
<td style="padding:12px 15px;">
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:600;background:<?= $st['bg'] ?>;color:<?= $st['color'] ?>;">
<?= $st['label'] ?>
</span>
</td>
<td style="padding:12px 15px;font-size:12px;color:#6B7280;"><?= e($entry['requested_at'] ?? '') ?></td>
<td style="padding:12px 15px;">
<div style="display:flex;gap:4px;flex-wrap:wrap;">
<?php if ($entry['status'] === 'waiting'): ?>
<form method="POST" action="/waitlist/<?= (int) $entry['id'] ?>/offer" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-primary" style="font-size:11px;padding:4px 8px;" onclick="return confirm('هل تريد تقديم العرض لهذا اللاعب؟')">
<i data-lucide="send" style="width:12px;height:12px;vertical-align:middle;"></i> عرض مكان
</button>
</form>
<?php endif; ?>
<?php if ($entry['status'] === 'offered'): ?>
<form method="POST" action="/waitlist/<?= (int) $entry['id'] ?>/accept" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm" style="font-size:11px;padding:4px 8px;background:#059669;color:#fff;">
<i data-lucide="check" style="width:12px;height:12px;vertical-align:middle;"></i> قبول
</button>
</form>
<?php endif; ?>
<?php if (in_array($entry['status'], ['waiting', 'offered'])): ?>
<form method="POST" action="/waitlist/<?= (int) $entry['id'] ?>/remove" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-outline" style="font-size:11px;padding:4px 8px;color:#DC2626;border-color:#DC2626;" onclick="return confirm('هل تريد إزالة هذا اللاعب من قائمة الانتظار؟')">
<i data-lucide="x" style="width:12px;height:12px;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="clock" 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['group_id']) || !empty($filters['status']) || !empty($filters['q'])): ?>
لا توجد نتائج مطابقة لبحثك.
<?php else: ?>
لم يتم إضافة أي لاعب لقائمة الانتظار بعد.
<?php endif; ?>
</p>
<a href="/waitlist/add" 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(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إضافة لاعب لقائمة الانتظار<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/waitlist" 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="/waitlist" id="waitlistForm">
<?= csrf_field() ?>
<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="clock" 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>
<select name="group_id" class="form-select" required>
<option value="">-- اختر المجموعة --</option>
<?php foreach ($groups as $g): ?>
<option value="<?= (int) $g['id'] ?>" <?= (old('group_id', (string) $preselectedGroup)) == $g['id'] ? 'selected' : '' ?>><?= e($g['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">اللاعب (رقم العضوية) <span style="color:#DC2626;">*</span></label>
<input type="number" name="player_id" value="<?= e(old('player_id')) ?>" class="form-input" required placeholder="أدخل رقم عضوية اللاعب" style="direction:ltr;text-align:left;">
<small style="color:#9CA3AF;font-size:11px;">أدخل رقم ID اللاعب من قاعدة البيانات</small>
</div>
</div>
<div class="form-group" style="margin-top:15px;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-input" rows="3" placeholder="ملاحظات إضافية (اختياري)..."><?= e(old('notes')) ?></textarea>
</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="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إضافة لقائمة الانتظار
</button>
<a href="/waitlist" 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(); ?>
......@@ -30,10 +30,13 @@ MenuRegistry::register('sports_activities', [
['label_ar' => 'حمام السباحة', 'label_en' => 'Pool Management', 'route' => '/pool', 'permission' => 'pool.view', 'order' => 10],
['label_ar' => 'الاشتراكات والتسعير', 'label_en' => 'Subscriptions & Pricing', 'route' => '/activity-subscriptions', 'permission' => 'activity_sub.view', 'order' => 11],
['label_ar' => 'العقود والتسويات', 'label_en' => 'Contracts & Settlements', 'route' => '/academy-contracts', 'permission' => 'academy_contract.view', 'order' => 12],
['label_ar' => 'قائمة الانتظار', 'label_en' => 'Waitlist', 'route' => '/waitlist', 'permission' => 'waitlist.view', 'order' => 13],
],
]);
PermissionRegistry::register('disciplines', [
'discipline.view' => ['ar' => 'عرض الأنشطة الرياضية', 'en' => 'View Disciplines'],
'discipline.manage' => ['ar' => 'إدارة الأنشطة الرياضية', 'en' => 'Manage Disciplines'],
'waitlist.view' => ['ar' => 'عرض قائمة الانتظار', 'en' => 'View Waitlist'],
'waitlist.manage' => ['ar' => 'إدارة قائمة الانتظار', 'en' => 'Manage Waitlist'],
]);
......@@ -21,6 +21,9 @@ class FacilityTimeSlot extends Model
'slot_type',
'linked_academy_id',
'is_active',
'access_type',
'member_price',
'guest_price',
];
/**
......@@ -48,6 +51,15 @@ class FacilityTimeSlot extends Model
];
}
public static function getAccessTypes(): array
{
return [
'all' => 'الكل',
'members_only' => 'أعضاء فقط',
'vip' => 'VIP فقط',
];
}
/**
* Get day-of-week labels in Arabic (0 = Sunday).
*/
......
<?php
declare(strict_types=1);
namespace App\Modules\Facilities\Services;
use App\Core\App;
use App\Core\EventBus;
class AccessControlService
{
public static function checkAccess(int $facilityId, string $date, string $startTime, ?int $memberId = null, string $bookerType = 'guest'): array
{
$db = App::getInstance()->db();
$dayOfWeek = (int) date('w', strtotime($date));
$slot = $db->selectOne(
"SELECT * FROM facility_time_slots
WHERE facility_id = ? AND day_of_week = ? AND start_time <= ? AND end_time > ? AND is_active = 1
ORDER BY start_time ASC LIMIT 1",
[$facilityId, $dayOfWeek, $startTime, $startTime]
);
if (!$slot) {
return ['allowed' => false, 'reason' => 'لا يوجد فترة متاحة في هذا الوقت', 'price' => 0];
}
$accessType = $slot['access_type'] ?? 'all';
$isMember = self::isMember($memberId);
if ($accessType === 'members_only' && !$isMember) {
return ['allowed' => false, 'reason' => 'هذه الفترة مخصصة للأعضاء فقط', 'price' => 0];
}
if ($accessType === 'vip' && !self::isVipMember($memberId)) {
return ['allowed' => false, 'reason' => 'هذه الفترة مخصصة لأعضاء VIP فقط', 'price' => 0];
}
$price = $isMember
? (float) ($slot['member_price'] ?? 0)
: (float) ($slot['guest_price'] ?? $slot['member_price'] ?? 0);
if ($price === 0.0) {
$facility = $db->selectOne("SELECT * FROM facilities WHERE id = ?", [$facilityId]);
if ($facility) {
$hour = (int) date('H', strtotime($startTime));
$timeTier = $hour < 16 ? 'AM' : 'PM';
$price = $isMember
? (float) ($timeTier === 'AM' ? ($facility['member_rate_am'] ?? $facility['hourly_rate'] ?? 0) : ($facility['member_rate_pm'] ?? $facility['hourly_rate'] ?? 0))
: (float) ($timeTier === 'AM' ? ($facility['guest_rate_am'] ?? $facility['hourly_rate'] ?? 0) : ($facility['guest_rate_pm'] ?? $facility['hourly_rate'] ?? 0));
}
}
return [
'allowed' => true,
'reason' => '',
'price' => $price,
'access_type' => $accessType,
'slot_id' => (int) $slot['id'],
'is_member' => $isMember,
];
}
public static function isMember(?int $memberId): bool
{
if (!$memberId) return false;
$db = App::getInstance()->db();
$member = $db->selectOne(
"SELECT id, membership_status FROM members WHERE id = ? AND membership_status = 'active'",
[$memberId]
);
return $member !== null && $member !== false;
}
public static function isVipMember(?int $memberId): bool
{
if (!$memberId) return false;
$db = App::getInstance()->db();
$member = $db->selectOne(
"SELECT id FROM members WHERE id = ? AND membership_status = 'active' AND membership_type IN ('vip', 'premium', 'gold')",
[$memberId]
);
return $member !== null && $member !== false;
}
public static function recordFacilityEntry(int $facilityId, array $data): int
{
$db = App::getInstance()->db();
$accessCheck = self::checkAccess(
$facilityId,
$data['date'] ?? date('Y-m-d'),
$data['start_time'] ?? date('H:i:s'),
!empty($data['member_id']) ? (int) $data['member_id'] : null,
$data['booker_type'] ?? 'guest'
);
if (!$accessCheck['allowed']) {
throw new \RuntimeException($accessCheck['reason'], 403);
}
$db->insert('pool_bookings', [
'facility_id' => $facilityId,
'member_id' => !empty($data['member_id']) ? (int) $data['member_id'] : null,
'guest_name' => $data['guest_name'] ?? null,
'guest_phone' => $data['guest_phone'] ?? null,
'booking_date' => $data['date'] ?? date('Y-m-d'),
'start_time' => $data['start_time'] ?? date('H:i:s'),
'end_time' => $data['end_time'] ?? null,
'booker_type' => $data['booker_type'] ?? 'guest',
'amount' => $accessCheck['price'],
'status' => 'active',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$entryId = (int) $db->selectOne("SELECT LAST_INSERT_ID() AS id")['id'];
EventBus::dispatch('facility.entry_recorded', [
'entry_id' => $entryId,
'facility_id' => $facilityId,
'member_id' => !empty($data['member_id']) ? (int) $data['member_id'] : null,
'amount' => $accessCheck['price'],
'booker_type' => $data['booker_type'] ?? 'guest',
]);
return $entryId;
}
public static function getFacilityAccessSchedule(int $facilityId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM facility_time_slots WHERE facility_id = ? AND is_active = 1 ORDER BY day_of_week ASC, start_time ASC",
[$facilityId]
);
}
public static function updateSlotAccess(int $slotId, string $accessType, ?float $memberPrice = null, ?float $guestPrice = null): void
{
$db = App::getInstance()->db();
$updateData = [
'access_type' => $accessType,
'updated_at' => date('Y-m-d H:i:s'),
];
if ($memberPrice !== null) $updateData['member_price'] = $memberPrice;
if ($guestPrice !== null) $updateData['guest_price'] = $guestPrice;
$db->update('facility_time_slots', $updateData, '`id` = ?', [$slotId]);
}
}
......@@ -7,4 +7,5 @@ declare(strict_types=1);
return [
// Facility Dashboard (per-facility stats page — still relevant)
['GET', '/facilities/{id:\d+}/dashboard', 'FacilityDashboards\Controllers\FacilityDashboardController@show', ['auth'], 'facility.dashboard'],
['GET', '/facilities/{id:\d+}/dashboard/export', 'FacilityDashboards\Controllers\FacilityDashboardController@exportPdf', ['auth'], 'facility.dashboard'],
];
This diff is collapsed.
......@@ -30,6 +30,15 @@ return [
['POST', '/facility-grids/{gridId:\d+}/trainees/{traineeId:\d+}/move', 'FacilityGrids\Controllers\ZoneTraineeController@move', ['auth', 'csrf'], 'facility_grid.manage'],
['POST', '/facility-grids/{gridId:\d+}/zones/{zoneId:\d+}/clear', 'FacilityGrids\Controllers\ZoneTraineeController@clearZone', ['auth', 'csrf'], 'facility_grid.manage'],
// Monthly Plans
['GET', '/facility-grids/{gridId:\d+}/plans', 'FacilityGrids\Controllers\MonthlyPlanController@index', ['auth'], 'facility_grid.view'],
['GET', '/facility-grids/{gridId:\d+}/plans/create', 'FacilityGrids\Controllers\MonthlyPlanController@create', ['auth'], 'facility_grid.manage'],
['POST', '/facility-grids/{gridId:\d+}/plans', 'FacilityGrids\Controllers\MonthlyPlanController@store', ['auth', 'csrf'], 'facility_grid.manage'],
['GET', '/facility-grids/{gridId:\d+}/plans/{planId:\d+}', 'FacilityGrids\Controllers\MonthlyPlanController@show', ['auth'], 'facility_grid.view'],
['POST', '/facility-grids/{gridId:\d+}/plans/{planId:\d+}/activate', 'FacilityGrids\Controllers\MonthlyPlanController@activate', ['auth', 'csrf'], 'facility_grid.manage'],
['POST', '/facility-grids/{gridId:\d+}/plans/{planId:\d+}/archive', 'FacilityGrids\Controllers\MonthlyPlanController@archive', ['auth', 'csrf'], 'facility_grid.manage'],
['GET', '/facility-grids/{gridId:\d+}/plans/{planId:\d+}/diff', 'FacilityGrids\Controllers\MonthlyPlanController@diff', ['auth'], 'facility_grid.view'],
// Legacy redirects (mirror + pool → unified facility-grids)
['GET', '/mirror', 'FacilityGrids\Controllers\RedirectController@mirrorIndex', ['auth'], 'facility_grid.view'],
['GET', '/mirror/{id:\d+}', 'FacilityGrids\Controllers\RedirectController@mirrorShow', ['auth'], 'facility_grid.view'],
......
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إنشاء خطة شهرية — <?= e($grid->name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php $dayNames = ['0'=>'الأحد','1'=>'الإثنين','2'=>'الثلاثاء','3'=>'الأربعاء','4'=>'الخميس','5'=>'الجمعة','6'=>'السبت']; ?>
<div class="card" style="max-width:650px;margin:0 auto;padding:25px;">
<h3 style="margin-bottom:20px;color:#0D7377;">خطة شهرية جديدة</h3>
<form method="POST" action="/facility-grids/<?= (int) $grid->id ?>/plans">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div style="grid-column:span 2;">
<label class="form-label">اسم الخطة <span style="color:#DC2626;">*</span></label>
<input type="text" name="plan_name_ar" placeholder="مثال: خطة حمام السباحة - يناير 2026" class="form-input" required>
</div>
<div>
<label class="form-label">الشهر <span style="color:#DC2626;">*</span></label>
<input type="month" name="plan_month" class="form-input" required style="direction:ltr;text-align:right;">
</div>
<div>
<label class="form-label">الاسم بالإنجليزية</label>
<input type="text" name="plan_name_en" placeholder="Swimming Pool Plan - January" class="form-input" style="direction:ltr;">
</div>
</div>
<div style="margin-top:20px;">
<label class="form-label">أيام العمل</label>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<?php foreach ($dayNames as $num => $name): ?>
<label style="display:flex;align-items:center;gap:5px;padding:6px 12px;background:#F9FAFB;border-radius:6px;cursor:pointer;">
<input type="checkbox" name="working_days[]" value="<?= $num ?>" <?= $num != 5 ? 'checked' : '' ?>>
<?= $name ?>
</label>
<?php endforeach; ?>
</div>
</div>
<div style="margin-top:20px;display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div>
<label class="form-label">بداية ساعات النهار</label>
<input type="time" name="day_hours_start" value="06:00" class="form-input" style="direction:ltr;">
</div>
<div>
<label class="form-label">نهاية ساعات النهار</label>
<input type="time" name="day_hours_end" value="14:00" class="form-input" style="direction:ltr;">
</div>
<div>
<label class="form-label">بداية ساعات الليل</label>
<input type="time" name="night_hours_start" value="14:00" class="form-input" style="direction:ltr;">
</div>
<div>
<label class="form-label">نهاية ساعات الليل</label>
<input type="time" name="night_hours_end" value="22:00" class="form-input" style="direction:ltr;">
</div>
</div>
<?php if (!empty($existingPlans)): ?>
<div style="margin-top:20px;background:#F0FDF4;border:1px solid #BBF7D0;border-radius:8px;padding:15px;">
<label class="form-label" style="color:#059669;">نسخ من خطة سابقة (اختياري)</label>
<select name="clone_from_id" class="form-select">
<option value="">— بدون نسخ (خطة فارغة) —</option>
<?php foreach ($existingPlans as $ep): ?>
<option value="<?= (int) $ep['id'] ?>"><?= e($ep['plan_name_ar']) ?> (<?= e($ep['plan_month']) ?>)</option>
<?php endforeach; ?>
</select>
<small style="color:#6B7280;display:block;margin-top:5px;">سيتم نسخ كل جداول المناطق من الخطة المحددة</small>
</div>
<?php endif; ?>
<div style="margin-top:20px;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-textarea" rows="3"></textarea>
</div>
<div style="margin-top:25px;display:flex;gap:10px;">
<button type="submit" class="btn btn-primary">إنشاء الخطة</button>
<a href="/facility-grids/<?= (int) $grid->id ?>/plans" class="btn btn-outline">إلغاء</a>
</div>
</form>
</div>
<?php $__template->endSection(); ?>
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.
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