Commit 4df45b97 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add PlayerApi, TrainerPortal, PlaygroundAdmin modules, FacilityGrids features, and notifications

Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 073c866d
......@@ -12,6 +12,8 @@ final class App
private ?Router $router = null;
private array $config = [];
private ?object $currentEmployee = null;
private ?object $currentPlayer = null;
private ?object $currentCoach = null;
private ?array $currentBranch = null;
private array $bindings = [];
private array $factories = [];
......@@ -362,6 +364,26 @@ final class App
return $this->currentEmployee;
}
public function setCurrentPlayer(object $player): void
{
$this->currentPlayer = $player;
}
public function currentPlayer(): ?object
{
return $this->currentPlayer;
}
public function setCurrentCoach(object $coach): void
{
$this->currentCoach = $coach;
}
public function currentCoach(): ?object
{
return $this->currentCoach;
}
public function setCurrentBranch(?array $branch): void
{
$this->currentBranch = $branch;
......
......@@ -103,14 +103,16 @@ final class Router
private function runMiddleware(array $middlewareList, Request $request, callable $final): Response
{
$middlewareMap = [
'csrf' => \App\Middleware\CSRFMiddleware::class,
'auth' => \App\Middleware\AuthMiddleware::class,
'api_auth' => \App\Middleware\ApiAuthMiddleware::class,
'cors' => \App\Middleware\CorsMiddleware::class,
'permission' => \App\Middleware\PermissionMiddleware::class,
'audit' => \App\Middleware\AuditMiddleware::class,
'rate_limit' => \App\Middleware\RateLimitMiddleware::class,
'guest' => null,
'csrf' => \App\Middleware\CSRFMiddleware::class,
'auth' => \App\Middleware\AuthMiddleware::class,
'api_auth' => \App\Middleware\ApiAuthMiddleware::class,
'player_auth' => \App\Middleware\PlayerApiAuthMiddleware::class,
'coach_auth' => \App\Middleware\CoachApiAuthMiddleware::class,
'cors' => \App\Middleware\CorsMiddleware::class,
'permission' => \App\Middleware\PermissionMiddleware::class,
'audit' => \App\Middleware\AuditMiddleware::class,
'rate_limit' => \App\Middleware\RateLimitMiddleware::class,
'guest' => null,
];
$stack = $final;
......
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
final class CoachApiAuthMiddleware
{
public function handle(Request $request, callable $next): Response
{
$token = $request->bearerToken();
if (!$token) {
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Authentication required'],
], 401);
}
$db = App::getInstance()->db();
$tokenRow = $db->selectOne(
"SELECT coach_id, expires_at FROM coach_tokens WHERE token = ? AND is_revoked = 0",
[$token]
);
if (!$tokenRow) {
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Invalid token'],
], 401);
}
if ($tokenRow['expires_at'] && $tokenRow['expires_at'] < date('Y-m-d H:i:s')) {
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Token expired'],
], 401);
}
$coach = $db->selectOne(
"SELECT * FROM coaches WHERE id = ? AND is_archived = 0",
[(int) $tokenRow['coach_id']]
);
if (!$coach) {
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Account inactive'],
], 401);
}
App::getInstance()->setCurrentCoach((object) $coach);
$db->update('coach_tokens', [
'last_used_at' => date('Y-m-d H:i:s'),
], '`token` = ?', [$token]);
return $next($request);
}
}
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
final class PlayerApiAuthMiddleware
{
public function handle(Request $request, callable $next): Response
{
$token = $request->bearerToken();
if (!$token) {
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Authentication required'],
], 401);
}
$db = App::getInstance()->db();
$tokenRow = $db->selectOne(
"SELECT player_id, expires_at FROM player_tokens WHERE token = ? AND is_revoked = 0",
[$token]
);
if (!$tokenRow) {
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Invalid token'],
], 401);
}
if ($tokenRow['expires_at'] && $tokenRow['expires_at'] < date('Y-m-d H:i:s')) {
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Token expired'],
], 401);
}
$player = $db->selectOne(
"SELECT * FROM players WHERE id = ? AND is_archived = 0",
[(int) $tokenRow['player_id']]
);
if (!$player) {
return (new Response())->json([
'success' => false,
'data' => null,
'meta' => [],
'errors' => ['Account inactive'],
], 401);
}
App::getInstance()->setCurrentPlayer((object) $player);
$db->update('player_tokens', [
'last_used_at' => date('Y-m-d H:i:s'),
], '`token` = ?', [$token]);
return $next($request);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\FacilityGrids\Controllers;
use App\Core\Controller;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Modules\FacilityGrids\Services\FacilityAttendanceService;
final class FacilityAttendanceController extends Controller
{
private FacilityAttendanceService $attendanceService;
public function __construct(Request $request)
{
parent::__construct($request);
$this->attendanceService = new FacilityAttendanceService();
}
public function index(Request $request, string $gridId): Response
{
$this->authorize('facility_grid.manage');
$date = $request->get('date', date('Y-m-d'));
$hourSlot = $request->get('hour');
$records = $this->attendanceService->getForGrid((int) $gridId, $date, $hourSlot);
$db = App::getInstance()->db();
$coaches = $db->select(
"SELECT DISTINCT fzs.coach_id, c.name_ar
FROM facility_zone_schedules fzs
INNER JOIN facility_grid_zones fgz ON fgz.id = fzs.zone_id
INNER JOIN coaches c ON c.id = fzs.coach_id
WHERE fgz.grid_id = ? AND fzs.is_active = 1",
[(int) $gridId]
);
$trainees = $db->select(
"SELECT DISTINCT fzt.player_id, p.name_ar
FROM facility_zone_trainees fzt
INNER JOIN facility_grid_zones fgz ON fgz.id = fzt.zone_id
INNER JOIN players p ON p.id = fzt.player_id
WHERE fgz.grid_id = ? AND fzt.is_active = 1",
[(int) $gridId]
);
return $this->view('FacilityGrids.Views.facility_attendance', [
'grid_id' => (int) $gridId,
'date' => $date,
'hour_slot' => $hourSlot,
'records' => $records,
'coaches' => $coaches,
'trainees' => $trainees,
]);
}
public function store(Request $request, string $gridId): Response
{
$this->authorize('facility_grid.manage');
$body = $_POST;
$date = $body['attendance_date'] ?? date('Y-m-d');
$hourSlot = !empty($body['hour_slot']) ? $body['hour_slot'] : null;
$records = [];
foreach ($body['attendance'] ?? [] as $entry) {
$records[] = [
'entity_type' => $entry['entity_type'],
'entity_id' => (int) $entry['entity_id'],
'entity_name' => $entry['entity_name'] ?? null,
'status' => $entry['status'] ?? 'present',
'check_in_time' => $entry['check_in_time'] ?? null,
'notes' => $entry['notes'] ?? null,
];
}
$employee = App::getInstance()->currentEmployee();
$count = $this->attendanceService->recordBatch(
(int) $gridId,
$date,
$hourSlot,
$records,
$employee ? (int) $employee->id : null
);
return $this->redirect('/facility-grids/' . $gridId . '/attendance?date=' . $date)
->withSuccess("تم تسجيل الحضور بنجاح ({$count} سجل)");
}
public function report(Request $request, string $gridId): Response
{
$this->authorize('facility_grid.view');
$dateFrom = $request->get('from', date('Y-m-01'));
$dateTo = $request->get('to', date('Y-m-t'));
$report = $this->attendanceService->getReport((int) $gridId, $dateFrom, $dateTo);
return $this->view('FacilityGrids.Views.attendance_report', [
'grid_id' => (int) $gridId,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'report' => $report,
]);
}
public function remind(Request $request, string $gridId): Response
{
$this->authorize('facility_grid.manage');
$count = $this->attendanceService->sendPaymentReminders((int) $gridId);
return $this->redirect('/facility-grids/' . $gridId . '/attendance')
->withSuccess("تم إرسال {$count} تذكير بالدفع");
}
}
<?php
declare(strict_types=1);
namespace App\Modules\FacilityGrids\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\FacilityGrids\Services\PoolFinancialService;
final class PoolFinancialDashboardController extends Controller
{
private PoolFinancialService $financialService;
public function __construct(Request $request)
{
parent::__construct($request);
$this->financialService = new PoolFinancialService();
}
public function dashboard(Request $request, string $gridId): Response
{
$this->authorize('facility_grid.view');
$filter = $request->get('filter', 'monthly');
$from = $request->get('from');
$to = $request->get('to');
$range = $this->financialService->getDateRange($filter, $from, $to);
$data = $this->financialService->getDashboardData((int) $gridId, $range['from'], $range['to']);
return $this->view('FacilityGrids.Views.pool_financial_dashboard', [
'grid_id' => (int) $gridId,
'filter' => $filter,
'data' => $data,
]);
}
public function export(Request $request, string $gridId): Response
{
$this->authorize('facility_grid.view');
$filter = $request->get('filter', 'monthly');
$from = $request->get('from');
$to = $request->get('to');
$range = $this->financialService->getDateRange($filter, $from, $to);
$data = $this->financialService->getDashboardData((int) $gridId, $range['from'], $range['to']);
$html = $this->renderExportHtml($data, $range);
$response = new Response();
return $response->html($html, 200, [
'Content-Type' => 'text/html; charset=utf-8',
'Content-Disposition' => 'attachment; filename="pool_report_' . date('Y-m-d') . '.html"',
]);
}
private function renderExportHtml(array $data, array $range): string
{
$html = '<!DOCTYPE html><html dir="rtl" lang="ar"><head><meta charset="utf-8">';
$html .= '<title>تقرير حمام السباحة المالي</title>';
$html .= '<style>body{font-family:Cairo,sans-serif;padding:20px;direction:rtl;}';
$html .= 'table{width:100%;border-collapse:collapse;margin:15px 0;}';
$html .= 'th,td{border:1px solid #ddd;padding:8px;text-align:right;}';
$html .= 'th{background:#f4f4f4;}.header{text-align:center;margin-bottom:20px;}';
$html .= '.stat{display:inline-block;margin:10px;padding:15px;border:1px solid #ddd;border-radius:5px;min-width:150px;text-align:center;}</style></head><body>';
$html .= '<div class="header"><h1>التقرير المالي - حمام السباحة</h1>';
$html .= '<p>الفترة: من ' . $range['from'] . ' إلى ' . $range['to'] . '</p></div>';
$html .= '<div style="text-align:center;">';
$html .= '<div class="stat"><strong>إجمالي الإيرادات</strong><br>' . number_format($data['total_revenue'], 2) . ' ج.م</div>';
$html .= '<div class="stat"><strong>إيرادات الحجوزات</strong><br>' . number_format($data['booking_revenue'], 2) . ' ج.م</div>';
$html .= '<div class="stat"><strong>إيرادات الأوقات الحرة</strong><br>' . number_format($data['free_time_revenue'], 2) . ' ج.م</div>';
$html .= '<div class="stat"><strong>عدد الحجوزات</strong><br>' . $data['total_bookings'] . '</div>';
$html .= '<div class="stat"><strong>المستخدمين الفريدين</strong><br>' . $data['unique_users'] . '</div>';
$html .= '</div>';
if (!empty($data['revenue_by_type'])) {
$html .= '<h3>الإيرادات حسب نوع الحاجز</h3><table><tr><th>النوع</th><th>العدد</th><th>الإيرادات</th></tr>';
foreach ($data['revenue_by_type'] as $row) {
$html .= '<tr><td>' . e($row['booker_type']) . '</td><td>' . $row['count'] . '</td><td>' . number_format((float) $row['revenue'], 2) . ' ج.م</td></tr>';
}
$html .= '</table>';
}
if (!empty($data['monthly_trend'])) {
$html .= '<h3>الاتجاه الشهري</h3><table><tr><th>الشهر</th><th>الحجوزات</th><th>الإيرادات</th></tr>';
foreach ($data['monthly_trend'] as $row) {
$html .= '<tr><td>' . $row['month'] . '</td><td>' . $row['bookings'] . '</td><td>' . number_format((float) $row['revenue'], 2) . ' ج.م</td></tr>';
}
$html .= '</table>';
}
$html .= '<p style="text-align:center;color:#888;margin-top:30px;">تم إنشاء التقرير: ' . date('Y-m-d H:i') . '</p>';
$html .= '</body></html>';
return $html;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\FacilityGrids\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\FacilityGrids\Services\PoolHourPlanService;
final class PoolHourPlanController extends Controller
{
private PoolHourPlanService $hourPlanService;
public function __construct(Request $request)
{
parent::__construct($request);
$this->hourPlanService = new PoolHourPlanService();
}
public function index(Request $request, string $gridId, string $planId): Response
{
$this->authorize('facility_grid.view');
$service = $this->hourPlanService;
$plan = \App\Core\App::getInstance()->db()->selectOne(
"SELECT * FROM facility_monthly_plans WHERE id = ? AND grid_id = ?",
[(int) $planId, (int) $gridId]
);
if (!$plan) {
return $this->redirect('/facility-grids/' . $gridId . '/plans')->withError('الخطة غير موجودة');
}
$workingDays = json_decode($plan['working_days_json'] ?? '[]', true);
return $this->view('FacilityGrids.Views.pool_hour_plans', [
'grid_id' => (int) $gridId,
'plan' => $plan,
'working_days' => $workingDays,
]);
}
public function dayView(Request $request, string $gridId, string $planId, string $date): Response
{
$this->authorize('facility_grid.view');
$hours = $this->hourPlanService->getHoursForDate((int) $planId, $date);
$workingHours = $this->hourPlanService->getWorkingHoursForDate((int) $planId, $date);
return $this->view('FacilityGrids.Views.pool_hour_plan_edit', [
'grid_id' => (int) $gridId,
'plan_id' => (int) $planId,
'date' => $date,
'hours' => $hours,
'working_hours' => $workingHours,
]);
}
public function showHour(Request $request, string $gridId, string $planId, string $date, string $hour): Response
{
$this->authorize('facility_grid.view');
$hourPlan = $this->hourPlanService->getHourPlan((int) $planId, $date, $hour . ':00');
return $this->json($hourPlan ?? ['grid_snapshot_json' => '{}']);
}
public function storeHour(Request $request, string $gridId, string $planId, string $date, string $hour): Response
{
$this->authorize('facility_grid.manage');
$snapshot = $request->jsonBody()['grid_snapshot'] ?? [];
$notes = $request->jsonBody()['notes'] ?? null;
$id = $this->hourPlanService->saveHourPlan((int) $planId, $date, $hour . ':00', $snapshot, $notes);
return $this->json(['success' => true, 'id' => $id]);
}
public function copyHour(Request $request, string $gridId, string $planId, string $date, string $hour): Response
{
$this->authorize('facility_grid.manage');
$body = $request->jsonBody();
$fromDate = $body['from_date'] ?? $date;
$fromHour = $body['from_hour'] ?? '';
$success = $this->hourPlanService->copyHourPlan((int) $planId, $fromDate, $fromHour . ':00', $date, $hour . ':00');
if (!$success) {
return $this->json(['success' => false, 'error' => 'لا يوجد خطة لنسخها']);
}
return $this->json(['success' => true]);
}
}
......@@ -39,6 +39,23 @@ return [
['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'],
// Pool Hour Plans
['GET', '/facility-grids/{gridId:\d+}/plans/{planId:\d+}/hours', 'FacilityGrids\Controllers\PoolHourPlanController@index', ['auth'], 'facility_grid.view'],
['GET', '/facility-grids/{gridId:\d+}/plans/{planId:\d+}/hours/{date}', 'FacilityGrids\Controllers\PoolHourPlanController@dayView', ['auth'], 'facility_grid.view'],
['GET', '/facility-grids/{gridId:\d+}/plans/{planId:\d+}/hours/{date}/{hour}', 'FacilityGrids\Controllers\PoolHourPlanController@showHour', ['auth'], 'facility_grid.view'],
['POST', '/facility-grids/{gridId:\d+}/plans/{planId:\d+}/hours/{date}/{hour}', 'FacilityGrids\Controllers\PoolHourPlanController@storeHour', ['auth', 'csrf'], 'facility_grid.manage'],
['POST', '/facility-grids/{gridId:\d+}/plans/{planId:\d+}/hours/{date}/{hour}/copy', 'FacilityGrids\Controllers\PoolHourPlanController@copyHour', ['auth', 'csrf'], 'facility_grid.manage'],
// Facility Attendance
['GET', '/facility-grids/{gridId:\d+}/attendance', 'FacilityGrids\Controllers\FacilityAttendanceController@index', ['auth'], 'facility_grid.manage'],
['POST', '/facility-grids/{gridId:\d+}/attendance', 'FacilityGrids\Controllers\FacilityAttendanceController@store', ['auth', 'csrf'], 'facility_grid.manage'],
['GET', '/facility-grids/{gridId:\d+}/attendance/report', 'FacilityGrids\Controllers\FacilityAttendanceController@report', ['auth'], 'facility_grid.view'],
['POST', '/facility-grids/{gridId:\d+}/attendance/remind', 'FacilityGrids\Controllers\FacilityAttendanceController@remind', ['auth', 'csrf'], 'facility_grid.manage'],
// Pool Financial Dashboard
['GET', '/facility-grids/{gridId:\d+}/financial-dashboard', 'FacilityGrids\Controllers\PoolFinancialDashboardController@dashboard', ['auth'], 'facility_grid.view'],
['GET', '/facility-grids/{gridId:\d+}/financial-dashboard/export', 'FacilityGrids\Controllers\PoolFinancialDashboardController@export', ['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'],
......
<?php
declare(strict_types=1);
namespace App\Modules\FacilityGrids\Services;
use App\Core\App;
use App\Core\EventBus;
final class FacilityAttendanceService
{
private \App\Core\Database $db;
public function __construct()
{
$this->db = App::getInstance()->db();
}
public function recordBatch(int $gridId, string $date, ?string $hourSlot, array $records, ?int $recordedBy = null): int
{
$count = 0;
foreach ($records as $record) {
$existing = $this->db->selectOne(
"SELECT id FROM facility_attendance
WHERE grid_id = ? AND attendance_date = ? AND entity_type = ? AND entity_id = ?
AND (hour_slot = ? OR (hour_slot IS NULL AND ? IS NULL))",
[$gridId, $date, $record['entity_type'], (int) $record['entity_id'], $hourSlot, $hourSlot]
);
if ($existing) {
$this->db->update('facility_attendance', [
'status' => $record['status'],
'check_in_time' => $record['check_in_time'] ?? null,
'notes' => $record['notes'] ?? null,
], 'id = ?', [(int) $existing['id']]);
} else {
$this->db->insert('facility_attendance', [
'grid_id' => $gridId,
'attendance_date' => $date,
'hour_slot' => $hourSlot,
'entity_type' => $record['entity_type'],
'entity_id' => (int) $record['entity_id'],
'entity_name_cache' => $record['entity_name'] ?? null,
'status' => $record['status'],
'check_in_time' => $record['check_in_time'] ?? null,
'notes' => $record['notes'] ?? null,
'recorded_by' => $recordedBy,
]);
}
$count++;
if ($record['status'] === 'absent') {
EventBus::dispatch('facility.attendance_absent', [
'entity_type' => $record['entity_type'],
'entity_id' => (int) $record['entity_id'],
'grid_id' => $gridId,
'date' => $date,
]);
}
}
return $count;
}
public function getForGrid(int $gridId, string $date, ?string $hourSlot = null): array
{
$where = 'grid_id = ? AND attendance_date = ?';
$params = [$gridId, $date];
if ($hourSlot !== null) {
$where .= ' AND hour_slot = ?';
$params[] = $hourSlot;
}
return $this->db->select(
"SELECT * FROM facility_attendance WHERE {$where} ORDER BY entity_type, entity_name_cache",
$params
);
}
public function getReport(int $gridId, string $dateFrom, string $dateTo): array
{
$summary = $this->db->select(
"SELECT entity_type, entity_id, entity_name_cache,
COUNT(*) as total_days,
SUM(CASE WHEN status = 'present' THEN 1 ELSE 0 END) as present_count,
SUM(CASE WHEN status = 'absent' THEN 1 ELSE 0 END) as absent_count,
SUM(CASE WHEN status = 'late' THEN 1 ELSE 0 END) as late_count,
SUM(CASE WHEN status = 'excused' THEN 1 ELSE 0 END) as excused_count
FROM facility_attendance
WHERE grid_id = ? AND attendance_date BETWEEN ? AND ?
GROUP BY entity_type, entity_id, entity_name_cache
ORDER BY entity_type, entity_name_cache",
[$gridId, $dateFrom, $dateTo]
);
return $summary;
}
public function sendPaymentReminders(int $gridId): int
{
$trainees = $this->db->select(
"SELECT DISTINCT fzt.player_id, p.name_ar, p.phone
FROM facility_zone_trainees fzt
INNER JOIN facility_grid_zones fgz ON fgz.id = fzt.zone_id
INNER JOIN players p ON p.id = fzt.player_id
WHERE fgz.grid_id = ? AND fzt.is_active = 1",
[$gridId]
);
$count = 0;
foreach ($trainees as $trainee) {
EventBus::dispatch('facility.payment_reminder', [
'player_id' => (int) $trainee['player_id'],
'player_name' => $trainee['name_ar'],
'phone' => $trainee['phone'],
'grid_id' => $gridId,
]);
$count++;
}
return $count;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\FacilityGrids\Services;
use App\Core\App;
final class PoolFinancialService
{
private \App\Core\Database $db;
public function __construct()
{
$this->db = App::getInstance()->db();
}
public function getDashboardData(int $gridId, string $dateFrom, string $dateTo): array
{
$grid = $this->db->selectOne("SELECT facility_id FROM facility_grids WHERE id = ?", [$gridId]);
if (!$grid) {
return [];
}
$facilityId = (int) $grid['facility_id'];
$revenue = $this->db->selectOne(
"SELECT COALESCE(SUM(total_amount), 0) as total_revenue,
COUNT(*) as total_bookings
FROM reservations
WHERE facility_id = ? AND reservation_date BETWEEN ? AND ?
AND status NOT IN ('cancelled', 'no_show')",
[$facilityId, $dateFrom, $dateTo]
);
$revenueByType = $this->db->select(
"SELECT booker_type, COUNT(*) as count, COALESCE(SUM(total_amount), 0) as revenue
FROM reservations
WHERE facility_id = ? AND reservation_date BETWEEN ? AND ?
AND status NOT IN ('cancelled', 'no_show')
GROUP BY booker_type",
[$facilityId, $dateFrom, $dateTo]
);
$freeTimeRevenue = $this->db->selectOne(
"SELECT COALESCE(SUM(amount_paid), 0) as total, COUNT(*) as entries
FROM free_time_entries
WHERE facility_id = ? AND entry_date BETWEEN ? AND ? AND status = 'active'",
[$facilityId, $dateFrom, $dateTo]
);
$uniqueUsers = $this->db->selectOne(
"SELECT COUNT(DISTINCT booker_id) as cnt FROM reservations
WHERE facility_id = ? AND reservation_date BETWEEN ? AND ?
AND status NOT IN ('cancelled', 'no_show')",
[$facilityId, $dateFrom, $dateTo]
);
$attendanceSummary = $this->db->selectOne(
"SELECT COUNT(DISTINCT CASE WHEN entity_type = 'coach' THEN entity_id END) as total_coaches,
COUNT(DISTINCT CASE WHEN entity_type = 'trainee' THEN entity_id END) as total_trainees,
ROUND(AVG(CASE WHEN status = 'present' THEN 100.0 ELSE 0.0 END), 1) as avg_attendance_rate
FROM facility_attendance
WHERE grid_id = ? AND attendance_date BETWEEN ? AND ?",
[$gridId, $dateFrom, $dateTo]
);
$monthlyTrend = $this->db->select(
"SELECT DATE_FORMAT(reservation_date, '%Y-%m') as month,
COALESCE(SUM(total_amount), 0) as revenue, COUNT(*) as bookings
FROM reservations
WHERE facility_id = ? AND reservation_date BETWEEN ? AND ?
AND status NOT IN ('cancelled', 'no_show')
GROUP BY DATE_FORMAT(reservation_date, '%Y-%m')
ORDER BY month",
[$facilityId, $dateFrom, $dateTo]
);
return [
'total_revenue' => (float) ($revenue['total_revenue'] ?? 0) + (float) ($freeTimeRevenue['total'] ?? 0),
'booking_revenue' => (float) ($revenue['total_revenue'] ?? 0),
'free_time_revenue' => (float) ($freeTimeRevenue['total'] ?? 0),
'total_bookings' => (int) ($revenue['total_bookings'] ?? 0),
'free_time_entries' => (int) ($freeTimeRevenue['entries'] ?? 0),
'revenue_by_type' => $revenueByType,
'unique_users' => (int) ($uniqueUsers['cnt'] ?? 0),
'attendance' => $attendanceSummary,
'monthly_trend' => $monthlyTrend,
'period' => ['from' => $dateFrom, 'to' => $dateTo],
];
}
public function getDateRange(string $filter, ?string $from = null, ?string $to = null): array
{
$today = date('Y-m-d');
return match ($filter) {
'daily' => ['from' => $today, 'to' => $today],
'weekly' => ['from' => date('Y-m-d', strtotime('monday this week')), 'to' => date('Y-m-d', strtotime('sunday this week'))],
'monthly' => ['from' => date('Y-m-01'), 'to' => date('Y-m-t')],
'yearly' => ['from' => date('Y-01-01'), 'to' => date('Y-12-31')],
'5years' => ['from' => date('Y-m-d', strtotime('-5 years')), 'to' => $today],
'custom' => ['from' => $from ?? $today, 'to' => $to ?? $today],
default => ['from' => date('Y-m-01'), 'to' => date('Y-m-t')],
};
}
}
<?php
declare(strict_types=1);
namespace App\Modules\FacilityGrids\Services;
use App\Core\App;
final class PoolHourPlanService
{
private \App\Core\Database $db;
public function __construct()
{
$this->db = App::getInstance()->db();
}
public function getHoursForDate(int $planId, string $date): array
{
return $this->db->select(
"SELECT id, hour_slot, period, grid_snapshot_json, notes, updated_at
FROM pool_hour_plans
WHERE monthly_plan_id = ? AND plan_date = ?
ORDER BY hour_slot",
[$planId, $date]
);
}
public function getHourPlan(int $planId, string $date, string $hour): ?array
{
return $this->db->selectOne(
"SELECT * FROM pool_hour_plans WHERE monthly_plan_id = ? AND plan_date = ? AND hour_slot = ?",
[$planId, $date, $hour]
);
}
public function saveHourPlan(int $planId, string $date, string $hour, array $gridSnapshot, ?string $notes = null): int
{
$plan = $this->db->selectOne(
"SELECT * FROM facility_monthly_plans WHERE id = ?",
[$planId]
);
$period = 'day';
if ($plan && $hour >= ($plan['night_hours_start'] ?? '18:00')) {
$period = 'night';
}
$existing = $this->db->selectOne(
"SELECT id FROM pool_hour_plans WHERE monthly_plan_id = ? AND plan_date = ? AND hour_slot = ?",
[$planId, $date, $hour]
);
$snapshotJson = json_encode($gridSnapshot, JSON_UNESCAPED_UNICODE);
if ($existing) {
$this->db->update('pool_hour_plans', [
'grid_snapshot_json' => $snapshotJson,
'period' => $period,
'notes' => $notes,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $existing['id']]);
return (int) $existing['id'];
}
$this->db->insert('pool_hour_plans', [
'monthly_plan_id' => $planId,
'plan_date' => $date,
'hour_slot' => $hour,
'period' => $period,
'grid_snapshot_json' => $snapshotJson,
'notes' => $notes,
]);
return (int) $this->db->selectOne("SELECT LAST_INSERT_ID() as id")['id'];
}
public function copyHourPlan(int $planId, string $fromDate, string $fromHour, string $toDate, string $toHour): bool
{
$source = $this->getHourPlan($planId, $fromDate, $fromHour);
if (!$source) {
return false;
}
$snapshot = json_decode($source['grid_snapshot_json'], true);
$this->saveHourPlan($planId, $toDate, $toHour, $snapshot, $source['notes']);
return true;
}
public function getWorkingHoursForDate(int $planId, string $date): array
{
$plan = $this->db->selectOne(
"SELECT * FROM facility_monthly_plans WHERE id = ?",
[$planId]
);
if (!$plan) {
return [];
}
$dayStart = $plan['day_hours_start'] ?? '07:00';
$dayEnd = $plan['day_hours_end'] ?? '14:00';
$nightStart = $plan['night_hours_start'] ?? '14:00';
$nightEnd = $plan['night_hours_end'] ?? '22:00';
$hours = [];
$current = strtotime($dayStart);
$end = strtotime($nightEnd);
while ($current < $end) {
$timeStr = date('H:i', $current);
$period = $current < strtotime($nightStart) ? 'day' : 'night';
$hours[] = ['time' => $timeStr . ':00', 'period' => $period];
$current += 3600;
}
return $hours;
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تسجيل الحضور<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4>تسجيل الحضور</h4>
<div>
<a href="/facility-grids/<?= $grid_id ?>/attendance/report" class="btn btn-outline-info">تقرير الحضور</a>
<form method="post" action="/facility-grids/<?= $grid_id ?>/attendance/remind" class="d-inline">
<?= csrf_field() ?>
<button type="submit" class="btn btn-outline-warning" onclick="return confirm('إرسال تذكيرات الدفع؟')">إرسال تذكيرات</button>
</form>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-auto">
<label class="form-label">التاريخ</label>
<input type="date" name="date" class="form-control" value="<?= e($date) ?>">
</div>
<div class="col-auto">
<label class="form-label">الساعة (اختياري)</label>
<input type="time" name="hour" class="form-control" value="<?= e($hour_slot ?? '') ?>">
</div>
<div class="col-auto d-flex align-items-end">
<button type="submit" class="btn btn-primary">عرض</button>
</div>
</form>
</div>
</div>
<form method="post" action="/facility-grids/<?= $grid_id ?>/attendance">
<?= csrf_field() ?>
<input type="hidden" name="attendance_date" value="<?= e($date) ?>">
<input type="hidden" name="hour_slot" value="<?= e($hour_slot ?? '') ?>">
<?php if (!empty($coaches)): ?>
<div class="card mb-3">
<div class="card-header">المدربين</div>
<div class="card-body">
<table class="table table-bordered">
<thead><tr><th>المدرب</th><th>الحالة</th><th>ملاحظات</th></tr></thead>
<tbody>
<?php foreach ($coaches as $i => $coach): ?>
<tr>
<td><?= e($coach['name_ar']) ?></td>
<td>
<input type="hidden" name="attendance[<?= $i ?>][entity_type]" value="coach">
<input type="hidden" name="attendance[<?= $i ?>][entity_id]" value="<?= $coach['coach_id'] ?>">
<input type="hidden" name="attendance[<?= $i ?>][entity_name]" value="<?= e($coach['name_ar']) ?>">
<select name="attendance[<?= $i ?>][status]" class="form-select form-select-sm">
<option value="present">حاضر</option>
<option value="absent">غائب</option>
<option value="late">متأخر</option>
<option value="excused">بعذر</option>
</select>
</td>
<td><input type="text" name="attendance[<?= $i ?>][notes]" class="form-control form-control-sm"></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php if (!empty($trainees)): ?>
<div class="card mb-3">
<div class="card-header">المتدربين</div>
<div class="card-body">
<table class="table table-bordered">
<thead><tr><th>المتدرب</th><th>الحالة</th><th>ملاحظات</th></tr></thead>
<tbody>
<?php $offset = count($coaches); foreach ($trainees as $j => $trainee): ?>
<tr>
<td><?= e($trainee['name_ar']) ?></td>
<td>
<input type="hidden" name="attendance[<?= $offset + $j ?>][entity_type]" value="trainee">
<input type="hidden" name="attendance[<?= $offset + $j ?>][entity_id]" value="<?= $trainee['player_id'] ?>">
<input type="hidden" name="attendance[<?= $offset + $j ?>][entity_name]" value="<?= e($trainee['name_ar']) ?>">
<select name="attendance[<?= $offset + $j ?>][status]" class="form-select form-select-sm">
<option value="present">حاضر</option>
<option value="absent">غائب</option>
<option value="late">متأخر</option>
<option value="excused">بعذر</option>
</select>
</td>
<td><input type="text" name="attendance[<?= $offset + $j ?>][notes]" class="form-control form-control-sm"></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<button type="submit" class="btn btn-success btn-lg">حفظ الحضور</button>
</form>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>لوحة التحكم المالية - حمام السباحة<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4>لوحة التحكم المالية - حمام السباحة</h4>
<a href="/facility-grids/<?= $grid_id ?>/financial-dashboard/export?filter=<?= e($filter) ?>&from=<?= e($data['period']['from'] ?? '') ?>&to=<?= e($data['period']['to'] ?? '') ?>"
class="btn btn-outline-success" target="_blank">تصدير PDF</a>
</div>
<!-- Filter -->
<div class="card mb-3">
<div class="card-body">
<form method="get" class="row g-3 align-items-end">
<div class="col-auto">
<label class="form-label">الفترة</label>
<select name="filter" class="form-select" onchange="toggleCustomDates(this)">
<option value="daily" <?= $filter === 'daily' ? 'selected' : '' ?>>يومي</option>
<option value="weekly" <?= $filter === 'weekly' ? 'selected' : '' ?>>أسبوعي</option>
<option value="monthly" <?= $filter === 'monthly' ? 'selected' : '' ?>>شهري</option>
<option value="yearly" <?= $filter === 'yearly' ? 'selected' : '' ?>>سنوي</option>
<option value="5years" <?= $filter === '5years' ? 'selected' : '' ?>>5 سنوات</option>
<option value="custom" <?= $filter === 'custom' ? 'selected' : '' ?>>تحديد تاريخ</option>
</select>
</div>
<div class="col-auto custom-dates" style="<?= $filter !== 'custom' ? 'display:none' : '' ?>">
<label class="form-label">من</label>
<input type="date" name="from" class="form-control" value="<?= e($data['period']['from'] ?? '') ?>">
</div>
<div class="col-auto custom-dates" style="<?= $filter !== 'custom' ? 'display:none' : '' ?>">
<label class="form-label">إلى</label>
<input type="date" name="to" class="form-control" value="<?= e($data['period']['to'] ?? '') ?>">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary">عرض</button>
</div>
</form>
</div>
</div>
<!-- Stats Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body text-center">
<h6>إجمالي الإيرادات</h6>
<h3><?= number_format($data['total_revenue'] ?? 0, 2) ?> ج.م</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body text-center">
<h6>عدد الحجوزات</h6>
<h3><?= $data['total_bookings'] ?? 0 ?></h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body text-center">
<h6>المستخدمين الفريدين</h6>
<h3><?= $data['unique_users'] ?? 0 ?></h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-dark">
<div class="card-body text-center">
<h6>نسبة الحضور</h6>
<h3><?= $data['attendance']['avg_attendance_rate'] ?? 0 ?>%</h3>
</div>
</div>
</div>
</div>
<!-- Revenue by Type -->
<?php if (!empty($data['revenue_by_type'])): ?>
<div class="card mb-3">
<div class="card-header">الإيرادات حسب نوع الحاجز</div>
<div class="card-body">
<table class="table table-bordered">
<thead><tr><th>النوع</th><th>العدد</th><th>الإيرادات</th></tr></thead>
<tbody>
<?php foreach ($data['revenue_by_type'] as $row): ?>
<tr>
<td><?= e($row['booker_type']) ?></td>
<td><?= $row['count'] ?></td>
<td><?= number_format((float) $row['revenue'], 2) ?> ج.م</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- Monthly Trend -->
<?php if (!empty($data['monthly_trend'])): ?>
<div class="card mb-3">
<div class="card-header">الاتجاه الشهري</div>
<div class="card-body">
<table class="table table-bordered">
<thead><tr><th>الشهر</th><th>الحجوزات</th><th>الإيرادات</th></tr></thead>
<tbody>
<?php foreach ($data['monthly_trend'] as $row): ?>
<tr>
<td><?= $row['month'] ?></td>
<td><?= $row['bookings'] ?></td>
<td><?= number_format((float) $row['revenue'], 2) ?> ج.م</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>
<script>
function toggleCustomDates(el) {
document.querySelectorAll('.custom-dates').forEach(d => {
d.style.display = el.value === 'custom' ? '' : 'none';
});
}
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تعديل الخطة الساعية - <?= e($date) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4>الخطة الساعية ليوم <?= e($date) ?></h4>
<a href="/facility-grids/<?= $grid_id ?>/plans/<?= $plan_id ?>/hours" class="btn btn-outline-secondary">← العودة</a>
</div>
<div class="row">
<?php foreach ($working_hours as $wh): ?>
<div class="col-md-3 mb-3">
<div class="card <?= $wh['period'] === 'night' ? 'border-info' : 'border-warning' ?>">
<div class="card-header d-flex justify-content-between align-items-center">
<span><?= e($wh['time']) ?> <small class="badge bg-<?= $wh['period'] === 'night' ? 'info' : 'warning' ?>"><?= $wh['period'] === 'night' ? 'مسائي' : 'صباحي' ?></small></span>
<?php
$hasData = false;
foreach ($hours as $h) {
if ($h['hour_slot'] === $wh['time']) { $hasData = true; break; }
}
?>
<span class="badge bg-<?= $hasData ? 'success' : 'secondary' ?>"><?= $hasData ? 'محدد' : 'فارغ' ?></span>
</div>
<div class="card-body text-center">
<a href="/facility-grids/<?= $grid_id ?>/plans/<?= $plan_id ?>/hours/<?= $date ?>/<?= substr($wh['time'], 0, 5) ?>"
class="btn btn-sm btn-outline-primary">تعديل الشبكة</a>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الخطة الساعية - حمام السباحة<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4>الخطة الساعية: <?= e($plan['plan_name_ar'] ?? 'خطة ' . $plan['plan_month']) ?></h4>
<a href="/facility-grids/<?= $grid_id ?>/plans/<?= $plan['id'] ?>" class="btn btn-outline-secondary">← العودة للخطة</a>
</div>
<div class="card">
<div class="card-body">
<p><strong>أيام العمل:</strong>
<?php
$dayNames = ['الأحد', 'الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت'];
foreach ($working_days as $day) {
echo '<span class="badge bg-primary me-1">' . ($dayNames[$day] ?? $day) . '</span>';
}
?>
</p>
<p><strong>ساعات النهار:</strong> <?= e($plan['day_hours_start'] ?? '07:00') ?> - <?= e($plan['day_hours_end'] ?? '14:00') ?></p>
<p><strong>ساعات الليل:</strong> <?= e($plan['night_hours_start'] ?? '14:00') ?> - <?= e($plan['night_hours_end'] ?? '22:00') ?></p>
</div>
</div>
<div class="card mt-3">
<div class="card-header">اختر يوم لتعديل الخطة الساعية</div>
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-auto">
<input type="date" name="date" class="form-control" value="<?= date('Y-m-d') ?>" id="planDate">
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary" onclick="window.location='/facility-grids/<?= $grid_id ?>/plans/<?= $plan['id'] ?>/hours/'+document.getElementById('planDate').value">
عرض اليوم
</button>
</div>
</form>
</div>
</div>
</div>
<?php $__template->endSection(); ?>
......@@ -7,6 +7,7 @@ use App\Core\App;
use App\Core\Logger;
use App\Modules\Notifications\Models\NotificationTrigger;
use App\Modules\Notifications\Models\ScheduledNotification;
use App\Modules\PlayerApi\Services\PlayerNotificationService;
final class NotificationTriggerService
{
......@@ -24,6 +25,13 @@ final class NotificationTriggerService
foreach ($triggers as $trigger) {
try {
$channel = $trigger['channel'] ?? 'sms';
if ($channel === 'player_push') {
$queued += self::processPlayerPushChannel($trigger, $data, $eventName);
continue;
}
$recipients = self::resolveRecipients($trigger['recipient_type'], $data);
if (empty($recipients)) {
Logger::debug("No recipients found for trigger {$trigger['trigger_code']} on event {$eventName}");
......@@ -94,6 +102,43 @@ final class NotificationTriggerService
return $queued;
}
/**
* Handle player_push channel — writes directly to player_notifications table.
*/
private static function processPlayerPushChannel(array $trigger, array $data, string $eventName): int
{
$playerId = (int) ($data['player_id'] ?? 0);
if ($playerId <= 0) {
Logger::debug("player_push: no player_id for event {$eventName}");
return 0;
}
$templateId = $trigger['template_id'] ? (int) $trigger['template_id'] : null;
$message = $templateId ? self::renderMessage($templateId, $data) : null;
$categoryMap = [
'player.enrolled' => 'sport',
'player.booking_confirmed' => 'booking',
'player.booking_cancelled' => 'booking',
'player.free_time_entry' => 'booking',
'player.transfer_result' => 'sport',
'player.evaluation_completed' => 'rating',
'player.medical_expiring' => 'medical',
'facility.attendance_absent' => 'attendance',
];
$category = $categoryMap[$eventName] ?? 'event';
$title = $data['notification_title'] ?? ($trigger['trigger_name_ar'] ?? 'إشعار');
$body = $message ?? ($data['notification_body'] ?? '');
if (empty($body)) {
return 0;
}
PlayerNotificationService::notify($playerId, $category, $title, $body, $data);
return 1;
}
/**
* Resolve recipients based on recipient type and event data context.
* Returns array of ['type' => string, 'id' => int, 'phone' => string].
......
......@@ -223,5 +223,103 @@ EventBus::listen('session.feedback_submitted', function (array $data): void {
}
}, -10);
// ─── Player API & Trainer Portal Events ──────────────────────────────────────
EventBus::listen('player.enrolled', function (array $data): void {
try {
NotificationTriggerService::processEvent('player.enrolled', $data);
} catch (\Throwable $e) {
Logger::error("Notification trigger error [player.enrolled]: {$e->getMessage()}");
}
}, -10);
EventBus::listen('player.booking_confirmed', function (array $data): void {
try {
NotificationTriggerService::processEvent('player.booking_confirmed', $data);
} catch (\Throwable $e) {
Logger::error("Notification trigger error [player.booking_confirmed]: {$e->getMessage()}");
}
}, -10);
EventBus::listen('player.booking_cancelled', function (array $data): void {
try {
NotificationTriggerService::processEvent('player.booking_cancelled', $data);
} catch (\Throwable $e) {
Logger::error("Notification trigger error [player.booking_cancelled]: {$e->getMessage()}");
}
}, -10);
EventBus::listen('player.free_time_entry', function (array $data): void {
try {
NotificationTriggerService::processEvent('player.free_time_entry', $data);
} catch (\Throwable $e) {
Logger::error("Notification trigger error [player.free_time_entry]: {$e->getMessage()}");
}
}, -10);
EventBus::listen('player.transfer_result', function (array $data): void {
try {
NotificationTriggerService::processEvent('player.transfer_result', $data);
} catch (\Throwable $e) {
Logger::error("Notification trigger error [player.transfer_result]: {$e->getMessage()}");
}
}, -10);
EventBus::listen('player.evaluation_completed', function (array $data): void {
try {
NotificationTriggerService::processEvent('player.evaluation_completed', $data);
} catch (\Throwable $e) {
Logger::error("Notification trigger error [player.evaluation_completed]: {$e->getMessage()}");
}
}, -10);
EventBus::listen('player.medical_expiring', function (array $data): void {
try {
NotificationTriggerService::processEvent('player.medical_expiring', $data);
} catch (\Throwable $e) {
Logger::error("Notification trigger error [player.medical_expiring]: {$e->getMessage()}");
}
}, -10);
EventBus::listen('coach.day_off_requested', function (array $data): void {
try {
NotificationTriggerService::processEvent('coach.day_off_requested', $data);
} catch (\Throwable $e) {
Logger::error("Notification trigger error [coach.day_off_requested]: {$e->getMessage()}");
}
}, -10);
EventBus::listen('coach.replacement_confirmed', function (array $data): void {
try {
NotificationTriggerService::processEvent('coach.replacement_confirmed', $data);
} catch (\Throwable $e) {
Logger::error("Notification trigger error [coach.replacement_confirmed]: {$e->getMessage()}");
}
}, -10);
EventBus::listen('coach.session_reminder', function (array $data): void {
try {
NotificationTriggerService::processEvent('coach.session_reminder', $data);
} catch (\Throwable $e) {
Logger::error("Notification trigger error [coach.session_reminder]: {$e->getMessage()}");
}
}, -10);
EventBus::listen('facility.attendance_absent', function (array $data): void {
try {
NotificationTriggerService::processEvent('facility.attendance_absent', $data);
} catch (\Throwable $e) {
Logger::error("Notification trigger error [facility.attendance_absent]: {$e->getMessage()}");
}
}, -10);
EventBus::listen('facility.payment_reminder', function (array $data): void {
try {
NotificationTriggerService::processEvent('facility.payment_reminder', $data);
} catch (\Throwable $e) {
Logger::error("Notification trigger error [facility.payment_reminder]: {$e->getMessage()}");
}
}, -10);
// ─── SMS/WhatsApp Notification Service for Parents ─────────────────────────────
\App\Modules\Notifications\Services\SmsNotificationService::registerListeners();
<?php
declare(strict_types=1);
return [
// Sport Browsing & Enrollment
['GET', '/api/v1/player/sports', 'PlayerApi\Controllers\Api\SportBrowseController@listSports', ['cors', 'player_auth'], null],
['GET', '/api/v1/player/sports/{id:\d+}/groups', 'PlayerApi\Controllers\Api\SportBrowseController@listGroups', ['cors', 'player_auth'], null],
['GET', '/api/v1/player/groups/{id:\d+}', 'PlayerApi\Controllers\Api\SportBrowseController@groupDetails', ['cors', 'player_auth'], null],
// Enrollment
['POST', '/api/v1/player/groups/{id:\d+}/enroll', 'PlayerApi\Controllers\Api\EnrollmentController@enroll', ['cors', 'player_auth'], null],
['GET', '/api/v1/player/enrollments', 'PlayerApi\Controllers\Api\EnrollmentController@myEnrollments', ['cors', 'player_auth'], null],
['GET', '/api/v1/player/evaluations', 'PlayerApi\Controllers\Api\EnrollmentController@myEvaluations', ['cors', 'player_auth'], null],
// Group Transfer
['POST', '/api/v1/player/transfers', 'PlayerApi\Controllers\Api\TransferController@requestTransfer', ['cors', 'player_auth'], null],
['GET', '/api/v1/player/transfers', 'PlayerApi\Controllers\Api\TransferController@myTransfers', ['cors', 'player_auth'], null],
// Facility Booking
['GET', '/api/v1/player/facilities', 'PlayerApi\Controllers\Api\BookingController@facilities', ['cors', 'player_auth'], null],
['GET', '/api/v1/player/facilities/{id:\d+}/availability', 'PlayerApi\Controllers\Api\BookingController@availability', ['cors', 'player_auth'], null],
['POST', '/api/v1/player/bookings', 'PlayerApi\Controllers\Api\BookingController@book', ['cors', 'player_auth'], null],
['GET', '/api/v1/player/bookings', 'PlayerApi\Controllers\Api\BookingController@myBookings', ['cors', 'player_auth'], null],
['POST', '/api/v1/player/bookings/{id:\d+}/cancel', 'PlayerApi\Controllers\Api\BookingController@cancel', ['cors', 'player_auth'], null],
// Free-Time Entry
['GET', '/api/v1/player/free-time/facilities', 'PlayerApi\Controllers\Api\FreeTimeController@availableFacilities', ['cors', 'player_auth'], null],
['GET', '/api/v1/player/free-time/{facilityId:\d+}/slots', 'PlayerApi\Controllers\Api\FreeTimeController@slots', ['cors', 'player_auth'], null],
['POST', '/api/v1/player/free-time/enter', 'PlayerApi\Controllers\Api\FreeTimeController@enter', ['cors', 'player_auth'], null],
// Carnet (Invitations)
['GET', '/api/v1/player/carnet', 'PlayerApi\Controllers\Api\CarnetController@summary', ['cors', 'player_auth'], null],
['POST', '/api/v1/player/carnet/invite', 'PlayerApi\Controllers\Api\CarnetController@invite', ['cors', 'player_auth'], null],
['GET', '/api/v1/player/carnet/history', 'PlayerApi\Controllers\Api\CarnetController@history', ['cors', 'player_auth'], null],
// Notifications
['GET', '/api/v1/player/notifications', 'PlayerApi\Controllers\Api\NotificationController@list', ['cors', 'player_auth'], null],
['POST', '/api/v1/player/notifications/{id:\d+}/read', 'PlayerApi\Controllers\Api\NotificationController@markRead', ['cors', 'player_auth'], null],
['POST', '/api/v1/player/notifications/read-all', 'PlayerApi\Controllers\Api\NotificationController@markAllRead', ['cors', 'player_auth'], null],
['GET', '/api/v1/player/notifications/unread-count', 'PlayerApi\Controllers\Api\NotificationController@unreadCount', ['cors', 'player_auth'], null],
// Medical
['GET', '/api/v1/player/medical', 'PlayerApi\Controllers\Api\MedicalController@records', ['cors', 'player_auth'], null],
['POST', '/api/v1/player/medical', 'PlayerApi\Controllers\Api\MedicalController@submit', ['cors', 'player_auth'], null],
['POST', '/api/v1/player/medical/{id:\d+}/upload', 'PlayerApi\Controllers\Api\MedicalController@upload', ['cors', 'player_auth'], null],
// Profile
['GET', '/api/v1/player/profile', 'PlayerApi\Controllers\Api\ProfileController@show', ['cors', 'player_auth'], null],
['PUT', '/api/v1/player/profile', 'PlayerApi\Controllers\Api\ProfileController@update', ['cors', 'player_auth'], null],
];
<?php
declare(strict_types=1);
namespace App\Modules\PlayerApi\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Core\Exceptions\ValidationException;
use App\Modules\PlayerApi\Services\PlayerBookingService;
final class BookingController extends ApiController
{
private PlayerBookingService $bookingService;
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
$this->bookingService = new PlayerBookingService();
}
public function facilities(Request $request): Response
{
$facilities = $this->bookingService->getBookableFacilities();
return $this->successResponse($facilities);
}
public function availability(Request $request, string $id): Response
{
$date = $request->get('date', date('Y-m-d'));
$slots = $this->bookingService->getAvailability((int) $id, $date);
return $this->successResponse($slots);
}
public function book(Request $request): Response
{
try {
$data = $this->validatedJson([
'facility_id' => 'required|numeric',
'date' => 'required|string',
'start_time' => 'required|string',
'end_time' => 'required|string',
'notes' => 'nullable|string|max:500',
]);
} catch (ValidationException $e) {
return $this->errorResponse('بيانات غير صالحة', 422, $e->errors());
}
$player = App::getInstance()->currentPlayer();
$result = $this->bookingService->createBooking((int) $player->id, $data);
if (isset($result['error'])) {
return $this->errorResponse($result['error'], 422);
}
return $this->successResponse($result, 201);
}
public function myBookings(Request $request): Response
{
$player = App::getInstance()->currentPlayer();
$page = max(1, $this->queryInt('page', 1));
$perPage = min(50, max(1, $this->queryInt('per_page', 25)));
$result = $this->bookingService->getPlayerBookings((int) $player->id, $page, $perPage);
return $this->paginatedResponse(
$result['data'],
$result['pagination']['total'],
$result['pagination']['current_page'],
$result['pagination']['per_page']
);
}
public function cancel(Request $request, string $id): Response
{
$player = App::getInstance()->currentPlayer();
$success = $this->bookingService->cancelBooking((int) $id, (int) $player->id);
if (!$success) {
return $this->errorResponse('لا يمكن إلغاء هذا الحجز', 422);
}
return $this->successResponse(['cancelled' => true]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlayerApi\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Core\Exceptions\ValidationException;
final class CarnetController extends ApiController
{
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
}
public function summary(Request $request): Response
{
$player = App::getInstance()->currentPlayer();
$db = App::getInstance()->db();
if (empty($player->member_id)) {
return $this->errorResponse('الكارنيه متاح للأعضاء فقط', 403);
}
$carnet = $db->selectOne(
"SELECT id, carnet_number, total_invitations, used_invitations, status, issued_at
FROM carnets WHERE member_id = ? AND status = 'active'
ORDER BY issued_at DESC LIMIT 1",
[$player->member_id]
);
if (!$carnet) {
return $this->successResponse(['has_carnet' => false]);
}
$carnet['remaining_invitations'] = (int) $carnet['total_invitations'] - (int) $carnet['used_invitations'];
$carnet['has_carnet'] = true;
return $this->successResponse($carnet);
}
public function invite(Request $request): Response
{
try {
$data = $this->validatedJson([
'guest_name' => 'required|string|max:200',
'guest_phone' => 'nullable|string|max:20',
'guest_national_id' => 'nullable|string|max:20',
'activity_type' => 'required|string|in:free_swim,court_booking,recreation,ping_pong,bowling,playstation,tennis,paddle,gym',
]);
} catch (ValidationException $e) {
return $this->errorResponse('بيانات غير صالحة', 422, $e->errors());
}
$player = App::getInstance()->currentPlayer();
$db = App::getInstance()->db();
if (empty($player->member_id)) {
return $this->errorResponse('الكارنيه متاح للأعضاء فقط', 403);
}
$carnet = $db->selectOne(
"SELECT id, total_invitations, used_invitations FROM carnets WHERE member_id = ? AND status = 'active' ORDER BY issued_at DESC LIMIT 1",
[$player->member_id]
);
if (!$carnet) {
return $this->errorResponse('لا يوجد كارنيه نشط', 422);
}
if ((int) $carnet['used_invitations'] >= (int) $carnet['total_invitations']) {
return $this->errorResponse('تم استنفاد جميع الدعوات', 422);
}
$db->insert('carnet_guest_entries', [
'carnet_id' => $carnet['id'],
'guest_name' => $data['guest_name'],
'guest_phone' => $data['guest_phone'] ?? null,
'guest_national_id' => $data['guest_national_id'] ?? null,
'activity_type' => $data['activity_type'],
'entry_time' => date('Y-m-d H:i:s'),
'status' => 'active',
]);
$db->query(
"UPDATE carnets SET used_invitations = used_invitations + 1 WHERE id = ?",
[$carnet['id']]
);
$remaining = (int) $carnet['total_invitations'] - (int) $carnet['used_invitations'] - 1;
return $this->successResponse([
'invited' => true,
'remaining_invitations' => $remaining,
'guest_name' => $data['guest_name'],
], 201);
}
public function history(Request $request): Response
{
$player = App::getInstance()->currentPlayer();
$db = App::getInstance()->db();
if (empty($player->member_id)) {
return $this->errorResponse('الكارنيه متاح للأعضاء فقط', 403);
}
$entries = $db->select(
"SELECT cge.guest_name, cge.activity_type, cge.entry_time, cge.exit_time, cge.status
FROM carnet_guest_entries cge
INNER JOIN carnets c ON c.id = cge.carnet_id
WHERE c.member_id = ?
ORDER BY cge.entry_time DESC
LIMIT 50",
[$player->member_id]
);
return $this->successResponse($entries);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlayerApi\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Core\EventBus;
use App\Core\Exceptions\ValidationException;
final class EnrollmentController extends ApiController
{
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
}
public function enroll(Request $request, string $id): Response
{
$player = App::getInstance()->currentPlayer();
$db = App::getInstance()->db();
$groupId = (int) $id;
$group = $db->selectOne(
"SELECT * FROM training_groups WHERE id = ? AND is_active = 1 AND is_archived = 0",
[$groupId]
);
if (!$group) {
return $this->errorResponse('المجموعة غير موجودة', 404);
}
$existing = $db->selectOne(
"SELECT id FROM group_memberships WHERE player_id = ? AND group_id = ? AND status = 'active'",
[$player->id, $groupId]
);
if ($existing) {
return $this->errorResponse('أنت مسجل بالفعل في هذه المجموعة', 422);
}
if ($group['is_full'] || (int) $group['current_count'] >= (int) $group['max_capacity']) {
$db->insert('waiting_list', [
'player_id' => $player->id,
'discipline_id' => $group['discipline_id'],
'level_id' => $group['level_id'],
'preferred_day_of_week' => $group['day_of_week'],
'preferred_time' => $group['start_time'],
'requested_group_type' => $group['group_type'],
'status' => 'pending',
'priority' => 10,
]);
return $this->successResponse([
'status' => 'waiting_list',
'message' => 'المجموعة ممتلئة، تم إضافتك لقائمة الانتظار',
], 201);
}
$db->insert('group_memberships', [
'player_id' => $player->id,
'group_id' => $groupId,
'status' => 'active',
'joined_at' => date('Y-m-d H:i:s'),
]);
$db->query(
"UPDATE training_groups SET current_count = current_count + 1, is_full = (current_count + 1 >= max_capacity) WHERE id = ?",
[$groupId]
);
EventBus::dispatch('player.enrolled', [
'player_id' => (int) $player->id,
'group_id' => $groupId,
'group_name' => $group['name_ar'],
]);
return $this->successResponse([
'status' => 'enrolled',
'group_id' => $groupId,
'message' => 'تم التسجيل بنجاح',
], 201);
}
public function myEnrollments(Request $request): Response
{
$player = App::getInstance()->currentPlayer();
$db = App::getInstance()->db();
$enrollments = $db->select(
"SELECT gm.id, gm.status, gm.joined_at,
tg.name_ar as group_name, tg.group_type, tg.day_of_week, tg.start_time, tg.end_time,
c.name_ar as coach_name,
f.name_ar as facility_name,
sd.name_ar as discipline_name
FROM group_memberships gm
INNER JOIN training_groups tg ON tg.id = gm.group_id
LEFT JOIN coaches c ON c.id = tg.coach_id
LEFT JOIN facilities f ON f.id = tg.facility_id
LEFT JOIN sport_disciplines sd ON sd.id = tg.discipline_id
WHERE gm.player_id = ? AND gm.status = 'active'
ORDER BY tg.day_of_week, tg.start_time",
[$player->id]
);
return $this->successResponse($enrollments);
}
public function myEvaluations(Request $request): Response
{
$player = App::getInstance()->currentPlayer();
$db = App::getInstance()->db();
$evaluations = $db->select(
"SELECT pe.id, pe.skill_level, pe.scores_json, pe.strengths_ar, pe.weaknesses_ar,
pe.status, pe.created_at,
sd.name_ar as discipline_name,
c.name_ar as evaluator_name
FROM player_evaluations pe
LEFT JOIN sport_disciplines sd ON sd.id = pe.discipline_id
LEFT JOIN coaches c ON c.id = pe.evaluator_coach_id
WHERE pe.player_id = ? AND pe.status = 'approved'
ORDER BY pe.created_at DESC",
[$player->id]
);
return $this->successResponse($evaluations);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlayerApi\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Core\Exceptions\ValidationException;
use App\Modules\PlayerApi\Services\PlayerFreeTimeService;
final class FreeTimeController extends ApiController
{
private PlayerFreeTimeService $freeTimeService;
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
$this->freeTimeService = new PlayerFreeTimeService();
}
public function availableFacilities(Request $request): Response
{
$facilities = $this->freeTimeService->getFreeTimeFacilities();
return $this->successResponse($facilities);
}
public function slots(Request $request, string $facilityId): Response
{
$date = $request->get('date', date('Y-m-d'));
$slots = $this->freeTimeService->getFreeTimeSlots((int) $facilityId, $date);
return $this->successResponse($slots);
}
public function enter(Request $request): Response
{
try {
$data = $this->validatedJson([
'facility_id' => 'required|numeric',
'date' => 'nullable|string',
'entry_time' => 'nullable|string',
'activity_type' => 'nullable|string|in:free_swim,recreation,gym,open_play',
]);
} catch (ValidationException $e) {
return $this->errorResponse('بيانات غير صالحة', 422, $e->errors());
}
$player = App::getInstance()->currentPlayer();
$result = $this->freeTimeService->recordEntry((int) $player->id, $data);
if (isset($result['error'])) {
return $this->errorResponse($result['error'], 422);
}
return $this->successResponse($result, 201);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlayerApi\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Core\Exceptions\ValidationException;
final class MedicalController extends ApiController
{
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
}
public function records(Request $request): Response
{
$player = App::getInstance()->currentPlayer();
$db = App::getInstance()->db();
$records = $db->select(
"SELECT id, record_type, certificate_type, exam_date, expiry_date,
doctor_name, clinic_name, result, restrictions,
approval_status, rejection_reason, document_id, created_at
FROM player_medical_records
WHERE player_id = ?
ORDER BY exam_date DESC",
[$player->id]
);
return $this->successResponse($records);
}
public function submit(Request $request): Response
{
try {
$data = $this->validatedJson([
'record_type' => 'required|string|in:fitness_cert,medical_exam,blood_test,cardiac_screen,other',
'certificate_type' => 'nullable|string|in:recreational,academy,international',
'exam_date' => 'required|string',
'expiry_date' => 'required|string',
'doctor_name' => 'nullable|string|max:200',
'clinic_name' => 'nullable|string|max:200',
'cert_number' => 'nullable|string|max:100',
'restrictions' => 'nullable|string|max:500',
]);
} catch (ValidationException $e) {
return $this->errorResponse('بيانات غير صالحة', 422, $e->errors());
}
$player = App::getInstance()->currentPlayer();
$db = App::getInstance()->db();
$db->insert('player_medical_records', [
'player_id' => $player->id,
'record_type' => $data['record_type'],
'certificate_type' => $data['certificate_type'] ?? null,
'exam_date' => $data['exam_date'],
'expiry_date' => $data['expiry_date'],
'doctor_name' => $data['doctor_name'] ?? null,
'clinic_name' => $data['clinic_name'] ?? null,
'cert_number' => $data['cert_number'] ?? null,
'restrictions' => $data['restrictions'] ?? null,
'result' => 'pending',
'approval_status' => 'pending',
]);
$recordId = (int) $db->selectOne("SELECT LAST_INSERT_ID() as id")['id'];
$db->update('players', [
'medical_status' => 'pending',
'medical_expiry_date' => $data['expiry_date'],
], 'id = ?', [$player->id]);
return $this->successResponse([
'id' => $recordId,
'message' => 'تم تقديم السجل الطبي وسيتم مراجعته',
], 201);
}
public function upload(Request $request, string $id): Response
{
$player = App::getInstance()->currentPlayer();
$db = App::getInstance()->db();
$record = $db->selectOne(
"SELECT id FROM player_medical_records WHERE id = ? AND player_id = ?",
[(int) $id, $player->id]
);
if (!$record) {
return $this->errorResponse('السجل الطبي غير موجود', 404);
}
if (!isset($_FILES['document']) || $_FILES['document']['error'] !== UPLOAD_ERR_OK) {
return $this->errorResponse('يرجى رفع ملف صالح', 422);
}
$file = $_FILES['document'];
$allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
if (!in_array($file['type'], $allowedTypes, true)) {
return $this->errorResponse('نوع الملف غير مسموح. المسموح: JPG, PNG, WebP, PDF', 422);
}
$maxSize = 10 * 1024 * 1024; // 10MB
if ($file['size'] > $maxSize) {
return $this->errorResponse('حجم الملف أكبر من 10 ميجا', 422);
}
$ext = match ($file['type']) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
default => 'bin',
};
$filename = 'medical_' . $player->id . '_' . $id . '_' . time() . '.' . $ext;
$uploadDir = App::getInstance()->storagePath() . '/uploads/medical';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$destination = $uploadDir . '/' . $filename;
move_uploaded_file($file['tmp_name'], $destination);
$db->insert('documents', [
'entity_type' => 'player_medical',
'entity_id' => (int) $id,
'file_name' => $filename,
'file_path' => 'uploads/medical/' . $filename,
'file_type' => $file['type'],
'file_size' => $file['size'],
'uploaded_by_type' => 'player',
'uploaded_by_id' => $player->id,
]);
$documentId = (int) $db->selectOne("SELECT LAST_INSERT_ID() as id")['id'];
$db->update('player_medical_records', [
'document_id' => $documentId,
], 'id = ?', [(int) $id]);
return $this->successResponse([
'document_id' => $documentId,
'file_name' => $filename,
'message' => 'تم رفع المستند بنجاح',
], 201);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlayerApi\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Modules\PlayerApi\Services\PlayerNotificationService;
final class NotificationController extends ApiController
{
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
}
public function list(Request $request): Response
{
$player = App::getInstance()->currentPlayer();
$page = max(1, $this->queryInt('page', 1));
$perPage = min(50, max(1, $this->queryInt('per_page', 25)));
$category = $request->get('category');
$isRead = $request->get('is_read') !== null ? (bool) (int) $request->get('is_read') : null;
$result = PlayerNotificationService::getForPlayer(
(int) $player->id,
$category,
$isRead,
$page,
$perPage
);
return $this->paginatedResponse(
$result['data'],
$result['pagination']['total'],
$result['pagination']['current_page'],
$result['pagination']['per_page']
);
}
public function markRead(Request $request, string $id): Response
{
$player = App::getInstance()->currentPlayer();
PlayerNotificationService::markRead((int) $id, (int) $player->id);
return $this->successResponse(['read' => true]);
}
public function markAllRead(Request $request): Response
{
$player = App::getInstance()->currentPlayer();
PlayerNotificationService::markAllRead((int) $player->id);
return $this->successResponse(['read_all' => true]);
}
public function unreadCount(Request $request): Response
{
$player = App::getInstance()->currentPlayer();
$count = PlayerNotificationService::unreadCount((int) $player->id);
return $this->successResponse(['unread_count' => $count]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlayerApi\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Core\Exceptions\ValidationException;
final class ProfileController extends ApiController
{
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
}
public function show(Request $request): Response
{
$player = App::getInstance()->currentPlayer();
$db = App::getInstance()->db();
$full = $db->selectOne(
"SELECT id, name_ar, name_en, phone, date_of_birth, gender, national_id,
member_id, player_type, medical_status, medical_expiry_date,
guardian_name, guardian_phone, guardian_relationship,
card_status, created_at
FROM players WHERE id = ?",
[$player->id]
);
$memberInfo = null;
if (!empty($full['member_id'])) {
$memberInfo = $db->selectOne(
"SELECT membership_number, membership_type, membership_status
FROM members WHERE id = ?",
[$full['member_id']]
);
}
$full['membership'] = $memberInfo;
return $this->successResponse($full);
}
public function update(Request $request): Response
{
try {
$data = $this->validatedJson([
'phone' => 'nullable|string|max:20',
'guardian_name' => 'nullable|string|max:200',
'guardian_phone' => 'nullable|string|max:20',
'guardian_relationship' => 'nullable|string|max:50',
]);
} catch (ValidationException $e) {
return $this->errorResponse('بيانات غير صالحة', 422, $e->errors());
}
$player = App::getInstance()->currentPlayer();
$db = App::getInstance()->db();
$updateData = [];
if (isset($data['phone']) && $data['phone'] !== '') {
$existing = $db->selectOne(
"SELECT id FROM players WHERE phone = ? AND id != ?",
[$data['phone'], $player->id]
);
if ($existing) {
return $this->errorResponse('رقم الهاتف مستخدم بالفعل', 422);
}
$updateData['phone'] = $data['phone'];
}
if (isset($data['guardian_name'])) {
$updateData['guardian_name'] = $data['guardian_name'];
}
if (isset($data['guardian_phone'])) {
$updateData['guardian_phone'] = $data['guardian_phone'];
}
if (isset($data['guardian_relationship'])) {
$updateData['guardian_relationship'] = $data['guardian_relationship'];
}
if (!empty($updateData)) {
$db->update('players', $updateData, 'id = ?', [$player->id]);
}
return $this->successResponse(['updated' => true]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlayerApi\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
final class SportBrowseController extends ApiController
{
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
}
public function listSports(Request $request): Response
{
$db = App::getInstance()->db();
$sports = $db->select(
"SELECT id, name_ar, name_en, category, description_ar, is_active
FROM sport_disciplines WHERE is_active = 1 AND is_archived = 0
ORDER BY name_ar"
);
return $this->successResponse($sports);
}
public function listGroups(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$disciplineId = (int) $id;
$filters = [];
$params = [$disciplineId];
$where = ['tg.discipline_id = ?', 'tg.is_active = 1', 'tg.is_archived = 0', 'tg.is_full = 0'];
if ($request->get('level_id')) {
$where[] = 'tg.level_id = ?';
$params[] = (int) $request->get('level_id');
}
if ($request->get('coach_id')) {
$where[] = 'tg.coach_id = ?';
$params[] = (int) $request->get('coach_id');
}
if ($request->get('day_of_week')) {
$where[] = 'tg.day_of_week = ?';
$params[] = $request->get('day_of_week');
}
if ($request->get('group_type')) {
$where[] = 'tg.group_type = ?';
$params[] = $request->get('group_type');
}
if ($request->get('gender')) {
$where[] = 'tg.gender_restriction = ?';
$params[] = $request->get('gender');
}
$whereClause = implode(' AND ', $where);
$groups = $db->select(
"SELECT tg.id, tg.name_ar, tg.group_type, tg.day_of_week, tg.start_time, tg.end_time,
tg.min_capacity, tg.max_capacity, tg.current_count, tg.gender_restriction,
tg.age_from, tg.age_to, tg.pricing_tier,
c.name_ar as coach_name,
f.name_ar as facility_name,
al.name_ar as level_name
FROM training_groups tg
LEFT JOIN coaches c ON c.id = tg.coach_id
LEFT JOIN facilities f ON f.id = tg.facility_id
LEFT JOIN academy_levels al ON al.id = tg.level_id
WHERE {$whereClause}
ORDER BY tg.day_of_week, tg.start_time",
$params
);
return $this->successResponse($groups);
}
public function groupDetails(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$groupId = (int) $id;
$group = $db->selectOne(
"SELECT tg.*, c.name_ar as coach_name, c.phone as coach_phone,
f.name_ar as facility_name, f.facility_type,
al.name_ar as level_name,
a.name_ar as academy_name
FROM training_groups tg
LEFT JOIN coaches c ON c.id = tg.coach_id
LEFT JOIN facilities f ON f.id = tg.facility_id
LEFT JOIN academy_levels al ON al.id = tg.level_id
LEFT JOIN academies a ON a.id = tg.academy_id
WHERE tg.id = ? AND tg.is_archived = 0",
[$groupId]
);
if (!$group) {
return $this->errorResponse('المجموعة غير موجودة', 404);
}
$pricing = $db->selectOne(
"SELECT member_rate, non_member_rate
FROM activity_pricing
WHERE entity_type = 'discipline' AND entity_id = ? AND is_active = 1
AND (effective_from IS NULL OR effective_from <= CURDATE())
AND (effective_to IS NULL OR effective_to >= CURDATE())
ORDER BY effective_from DESC LIMIT 1",
[$group['discipline_id']]
);
$group['pricing'] = $pricing;
$group['available_spots'] = max(0, (int) $group['max_capacity'] - (int) $group['current_count']);
return $this->successResponse($group);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlayerApi\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Core\Exceptions\ValidationException;
final class TransferController extends ApiController
{
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
}
public function requestTransfer(Request $request): Response
{
try {
$data = $this->validatedJson([
'from_group_id' => 'required|numeric',
'to_group_id' => 'required|numeric',
'reason' => 'nullable|string|max:500',
]);
} catch (ValidationException $e) {
return $this->errorResponse('بيانات غير صالحة', 422, $e->errors());
}
$player = App::getInstance()->currentPlayer();
$db = App::getInstance()->db();
$membership = $db->selectOne(
"SELECT id FROM group_memberships WHERE player_id = ? AND group_id = ? AND status = 'active'",
[$player->id, (int) $data['from_group_id']]
);
if (!$membership) {
return $this->errorResponse('أنت غير مسجل في المجموعة المصدر', 422);
}
$toGroup = $db->selectOne(
"SELECT id, name_ar, is_full, current_count, max_capacity FROM training_groups WHERE id = ? AND is_active = 1 AND is_archived = 0",
[(int) $data['to_group_id']]
);
if (!$toGroup) {
return $this->errorResponse('المجموعة المستهدفة غير متاحة', 404);
}
$pending = $db->selectOne(
"SELECT id FROM group_transfer_requests WHERE player_id = ? AND status = 'pending'",
[$player->id]
);
if ($pending) {
return $this->errorResponse('لديك طلب نقل معلق بالفعل', 422);
}
$db->insert('group_transfer_requests', [
'player_id' => $player->id,
'from_group_id' => (int) $data['from_group_id'],
'to_group_id' => (int) $data['to_group_id'],
'reason_ar' => $data['reason'] ?? null,
'status' => 'pending',
]);
$requestId = (int) $db->selectOne("SELECT LAST_INSERT_ID() as id")['id'];
return $this->successResponse([
'id' => $requestId,
'status' => 'pending',
'message' => 'تم إرسال طلب النقل وسيتم مراجعته',
], 201);
}
public function myTransfers(Request $request): Response
{
$player = App::getInstance()->currentPlayer();
$db = App::getInstance()->db();
$transfers = $db->select(
"SELECT gtr.id, gtr.status, gtr.reason_ar, gtr.review_notes, gtr.created_at, gtr.reviewed_at,
fg.name_ar as from_group_name,
tg.name_ar as to_group_name
FROM group_transfer_requests gtr
LEFT JOIN training_groups fg ON fg.id = gtr.from_group_id
LEFT JOIN training_groups tg ON tg.id = gtr.to_group_id
WHERE gtr.player_id = ?
ORDER BY gtr.created_at DESC",
[$player->id]
);
return $this->successResponse($transfers);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlayerApi\Services;
use App\Core\App;
use App\Core\EventBus;
final class PlayerBookingService
{
private \App\Core\Database $db;
public function __construct()
{
$this->db = App::getInstance()->db();
}
public function getBookableFacilities(): array
{
return $this->db->select(
"SELECT id, name_ar, facility_type, location, hourly_rate_member, hourly_rate_nonmember
FROM facilities WHERE is_active = 1 AND is_archived = 0
AND facility_type IN ('pitch', 'court', 'hall', 'table', 'device')
ORDER BY name_ar"
);
}
public function getAvailability(int $facilityId, string $date): array
{
$facility = $this->db->selectOne(
"SELECT * FROM facilities WHERE id = ? AND is_active = 1 AND is_archived = 0",
[$facilityId]
);
if (!$facility) {
return [];
}
$dayOfWeek = (int) date('w', strtotime($date));
$timeSlots = $this->db->select(
"SELECT start_time, end_time, access_type, member_price, guest_price
FROM facility_time_slots
WHERE facility_id = ? AND day_of_week = ? AND is_active = 1",
[$facilityId, $dayOfWeek]
);
$existingBookings = $this->db->select(
"SELECT start_time, end_time FROM reservations
WHERE facility_id = ? AND reservation_date = ? AND status NOT IN ('cancelled', 'no_show')",
[$facilityId, $date]
);
$available = [];
foreach ($timeSlots as $slot) {
$isBooked = false;
foreach ($existingBookings as $booking) {
if ($slot['start_time'] < $booking['end_time'] && $slot['end_time'] > $booking['start_time']) {
$isBooked = true;
break;
}
}
$slot['is_available'] = !$isBooked;
$available[] = $slot;
}
return $available;
}
public function createBooking(int $playerId, array $data): array
{
$player = App::getInstance()->currentPlayer();
$isMember = !empty($player->member_id);
$facility = $this->db->selectOne(
"SELECT * FROM facilities WHERE id = ? AND is_active = 1 AND is_archived = 0",
[$data['facility_id']]
);
if (!$facility) {
return ['error' => 'الملعب غير متاح'];
}
$conflict = $this->db->selectOne(
"SELECT id FROM reservations
WHERE facility_id = ? AND reservation_date = ? AND status NOT IN ('cancelled', 'no_show')
AND start_time < ? AND end_time > ?",
[$data['facility_id'], $data['date'], $data['end_time'], $data['start_time']]
);
if ($conflict) {
return ['error' => 'الوقت المطلوب محجوز بالفعل'];
}
$rate = $isMember
? (float) ($facility['hourly_rate_member'] ?? $facility['hourly_rate_nonmember'])
: (float) $facility['hourly_rate_nonmember'];
$hours = (strtotime($data['end_time']) - strtotime($data['start_time'])) / 3600;
$totalAmount = $rate * $hours;
$reservationNumber = 'RES-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$this->db->insert('reservations', [
'reservation_number' => $reservationNumber,
'facility_id' => $data['facility_id'],
'booker_type' => 'player',
'booker_id' => $playerId,
'reservation_date' => $data['date'],
'start_time' => $data['start_time'],
'end_time' => $data['end_time'],
'status' => 'confirmed',
'unit_rate' => $rate,
'total_amount' => $totalAmount,
'notes' => $data['notes'] ?? null,
]);
$bookingId = (int) $this->db->selectOne("SELECT LAST_INSERT_ID() as id")['id'];
EventBus::dispatch('player.booking_confirmed', [
'player_id' => $playerId,
'booking_id' => $bookingId,
'facility_name' => $facility['name_ar'],
'date' => $data['date'],
]);
return [
'id' => $bookingId,
'reservation_number' => $reservationNumber,
'total_amount' => $totalAmount,
];
}
public function getPlayerBookings(int $playerId, int $page = 1, int $perPage = 25): array
{
$total = (int) ($this->db->selectOne(
"SELECT COUNT(*) as cnt FROM reservations WHERE booker_type = 'player' AND booker_id = ?",
[$playerId]
)['cnt'] ?? 0);
$lastPage = max(1, (int) ceil($total / $perPage));
$page = min($page, $lastPage);
$offset = ($page - 1) * $perPage;
$items = $this->db->select(
"SELECT r.*, f.name_ar as facility_name, f.facility_type
FROM reservations r
LEFT JOIN facilities f ON f.id = r.facility_id
WHERE r.booker_type = 'player' AND r.booker_id = ?
ORDER BY r.reservation_date DESC, r.start_time DESC
LIMIT ? OFFSET ?",
[$playerId, $perPage, $offset]
);
return [
'data' => $items,
'pagination' => ['total' => $total, 'per_page' => $perPage, 'current_page' => $page, 'last_page' => $lastPage],
];
}
public function cancelBooking(int $bookingId, int $playerId): bool
{
$booking = $this->db->selectOne(
"SELECT id, status FROM reservations WHERE id = ? AND booker_type = 'player' AND booker_id = ?",
[$bookingId, $playerId]
);
if (!$booking || $booking['status'] === 'cancelled') {
return false;
}
$this->db->update('reservations', ['status' => 'cancelled'], 'id = ?', [$bookingId]);
EventBus::dispatch('player.booking_cancelled', [
'player_id' => $playerId,
'booking_id' => $bookingId,
]);
return true;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlayerApi\Services;
use App\Core\App;
use App\Core\EventBus;
final class PlayerFreeTimeService
{
private \App\Core\Database $db;
public function __construct()
{
$this->db = App::getInstance()->db();
}
public function getFreeTimeFacilities(): array
{
return $this->db->select(
"SELECT DISTINCT f.id, f.name_ar, f.facility_type, f.location,
f.hourly_rate_member, f.hourly_rate_nonmember
FROM facilities f
INNER JOIN facility_time_slots fts ON fts.facility_id = f.id
WHERE f.is_active = 1 AND f.is_archived = 0
AND fts.access_type IN ('open', 'members_only')
AND f.facility_type IN ('pool', 'hall', 'court', 'table', 'device')
ORDER BY f.name_ar"
);
}
public function getFreeTimeSlots(int $facilityId, string $date): array
{
$dayOfWeek = (int) date('w', strtotime($date));
return $this->db->select(
"SELECT fts.id, fts.start_time, fts.end_time, fts.access_type,
fts.member_price, fts.guest_price
FROM facility_time_slots fts
WHERE fts.facility_id = ? AND fts.day_of_week = ? AND fts.is_active = 1
AND fts.access_type IN ('open', 'members_only')
ORDER BY fts.start_time",
[$facilityId, $dayOfWeek]
);
}
public function recordEntry(int $playerId, array $data): array
{
$player = App::getInstance()->currentPlayer();
$isMember = !empty($player->member_id);
$facility = $this->db->selectOne(
"SELECT id, name_ar, hourly_rate_member, hourly_rate_nonmember
FROM facilities WHERE id = ? AND is_active = 1",
[$data['facility_id']]
);
if (!$facility) {
return ['error' => 'المرفق غير متاح'];
}
$dayOfWeek = (int) date('w', strtotime($data['date'] ?? date('Y-m-d')));
$currentTime = $data['entry_time'] ?? date('H:i:s');
$slot = $this->db->selectOne(
"SELECT access_type, member_price, guest_price
FROM facility_time_slots
WHERE facility_id = ? AND day_of_week = ? AND start_time <= ? AND end_time > ? AND is_active = 1
AND access_type IN ('open', 'members_only')",
[$data['facility_id'], $dayOfWeek, $currentTime, $currentTime]
);
if (!$slot) {
return ['error' => 'لا يوجد وقت حر متاح الآن'];
}
if ($slot['access_type'] === 'members_only' && !$isMember) {
return ['error' => 'هذا الوقت مخصص للأعضاء فقط'];
}
$amount = $isMember
? (float) ($slot['member_price'] ?? $facility['hourly_rate_member'] ?? 0)
: (float) ($slot['guest_price'] ?? $facility['hourly_rate_nonmember'] ?? 0);
$this->db->insert('free_time_entries', [
'player_id' => $playerId,
'member_id' => $player->member_id ?? null,
'facility_id' => $data['facility_id'],
'entry_date' => $data['date'] ?? date('Y-m-d'),
'entry_time' => $currentTime,
'activity_type' => $data['activity_type'] ?? 'recreation',
'is_member' => $isMember ? 1 : 0,
'amount_paid' => $amount,
'status' => 'active',
]);
$entryId = (int) $this->db->selectOne("SELECT LAST_INSERT_ID() as id")['id'];
EventBus::dispatch('player.free_time_entry', [
'player_id' => $playerId,
'entry_id' => $entryId,
'facility_name' => $facility['name_ar'],
]);
return [
'id' => $entryId,
'amount_paid' => $amount,
'facility_name' => $facility['name_ar'],
];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlayerApi\Services;
use App\Core\App;
final class PlayerNotificationService
{
public static function notify(int $playerId, string $category, string $title, string $body, array $data = []): void
{
$db = App::getInstance()->db();
$db->insert('player_notifications', [
'player_id' => $playerId,
'category' => $category,
'title_ar' => $title,
'body_ar' => $body,
'data_json' => !empty($data) ? json_encode($data, JSON_UNESCAPED_UNICODE) : null,
]);
}
public static function getForPlayer(int $playerId, ?string $category = null, ?bool $isRead = null, int $page = 1, int $perPage = 25): array
{
$db = App::getInstance()->db();
$where = ['player_id = ?'];
$params = [$playerId];
if ($category !== null) {
$where[] = 'category = ?';
$params[] = $category;
}
if ($isRead !== null) {
$where[] = 'is_read = ?';
$params[] = $isRead ? 1 : 0;
}
$whereClause = implode(' AND ', $where);
$total = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM player_notifications WHERE {$whereClause}",
$params
)['cnt'] ?? 0);
$lastPage = max(1, (int) ceil($total / $perPage));
$page = min($page, $lastPage);
$offset = ($page - 1) * $perPage;
$items = $db->select(
"SELECT id, category, title_ar, body_ar, data_json, is_read, read_at, created_at
FROM player_notifications WHERE {$whereClause}
ORDER BY created_at DESC LIMIT ? OFFSET ?",
array_merge($params, [$perPage, $offset])
);
return [
'data' => $items,
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => $lastPage,
],
];
}
public static function markRead(int $notificationId, int $playerId): bool
{
$db = App::getInstance()->db();
$db->update('player_notifications', [
'is_read' => 1,
'read_at' => date('Y-m-d H:i:s'),
], 'id = ? AND player_id = ?', [$notificationId, $playerId]);
return true;
}
public static function markAllRead(int $playerId): void
{
$db = App::getInstance()->db();
$db->query(
"UPDATE player_notifications SET is_read = 1, read_at = NOW() WHERE player_id = ? AND is_read = 0",
[$playerId]
);
}
public static function unreadCount(int $playerId): int
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COUNT(*) as cnt FROM player_notifications WHERE player_id = ? AND is_read = 0",
[$playerId]
);
return (int) ($row['cnt'] ?? 0);
}
}
<?php
declare(strict_types=1);
use App\Core\EventBus;
use App\Modules\PlayerApi\Services\PlayerNotificationService;
EventBus::listen('player.enrolled', function (array $data) {
PlayerNotificationService::notify(
(int) $data['player_id'],
'sport',
'تم تسجيلك بنجاح',
'تم تسجيلك في مجموعة ' . ($data['group_name'] ?? ''),
$data
);
});
EventBus::listen('player.booking_confirmed', function (array $data) {
PlayerNotificationService::notify(
(int) $data['player_id'],
'booking',
'تم تأكيد الحجز',
'تم تأكيد حجزك في ' . ($data['facility_name'] ?? '') . ' بتاريخ ' . ($data['date'] ?? ''),
$data
);
});
EventBus::listen('player.transfer_result', function (array $data) {
PlayerNotificationService::notify(
(int) $data['player_id'],
'sport',
'نتيجة طلب النقل',
($data['status'] === 'approved' ? 'تمت الموافقة على' : 'تم رفض') . ' طلب نقلك',
$data
);
});
EventBus::listen('player.booking_cancelled', function (array $data) {
PlayerNotificationService::notify(
(int) $data['player_id'],
'booking',
'تم إلغاء الحجز',
'تم إلغاء حجزك في ' . ($data['facility_name'] ?? '') . ' بتاريخ ' . ($data['date'] ?? ''),
$data
);
});
EventBus::listen('player.free_time_entry', function (array $data) {
PlayerNotificationService::notify(
(int) $data['player_id'],
'booking',
'دخول وقت حر',
'تم تسجيل دخولك في ' . ($data['facility_name'] ?? ''),
$data
);
});
EventBus::listen('player.evaluation_completed', function (array $data) {
PlayerNotificationService::notify(
(int) $data['player_id'],
'rating',
'تم تقييمك',
'تم تقييمك من المدرب. يمكنك الاطلاع على نتيجتك من خلال التطبيق.',
$data
);
});
EventBus::listen('player.medical_expiring', function (array $data) {
PlayerNotificationService::notify(
(int) $data['player_id'],
'medical',
'انتهاء كشف طبي',
'تنبيه: كشفك الطبي سينتهي خلال 7 أيام. يرجى تجديده لمواصلة التدريب.',
$data
);
});
EventBus::listen('facility.attendance_absent', function (array $data) {
if (($data['entity_type'] ?? '') === 'trainee' && !empty($data['entity_id'])) {
$db = \App\Core\App::getInstance()->db();
$player = $db->selectOne("SELECT id FROM players WHERE id = ?", [(int) $data['entity_id']]);
if ($player) {
PlayerNotificationService::notify(
(int) $data['entity_id'],
'attendance',
'تسجيل غياب',
'تم تسجيل غيابك يوم ' . ($data['date'] ?? date('Y-m-d')),
$data
);
}
}
});
<?php
declare(strict_types=1);
return [
['POST', '/api/v1/player/auth/login', 'PlayerAuth\Controllers\Api\PlayerTokenController@login', ['cors'], null],
['POST', '/api/v1/player/auth/otp/request', 'PlayerAuth\Controllers\Api\PlayerTokenController@otpRequest', ['cors'], null],
['POST', '/api/v1/player/auth/otp/verify', 'PlayerAuth\Controllers\Api\PlayerTokenController@otpVerify', ['cors'], null],
['POST', '/api/v1/player/auth/refresh', 'PlayerAuth\Controllers\Api\PlayerTokenController@refresh', ['cors', 'player_auth'], null],
['POST', '/api/v1/player/auth/logout', 'PlayerAuth\Controllers\Api\PlayerTokenController@logout', ['cors', 'player_auth'], null],
];
<?php
declare(strict_types=1);
namespace App\Modules\PlayerAuth\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Core\Exceptions\ValidationException;
use App\Modules\PlayerAuth\Services\PlayerAuthService;
final class PlayerTokenController extends ApiController
{
private PlayerAuthService $authService;
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
$this->authService = new PlayerAuthService();
}
public function login(Request $request): Response
{
try {
$data = $this->validatedJson([
'phone' => 'required|string',
'password' => 'required|string',
'device_info' => 'nullable|string|max:255',
]);
} catch (ValidationException $e) {
return $this->errorResponse('بيانات غير صالحة', 422, $e->errors());
}
$player = $this->authService->findPlayerByPhone($data['phone']);
if (!$player || !$this->authService->verifyPassword($player, $data['password'])) {
return $this->errorResponse('رقم الهاتف أو كلمة المرور غير صحيحة', 401);
}
$tokenData = $this->authService->createToken(
(int) $player['id'],
'mobile',
$data['device_info'] ?? null
);
return $this->successResponse([
'token' => $tokenData['token'],
'expires_at' => $tokenData['expires_at'],
'player' => [
'id' => (int) $player['id'],
'name' => $player['name_ar'],
'phone' => $player['phone'],
],
], 201);
}
public function otpRequest(Request $request): Response
{
try {
$data = $this->validatedJson([
'phone' => 'required|string',
]);
} catch (ValidationException $e) {
return $this->errorResponse('بيانات غير صالحة', 422, $e->errors());
}
$player = $this->authService->findPlayerByPhone($data['phone']);
if (!$player) {
return $this->successResponse(['sent' => true]);
}
$otp = $this->authService->generateOtp((int) $player['id']);
// TODO: Send OTP via SMS using SmsService
// For now just return success (in production, integrate with SmsNotificationService)
return $this->successResponse(['sent' => true]);
}
public function otpVerify(Request $request): Response
{
try {
$data = $this->validatedJson([
'phone' => 'required|string',
'otp' => 'required|string|max:6',
'device_info' => 'nullable|string|max:255',
]);
} catch (ValidationException $e) {
return $this->errorResponse('بيانات غير صالحة', 422, $e->errors());
}
$player = $this->authService->findPlayerByPhone($data['phone']);
if (!$player) {
return $this->errorResponse('رمز التحقق غير صحيح', 401);
}
if (!$this->authService->verifyOtp($player, $data['otp'])) {
return $this->errorResponse('رمز التحقق غير صحيح أو منتهي الصلاحية', 401);
}
$tokenData = $this->authService->createToken(
(int) $player['id'],
'mobile',
$data['device_info'] ?? null
);
return $this->successResponse([
'token' => $tokenData['token'],
'expires_at' => $tokenData['expires_at'],
'player' => [
'id' => (int) $player['id'],
'name' => $player['name_ar'],
'phone' => $player['phone'],
],
], 201);
}
public function refresh(Request $request): Response
{
$player = App::getInstance()->currentPlayer();
$currentToken = $request->bearerToken();
$tokenData = $this->authService->refreshToken($currentToken, (int) $player->id);
return $this->successResponse([
'token' => $tokenData['token'],
'expires_at' => $tokenData['expires_at'],
], 201);
}
public function logout(Request $request): Response
{
$currentToken = $request->bearerToken();
$this->authService->revokeToken($currentToken);
return $this->successResponse(['logged_out' => true]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlayerAuth\Services;
use App\Core\App;
use App\Core\Database;
final class PlayerAuthService
{
private Database $db;
public function __construct()
{
$this->db = App::getInstance()->db();
}
public function findPlayerByPhone(string $phone): ?array
{
return $this->db->selectOne(
"SELECT id, name_ar, phone, password_hash, is_archived FROM players WHERE phone = ? AND is_archived = 0",
[$phone]
);
}
public function verifyPassword(array $player, string $password): bool
{
if (empty($player['password_hash'])) {
return false;
}
return password_verify($password, $player['password_hash']);
}
public function generateOtp(int $playerId): string
{
$otp = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$expiresAt = date('Y-m-d H:i:s', time() + 300); // 5 minutes
$this->db->update('players', [
'otp_code' => $otp,
'otp_expires_at' => $expiresAt,
], 'id = ?', [$playerId]);
return $otp;
}
public function verifyOtp(array $player, string $otp): bool
{
if (empty($player['otp_code']) || $player['otp_code'] !== $otp) {
return false;
}
$row = $this->db->selectOne(
"SELECT otp_expires_at FROM players WHERE id = ?",
[$player['id']]
);
if (!$row || !$row['otp_expires_at'] || $row['otp_expires_at'] < date('Y-m-d H:i:s')) {
return false;
}
$this->db->update('players', [
'otp_code' => null,
'otp_expires_at' => null,
], 'id = ?', [$player['id']]);
return true;
}
public function createToken(int $playerId, string $name = 'mobile', ?string $deviceInfo = null): array
{
$config = App::getInstance()->config('api.token', []);
$maxTokens = $config['max_per_player'] ?? 5;
$ttlHours = $config['player_ttl_hours'] ?? 2160; // 90 days
$activeCount = $this->db->selectOne(
"SELECT COUNT(*) as cnt FROM player_tokens WHERE player_id = ? AND is_revoked = 0 AND (expires_at IS NULL OR expires_at > NOW())",
[$playerId]
);
if (($activeCount['cnt'] ?? 0) >= $maxTokens) {
$this->db->query(
"UPDATE player_tokens SET is_revoked = 1 WHERE player_id = ? AND is_revoked = 0 ORDER BY last_used_at ASC LIMIT 1",
[$playerId]
);
}
$token = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', time() + ($ttlHours * 3600));
$this->db->insert('player_tokens', [
'player_id' => $playerId,
'token' => $token,
'name' => $name,
'device_info' => $deviceInfo,
'expires_at' => $expiresAt,
]);
$this->db->update('players', [
'last_login_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$playerId]);
return [
'token' => $token,
'expires_at' => $expiresAt,
];
}
public function revokeToken(string $token): void
{
$this->db->update('player_tokens', ['is_revoked' => 1], '`token` = ?', [$token]);
}
public function refreshToken(string $currentToken, int $playerId): array
{
$this->revokeToken($currentToken);
return $this->createToken($playerId);
}
}
<?php
declare(strict_types=1);
use App\Core\Registries\PermissionRegistry;
PermissionRegistry::register('player_auth', 'إدارة مصادقة اللاعبين', [
'player_auth.manage_tokens' => 'إدارة توكنات اللاعبين',
]);
<?php
declare(strict_types=1);
namespace App\Modules\PlaygroundAdmin\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\PlaygroundAdmin\Services\ClubDashboardService;
use App\Modules\PlaygroundAdmin\Services\DashboardExportService;
final class ClubSportsDashboardController extends Controller
{
private ClubDashboardService $dashboardService;
public function __construct(Request $request)
{
parent::__construct($request);
$this->dashboardService = new ClubDashboardService();
}
public function index(Request $request): Response
{
$this->authorize('playground.dashboard');
$filter = $request->get('filter', 'monthly');
$range = $this->dashboardService->getDateRange($filter, $request->get('from'), $request->get('to'));
$data = $this->dashboardService->getClubWideData($range['from'], $range['to']);
return $this->view('PlaygroundAdmin.Views.club_dashboard', [
'filter' => $filter,
'data' => $data,
]);
}
public function export(Request $request): Response
{
$this->authorize('playground.dashboard');
$filter = $request->get('filter', 'monthly');
$range = $this->dashboardService->getDateRange($filter, $request->get('from'), $request->get('to'));
$data = $this->dashboardService->getClubWideData($range['from'], $range['to']);
$exportService = new DashboardExportService();
return $exportService->exportClubWide($data, $range);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlaygroundAdmin\Controllers;
use App\Core\Controller;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Core\EventBus;
use App\Modules\FacilityGrids\Services\FacilityAttendanceService;
final class PlaygroundAttendanceController extends Controller
{
public function __construct(Request $request)
{
parent::__construct($request);
}
public function index(Request $request, string $id): Response
{
$this->authorize('playground.attendance');
$db = App::getInstance()->db();
$playground = $db->selectOne("SELECT * FROM playground_configurations WHERE id = ?", [(int) $id]);
if (!$playground) {
return $this->redirect('/playgrounds')->withError('الملعب غير موجود');
}
$date = $request->get('date', date('Y-m-d'));
$slots = $db->select(
"SELECT pss.*, c.name_ar as coach_name
FROM playground_schedule_slots pss
INNER JOIN playground_monthly_schedules pms ON pms.id = pss.schedule_id
LEFT JOIN coaches c ON c.id = pss.coach_id
WHERE pms.playground_id = ? AND pss.slot_date = ? AND pms.status = 'active'
ORDER BY pss.slot_hour, pss.row_number",
[(int) $id, $date]
);
$attendance = [];
if ($playground['grid_id']) {
$service = new FacilityAttendanceService();
$attendance = $service->getForGrid((int) $playground['grid_id'], $date);
}
return $this->view('PlaygroundAdmin.Views.attendance', [
'playground' => $playground,
'date' => $date,
'slots' => $slots,
'attendance' => $attendance,
]);
}
public function store(Request $request, string $id): Response
{
$this->authorize('playground.attendance');
$db = App::getInstance()->db();
$playground = $db->selectOne("SELECT * FROM playground_configurations WHERE id = ?", [(int) $id]);
if (!$playground || empty($playground['grid_id'])) {
return $this->redirect('/playgrounds/' . $id . '/attendance')->withError('لا يوجد شبكة مرتبطة بالملعب');
}
$data = $request->all();
$date = $data['attendance_date'] ?? date('Y-m-d');
$records = [];
foreach ($data['attendance'] ?? [] as $entry) {
$records[] = [
'entity_type' => $entry['entity_type'],
'entity_id' => (int) $entry['entity_id'],
'entity_name' => $entry['entity_name'] ?? null,
'status' => $entry['status'] ?? 'present',
'notes' => $entry['notes'] ?? null,
];
}
$employee = App::getInstance()->currentEmployee();
$service = new FacilityAttendanceService();
$count = $service->recordBatch(
(int) $playground['grid_id'],
$date,
null,
$records,
$employee ? (int) $employee->id : null
);
return $this->redirect('/playgrounds/' . $id . '/attendance?date=' . $date)
->withSuccess("تم تسجيل {$count} حضور");
}
public function remind(Request $request, string $id): Response
{
$this->authorize('playground.attendance');
$db = App::getInstance()->db();
$playground = $db->selectOne("SELECT * FROM playground_configurations WHERE id = ?", [(int) $id]);
$activeSched = $db->selectOne(
"SELECT id FROM playground_monthly_schedules WHERE playground_id = ? AND status = 'active' LIMIT 1",
[(int) $id]
);
if (!$activeSched) {
return $this->redirect('/playgrounds/' . $id . '/attendance')->withError('لا يوجد جدول نشط');
}
$players = $db->select(
"SELECT DISTINCT pss.coach_id, c.name_ar, c.phone
FROM playground_schedule_slots pss
INNER JOIN coaches c ON c.id = pss.coach_id
WHERE pss.schedule_id = ? AND pss.coach_id IS NOT NULL",
[(int) $activeSched['id']]
);
$count = 0;
foreach ($players as $coach) {
EventBus::dispatch('facility.payment_reminder', [
'entity_type' => 'coach',
'entity_id' => (int) $coach['coach_id'],
'name' => $coach['name_ar'],
'phone' => $coach['phone'],
]);
$count++;
}
return $this->redirect('/playgrounds/' . $id . '/attendance')
->withSuccess("تم إرسال {$count} تذكير");
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlaygroundAdmin\Controllers;
use App\Core\Controller;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Modules\PlaygroundAdmin\Models\PlaygroundConfiguration;
final class PlaygroundController extends Controller
{
public function __construct(Request $request)
{
parent::__construct($request);
}
public function index(Request $request): Response
{
$this->authorize('playground.view');
$filters = ['type' => $request->get('type'), 'is_active' => $request->get('is_active')];
$page = max(1, (int) ($request->get('page') ?? 1));
$result = PlaygroundConfiguration::search(array_filter($filters), 25, $page);
return $this->view('PlaygroundAdmin.Views.index', [
'playgrounds' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'types' => PlaygroundConfiguration::getTypes(),
]);
}
public function create(Request $request): Response
{
$this->authorize('playground.manage');
$db = App::getInstance()->db();
$facilities = $db->select("SELECT id, name_ar FROM facilities WHERE is_active = 1 AND is_archived = 0 ORDER BY name_ar");
return $this->view('PlaygroundAdmin.Views.create', [
'types' => PlaygroundConfiguration::getTypes(),
'facilities' => $facilities,
]);
}
public function store(Request $request): Response
{
$this->authorize('playground.manage');
$data = $request->all();
$session = App::getInstance()->session();
$errors = [];
if (empty($data['playground_name_ar'])) $errors[] = 'اسم الملعب مطلوب';
if (empty($data['playground_type'])) $errors[] = 'نوع الملعب مطلوب';
if (empty($data['facility_id'])) $errors[] = 'المرفق مطلوب';
if (!empty($errors)) {
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $data);
return $this->redirect('/playgrounds/create');
}
$employee = App::getInstance()->currentEmployee();
$hasRows = in_array($data['playground_type'], PlaygroundConfiguration::getRowTypes());
$db = App::getInstance()->db();
$db->insert('playground_configurations', [
'facility_id' => (int) $data['facility_id'],
'playground_name_ar' => $data['playground_name_ar'],
'playground_type' => $data['playground_type'],
'has_rows' => $hasRows ? 1 : 0,
'row_count' => $hasRows ? ((int) ($data['row_count'] ?? 0) ?: null) : null,
'row_label_ar' => $data['row_label_ar'] ?? 'حارة',
'operating_hours_json' => !empty($data['operating_hours']) ? json_encode($data['operating_hours']) : null,
'notes' => $data['notes'] ?? null,
'created_by' => $employee ? (int) $employee->id : null,
]);
$id = (int) $db->selectOne("SELECT LAST_INSERT_ID() as id")['id'];
return $this->redirect('/playgrounds/' . $id)->withSuccess('تم إنشاء الملعب بنجاح');
}
public function show(Request $request, string $id): Response
{
$this->authorize('playground.view');
$db = App::getInstance()->db();
$playground = $db->selectOne(
"SELECT pc.*, f.name_ar as facility_name
FROM playground_configurations pc
LEFT JOIN facilities f ON f.id = pc.facility_id
WHERE pc.id = ?",
[(int) $id]
);
if (!$playground) {
return $this->redirect('/playgrounds')->withError('الملعب غير موجود');
}
return $this->view('PlaygroundAdmin.Views.show', [
'playground' => $playground,
'types' => PlaygroundConfiguration::getTypes(),
]);
}
public function edit(Request $request, string $id): Response
{
$this->authorize('playground.manage');
$db = App::getInstance()->db();
$playground = $db->selectOne("SELECT * FROM playground_configurations WHERE id = ?", [(int) $id]);
if (!$playground) {
return $this->redirect('/playgrounds')->withError('الملعب غير موجود');
}
$facilities = $db->select("SELECT id, name_ar FROM facilities WHERE is_active = 1 AND is_archived = 0 ORDER BY name_ar");
return $this->view('PlaygroundAdmin.Views.edit', [
'playground' => $playground,
'types' => PlaygroundConfiguration::getTypes(),
'facilities' => $facilities,
]);
}
public function update(Request $request, string $id): Response
{
$this->authorize('playground.manage');
$data = $request->all();
$db = App::getInstance()->db();
$hasRows = in_array($data['playground_type'] ?? '', PlaygroundConfiguration::getRowTypes());
$db->update('playground_configurations', [
'playground_name_ar' => $data['playground_name_ar'] ?? '',
'playground_type' => $data['playground_type'] ?? '',
'facility_id' => (int) ($data['facility_id'] ?? 0),
'has_rows' => $hasRows ? 1 : 0,
'row_count' => $hasRows ? ((int) ($data['row_count'] ?? 0) ?: null) : null,
'row_label_ar' => $data['row_label_ar'] ?? 'حارة',
'notes' => $data['notes'] ?? null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $id]);
return $this->redirect('/playgrounds/' . $id)->withSuccess('تم تحديث الملعب بنجاح');
}
public function toggle(Request $request, string $id): Response
{
$this->authorize('playground.manage');
$db = App::getInstance()->db();
$playground = $db->selectOne("SELECT is_active FROM playground_configurations WHERE id = ?", [(int) $id]);
if (!$playground) {
return $this->redirect('/playgrounds')->withError('الملعب غير موجود');
}
$db->update('playground_configurations', [
'is_active' => $playground['is_active'] ? 0 : 1,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $id]);
return $this->redirect('/playgrounds/' . $id)->withSuccess('تم تحديث حالة الملعب');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlaygroundAdmin\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\PlaygroundAdmin\Services\ClubDashboardService;
use App\Modules\PlaygroundAdmin\Services\DashboardExportService;
final class PlaygroundDashboardController extends Controller
{
private ClubDashboardService $dashboardService;
public function __construct(Request $request)
{
parent::__construct($request);
$this->dashboardService = new ClubDashboardService();
}
public function dashboard(Request $request, string $id): Response
{
$this->authorize('playground.dashboard');
$filter = $request->get('filter', 'monthly');
$range = $this->dashboardService->getDateRange($filter, $request->get('from'), $request->get('to'));
$data = $this->dashboardService->getPlaygroundData((int) $id, $range['from'], $range['to']);
return $this->view('PlaygroundAdmin.Views.dashboard', [
'playground_id' => (int) $id,
'filter' => $filter,
'data' => $data,
]);
}
public function export(Request $request, string $id): Response
{
$this->authorize('playground.dashboard');
$filter = $request->get('filter', 'monthly');
$range = $this->dashboardService->getDateRange($filter, $request->get('from'), $request->get('to'));
$data = $this->dashboardService->getPlaygroundData((int) $id, $range['from'], $range['to']);
$exportService = new DashboardExportService();
return $exportService->exportPlayground($data, $range);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlaygroundAdmin\Controllers;
use App\Core\Controller;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Modules\PlaygroundAdmin\Services\PlaygroundMirrorService;
final class PlaygroundMirrorController extends Controller
{
private PlaygroundMirrorService $mirrorService;
public function __construct(Request $request)
{
parent::__construct($request);
$this->mirrorService = new PlaygroundMirrorService();
}
public function mirror(Request $request, string $id): Response
{
$this->authorize('playground.view');
$date = date('Y-m-d');
return $this->renderMirror((int) $id, $date);
}
public function mirrorDate(Request $request, string $id, string $date): Response
{
$this->authorize('playground.view');
return $this->renderMirror((int) $id, $date);
}
public function mirrorState(Request $request, string $id): Response
{
$this->authorize('playground.view');
$date = $request->get('date', date('Y-m-d'));
$state = $this->mirrorService->getMirrorState((int) $id, $date);
return $this->json($state);
}
private function renderMirror(int $playgroundId, string $date): Response
{
$db = App::getInstance()->db();
$playground = $db->selectOne("SELECT * FROM playground_configurations WHERE id = ?", [$playgroundId]);
if (!$playground) {
return $this->redirect('/playgrounds')->withError('الملعب غير موجود');
}
$state = $this->mirrorService->getMirrorState($playgroundId, $date);
return $this->view('PlaygroundAdmin.Views.mirror', [
'playground' => $playground,
'date' => $date,
'state' => $state,
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlaygroundAdmin\Controllers;
use App\Core\Controller;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Modules\PlaygroundAdmin\Models\PlaygroundMonthlySchedule;
use App\Modules\PlaygroundAdmin\Models\PlaygroundScheduleSlot;
final class PlaygroundScheduleController extends Controller
{
public function __construct(Request $request)
{
parent::__construct($request);
}
public function schedules(Request $request, string $id): Response
{
$this->authorize('playground.schedule');
$schedules = PlaygroundMonthlySchedule::getForPlayground((int) $id);
$db = App::getInstance()->db();
$playground = $db->selectOne("SELECT * FROM playground_configurations WHERE id = ?", [(int) $id]);
return $this->view('PlaygroundAdmin.Views.schedules', [
'playground' => $playground,
'schedules' => $schedules,
]);
}
public function createSchedule(Request $request, string $id): Response
{
$this->authorize('playground.schedule');
$db = App::getInstance()->db();
$playground = $db->selectOne("SELECT * FROM playground_configurations WHERE id = ?", [(int) $id]);
return $this->view('PlaygroundAdmin.Views.schedule_edit', [
'playground' => $playground,
'schedule' => null,
'mode' => 'create',
]);
}
public function storeSchedule(Request $request, string $id): Response
{
$this->authorize('playground.schedule');
$data = $request->all();
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$workingDays = $data['working_days'] ?? [];
$db->insert('playground_monthly_schedules', [
'playground_id' => (int) $id,
'plan_month' => $data['plan_month'],
'plan_name_ar' => $data['plan_name_ar'],
'status' => 'draft',
'working_days_json' => json_encode(array_map('intval', $workingDays)),
'day_start_time' => $data['day_start_time'] ?? '07:00:00',
'day_end_time' => $data['day_end_time'] ?? '22:00:00',
'notes' => $data['notes'] ?? null,
'created_by' => $employee ? (int) $employee->id : null,
]);
$schedId = (int) $db->selectOne("SELECT LAST_INSERT_ID() as id")['id'];
return $this->redirect('/playgrounds/' . $id . '/schedules/' . $schedId)->withSuccess('تم إنشاء الجدول بنجاح');
}
public function showSchedule(Request $request, string $id, string $schedId): Response
{
$this->authorize('playground.schedule');
$db = App::getInstance()->db();
$playground = $db->selectOne("SELECT * FROM playground_configurations WHERE id = ?", [(int) $id]);
$schedule = $db->selectOne("SELECT * FROM playground_monthly_schedules WHERE id = ? AND playground_id = ?", [(int) $schedId, (int) $id]);
if (!$schedule) {
return $this->redirect('/playgrounds/' . $id . '/schedules')->withError('الجدول غير موجود');
}
$date = $request->get('date', $schedule['plan_month']);
$slots = PlaygroundScheduleSlot::getForDate((int) $schedId, $date);
return $this->view('PlaygroundAdmin.Views.schedule_view', [
'playground' => $playground,
'schedule' => $schedule,
'date' => $date,
'slots' => $slots,
]);
}
public function editSchedule(Request $request, string $id, string $schedId): Response
{
$this->authorize('playground.schedule');
$db = App::getInstance()->db();
$playground = $db->selectOne("SELECT * FROM playground_configurations WHERE id = ?", [(int) $id]);
$schedule = $db->selectOne("SELECT * FROM playground_monthly_schedules WHERE id = ? AND playground_id = ?", [(int) $schedId, (int) $id]);
return $this->view('PlaygroundAdmin.Views.schedule_edit', [
'playground' => $playground,
'schedule' => $schedule,
'mode' => 'edit',
]);
}
public function updateSchedule(Request $request, string $id, string $schedId): Response
{
$this->authorize('playground.schedule');
$data = $request->all();
$db = App::getInstance()->db();
$workingDays = $data['working_days'] ?? [];
$updateData = [
'plan_name_ar' => $data['plan_name_ar'],
'working_days_json' => json_encode(array_map('intval', $workingDays)),
'day_start_time' => $data['day_start_time'] ?? '07:00:00',
'day_end_time' => $data['day_end_time'] ?? '22:00:00',
'notes' => $data['notes'] ?? null,
'updated_at' => date('Y-m-d H:i:s'),
];
if (($data['status'] ?? '') === 'active') {
$updateData['status'] = 'active';
$updateData['activated_at'] = date('Y-m-d H:i:s');
}
$db->update('playground_monthly_schedules', $updateData, 'id = ? AND playground_id = ?', [(int) $schedId, (int) $id]);
return $this->redirect('/playgrounds/' . $id . '/schedules/' . $schedId)->withSuccess('تم تحديث الجدول');
}
public function slotEditor(Request $request, string $id, string $schedId, string $date, string $hour): Response
{
$this->authorize('playground.schedule');
$db = App::getInstance()->db();
$playground = $db->selectOne("SELECT * FROM playground_configurations WHERE id = ?", [(int) $id]);
$slots = PlaygroundScheduleSlot::getForDateAndHour((int) $schedId, $date, $hour . ':00');
$coaches = $db->select("SELECT id, name_ar FROM coaches WHERE is_archived = 0 ORDER BY name_ar");
$academies = $db->select("SELECT id, name_ar FROM academies WHERE is_active = 1 ORDER BY name_ar");
return $this->view('PlaygroundAdmin.Views.slot_editor', [
'playground' => $playground,
'schedule_id' => (int) $schedId,
'date' => $date,
'hour' => $hour,
'slots' => $slots,
'coaches' => $coaches,
'academies' => $academies,
]);
}
public function saveSlot(Request $request, string $id, string $schedId, string $date, string $hour): Response
{
$this->authorize('playground.schedule');
$db = App::getInstance()->db();
$data = $request->all();
$db->query(
"DELETE FROM playground_schedule_slots WHERE schedule_id = ? AND slot_date = ? AND slot_hour = ?",
[(int) $schedId, $date, $hour . ':00']
);
foreach ($data['slots'] ?? [] as $slotData) {
if (empty($slotData['coach_id']) && empty($slotData['academy_id'])) {
continue;
}
$db->insert('playground_schedule_slots', [
'schedule_id' => (int) $schedId,
'slot_date' => $date,
'slot_hour' => $hour . ':00',
'row_number' => !empty($slotData['row_number']) ? (int) $slotData['row_number'] : null,
'coach_id' => !empty($slotData['coach_id']) ? (int) $slotData['coach_id'] : null,
'academy_id' => !empty($slotData['academy_id']) ? (int) $slotData['academy_id'] : null,
'group_type' => $slotData['group_type'] ?? null,
'max_trainees' => !empty($slotData['max_trainees']) ? (int) $slotData['max_trainees'] : null,
'age_group_ar' => $slotData['age_group_ar'] ?? null,
'gender' => $slotData['gender'] ?? null,
'notes' => $slotData['notes'] ?? null,
]);
}
return $this->redirect('/playgrounds/' . $id . '/schedules/' . $schedId . '?date=' . $date)
->withSuccess('تم حفظ الخانة بنجاح');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlaygroundAdmin\Models;
use App\Core\Model;
final class PlaygroundConfiguration extends Model
{
protected string $table = 'playground_configurations';
protected bool $timestamps = true;
protected array $fillable = [
'facility_id', 'playground_name_ar', 'playground_type', 'has_rows',
'row_count', 'row_label_ar', 'grid_id', 'operating_hours_json',
'notes', 'is_active', 'created_by',
];
public static function getTypes(): array
{
return [
'football' => 'كرة قدم',
'tennis' => 'تنس',
'bowling' => 'بولينج',
'playstation' => 'بلاي ستيشن',
'swimming' => 'سباحة',
'basketball' => 'كرة سلة',
'paddle' => 'بادل',
'gym' => 'جيم',
'ping_pong' => 'تنس طاولة',
];
}
public static function getRowTypes(): array
{
return ['bowling', 'swimming'];
}
public static function search(array $filters = [], int $perPage = 25, int $page = 1): array
{
$db = \App\Core\App::getInstance()->db();
$where = ['1=1'];
$params = [];
if (!empty($filters['type'])) {
$where[] = 'pc.playground_type = ?';
$params[] = $filters['type'];
}
if (isset($filters['is_active'])) {
$where[] = 'pc.is_active = ?';
$params[] = (int) $filters['is_active'];
}
$whereClause = implode(' AND ', $where);
$total = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM playground_configurations pc WHERE {$whereClause}",
$params
)['cnt'] ?? 0);
$lastPage = max(1, (int) ceil($total / $perPage));
$page = min($page, $lastPage);
$offset = ($page - 1) * $perPage;
$data = $db->select(
"SELECT pc.*, f.name_ar as facility_name, f.facility_type
FROM playground_configurations pc
LEFT JOIN facilities f ON f.id = pc.facility_id
WHERE {$whereClause}
ORDER BY pc.playground_name_ar
LIMIT ? OFFSET ?",
array_merge($params, [$perPage, $offset])
);
return [
'data' => $data,
'pagination' => ['total' => $total, 'per_page' => $perPage, 'current_page' => $page, 'last_page' => $lastPage],
];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlaygroundAdmin\Models;
use App\Core\Model;
final class PlaygroundMonthlySchedule extends Model
{
protected string $table = 'playground_monthly_schedules';
protected bool $timestamps = true;
protected array $fillable = [
'playground_id', 'plan_month', 'plan_name_ar', 'status',
'working_days_json', 'day_start_time', 'day_end_time',
'notes', 'created_by', 'activated_at',
];
public static function getForPlayground(int $playgroundId): array
{
$db = \App\Core\App::getInstance()->db();
return $db->select(
"SELECT * FROM playground_monthly_schedules WHERE playground_id = ? ORDER BY plan_month DESC",
[$playgroundId]
);
}
public function getWorkingHours(): array
{
$start = strtotime($this->day_start_time ?? '07:00');
$end = strtotime($this->day_end_time ?? '22:00');
$hours = [];
while ($start < $end) {
$hours[] = date('H:i', $start);
$start += 3600;
}
return $hours;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlaygroundAdmin\Models;
use App\Core\Model;
final class PlaygroundScheduleSlot extends Model
{
protected string $table = 'playground_schedule_slots';
protected bool $timestamps = true;
protected array $fillable = [
'schedule_id', 'slot_date', 'slot_hour', 'row_number',
'coach_id', 'academy_id', 'group_type', 'max_trainees',
'age_group_ar', 'gender', 'notes',
];
public static function getForDateAndHour(int $scheduleId, string $date, string $hour): array
{
$db = \App\Core\App::getInstance()->db();
return $db->select(
"SELECT pss.*, c.name_ar as coach_name, a.name_ar as academy_name
FROM playground_schedule_slots pss
LEFT JOIN coaches c ON c.id = pss.coach_id
LEFT JOIN academies a ON a.id = pss.academy_id
WHERE pss.schedule_id = ? AND pss.slot_date = ? AND pss.slot_hour = ?
ORDER BY pss.row_number",
[$scheduleId, $date, $hour]
);
}
public static function getForDate(int $scheduleId, string $date): array
{
$db = \App\Core\App::getInstance()->db();
return $db->select(
"SELECT pss.*, c.name_ar as coach_name, a.name_ar as academy_name
FROM playground_schedule_slots pss
LEFT JOIN coaches c ON c.id = pss.coach_id
LEFT JOIN academies a ON a.id = pss.academy_id
WHERE pss.schedule_id = ? AND pss.slot_date = ?
ORDER BY pss.slot_hour, pss.row_number",
[$scheduleId, $date]
);
}
}
<?php
declare(strict_types=1);
return [
// Playground CRUD
['GET', '/playgrounds', 'PlaygroundAdmin\Controllers\PlaygroundController@index', ['auth'], 'playground.view'],
['GET', '/playgrounds/create', 'PlaygroundAdmin\Controllers\PlaygroundController@create', ['auth'], 'playground.manage'],
['POST', '/playgrounds', 'PlaygroundAdmin\Controllers\PlaygroundController@store', ['auth', 'csrf'], 'playground.manage'],
['GET', '/playgrounds/{id:\d+}', 'PlaygroundAdmin\Controllers\PlaygroundController@show', ['auth'], 'playground.view'],
['GET', '/playgrounds/{id:\d+}/edit', 'PlaygroundAdmin\Controllers\PlaygroundController@edit', ['auth'], 'playground.manage'],
['POST', '/playgrounds/{id:\d+}', 'PlaygroundAdmin\Controllers\PlaygroundController@update', ['auth', 'csrf'], 'playground.manage'],
['POST', '/playgrounds/{id:\d+}/toggle', 'PlaygroundAdmin\Controllers\PlaygroundController@toggle', ['auth', 'csrf'], 'playground.manage'],
// Mirror View
['GET', '/playgrounds/{id:\d+}/mirror', 'PlaygroundAdmin\Controllers\PlaygroundMirrorController@mirror', ['auth'], 'playground.view'],
['GET', '/playgrounds/{id:\d+}/mirror/{date}', 'PlaygroundAdmin\Controllers\PlaygroundMirrorController@mirrorDate', ['auth'], 'playground.view'],
['GET', '/playgrounds/{id:\d+}/mirror/state', 'PlaygroundAdmin\Controllers\PlaygroundMirrorController@mirrorState', ['auth'], 'playground.view'],
// Monthly Schedules
['GET', '/playgrounds/{id:\d+}/schedules', 'PlaygroundAdmin\Controllers\PlaygroundScheduleController@schedules', ['auth'], 'playground.schedule'],
['GET', '/playgrounds/{id:\d+}/schedules/create', 'PlaygroundAdmin\Controllers\PlaygroundScheduleController@createSchedule', ['auth'], 'playground.schedule'],
['POST', '/playgrounds/{id:\d+}/schedules', 'PlaygroundAdmin\Controllers\PlaygroundScheduleController@storeSchedule', ['auth', 'csrf'], 'playground.schedule'],
['GET', '/playgrounds/{id:\d+}/schedules/{schedId:\d+}', 'PlaygroundAdmin\Controllers\PlaygroundScheduleController@showSchedule', ['auth'], 'playground.schedule'],
['GET', '/playgrounds/{id:\d+}/schedules/{schedId:\d+}/edit', 'PlaygroundAdmin\Controllers\PlaygroundScheduleController@editSchedule', ['auth'], 'playground.schedule'],
['POST', '/playgrounds/{id:\d+}/schedules/{schedId:\d+}', 'PlaygroundAdmin\Controllers\PlaygroundScheduleController@updateSchedule', ['auth', 'csrf'], 'playground.schedule'],
['GET', '/playgrounds/{id:\d+}/schedules/{schedId:\d+}/slots/{date}/{hour}', 'PlaygroundAdmin\Controllers\PlaygroundScheduleController@slotEditor', ['auth'], 'playground.schedule'],
['POST', '/playgrounds/{id:\d+}/schedules/{schedId:\d+}/slots/{date}/{hour}', 'PlaygroundAdmin\Controllers\PlaygroundScheduleController@saveSlot', ['auth', 'csrf'], 'playground.schedule'],
// Attendance
['GET', '/playgrounds/{id:\d+}/attendance', 'PlaygroundAdmin\Controllers\PlaygroundAttendanceController@index', ['auth'], 'playground.attendance'],
['POST', '/playgrounds/{id:\d+}/attendance', 'PlaygroundAdmin\Controllers\PlaygroundAttendanceController@store', ['auth', 'csrf'], 'playground.attendance'],
['POST', '/playgrounds/{id:\d+}/attendance/remind', 'PlaygroundAdmin\Controllers\PlaygroundAttendanceController@remind', ['auth', 'csrf'], 'playground.attendance'],
// Per-Playground Dashboard
['GET', '/playgrounds/{id:\d+}/dashboard', 'PlaygroundAdmin\Controllers\PlaygroundDashboardController@dashboard', ['auth'], 'playground.dashboard'],
['GET', '/playgrounds/{id:\d+}/dashboard/export', 'PlaygroundAdmin\Controllers\PlaygroundDashboardController@export', ['auth'], 'playground.dashboard'],
// Club-wide Sports Dashboard
['GET', '/sports-dashboard', 'PlaygroundAdmin\Controllers\ClubSportsDashboardController@index', ['auth'], 'playground.dashboard'],
['GET', '/sports-dashboard/export', 'PlaygroundAdmin\Controllers\ClubSportsDashboardController@export', ['auth'], 'playground.dashboard'],
];
<?php
declare(strict_types=1);
namespace App\Modules\PlaygroundAdmin\Services;
use App\Core\App;
final class ClubDashboardService
{
private \App\Core\Database $db;
public function __construct()
{
$this->db = App::getInstance()->db();
}
public function getClubWideData(string $dateFrom, string $dateTo): array
{
$totalRevenue = $this->db->selectOne(
"SELECT COALESCE(SUM(total_amount), 0) as total FROM reservations
WHERE reservation_date BETWEEN ? AND ? AND status NOT IN ('cancelled', 'no_show')",
[$dateFrom, $dateTo]
);
$freeTimeRevenue = $this->db->selectOne(
"SELECT COALESCE(SUM(amount_paid), 0) as total, COUNT(*) as entries FROM free_time_entries
WHERE entry_date BETWEEN ? AND ? AND status = 'active'",
[$dateFrom, $dateTo]
);
$bookingsByFacility = $this->db->select(
"SELECT f.name_ar as facility_name, f.facility_type, COUNT(r.id) as bookings,
COALESCE(SUM(r.total_amount), 0) as revenue
FROM reservations r
INNER JOIN facilities f ON f.id = r.facility_id
WHERE r.reservation_date BETWEEN ? AND ? AND r.status NOT IN ('cancelled', 'no_show')
GROUP BY f.id, f.name_ar, f.facility_type
ORDER BY revenue DESC",
[$dateFrom, $dateTo]
);
$uniquePlayers = $this->db->selectOne(
"SELECT COUNT(DISTINCT booker_id) as cnt FROM reservations
WHERE reservation_date BETWEEN ? AND ? AND status NOT IN ('cancelled', 'no_show')",
[$dateFrom, $dateTo]
);
$activeCoaches = $this->db->selectOne(
"SELECT COUNT(DISTINCT coach_id) as cnt FROM training_sessions
WHERE session_date BETWEEN ? AND ? AND status != 'cancelled'",
[$dateFrom, $dateTo]
);
$attendanceRate = $this->db->selectOne(
"SELECT ROUND(AVG(CASE WHEN status = 'present' THEN 100.0 ELSE 0.0 END), 1) as rate
FROM facility_attendance WHERE attendance_date BETWEEN ? AND ?",
[$dateFrom, $dateTo]
);
$playgroundStats = $this->db->select(
"SELECT pc.playground_name_ar, pc.playground_type,
COUNT(DISTINCT r.id) as bookings,
COALESCE(SUM(r.total_amount), 0) as revenue
FROM playground_configurations pc
INNER JOIN facilities f ON f.id = pc.facility_id
LEFT JOIN reservations r ON r.facility_id = f.id
AND r.reservation_date BETWEEN ? AND ?
AND r.status NOT IN ('cancelled', 'no_show')
WHERE pc.is_active = 1
GROUP BY pc.id, pc.playground_name_ar, pc.playground_type
ORDER BY revenue DESC",
[$dateFrom, $dateTo]
);
$monthlyTrend = $this->db->select(
"SELECT DATE_FORMAT(reservation_date, '%Y-%m') as month,
COUNT(*) as bookings, COALESCE(SUM(total_amount), 0) as revenue
FROM reservations
WHERE reservation_date BETWEEN ? AND ? AND status NOT IN ('cancelled', 'no_show')
GROUP BY month ORDER BY month",
[$dateFrom, $dateTo]
);
return [
'total_revenue' => (float) ($totalRevenue['total'] ?? 0) + (float) ($freeTimeRevenue['total'] ?? 0),
'booking_revenue' => (float) ($totalRevenue['total'] ?? 0),
'free_time_revenue' => (float) ($freeTimeRevenue['total'] ?? 0),
'free_time_entries' => (int) ($freeTimeRevenue['entries'] ?? 0),
'unique_players' => (int) ($uniquePlayers['cnt'] ?? 0),
'active_coaches' => (int) ($activeCoaches['cnt'] ?? 0),
'avg_attendance_rate' => (float) ($attendanceRate['rate'] ?? 0),
'bookings_by_facility' => $bookingsByFacility,
'playground_stats' => $playgroundStats,
'monthly_trend' => $monthlyTrend,
'period' => ['from' => $dateFrom, 'to' => $dateTo],
];
}
public function getPlaygroundData(int $playgroundId, string $dateFrom, string $dateTo): array
{
$playground = $this->db->selectOne(
"SELECT pc.*, f.name_ar as facility_name FROM playground_configurations pc
LEFT JOIN facilities f ON f.id = pc.facility_id WHERE pc.id = ?",
[$playgroundId]
);
if (!$playground) return [];
$facilityId = (int) $playground['facility_id'];
$revenue = $this->db->selectOne(
"SELECT COALESCE(SUM(total_amount), 0) as total, COUNT(*) as bookings
FROM reservations WHERE facility_id = ? AND reservation_date BETWEEN ? AND ?
AND status NOT IN ('cancelled', 'no_show')",
[$facilityId, $dateFrom, $dateTo]
);
$uniqueUsers = $this->db->selectOne(
"SELECT COUNT(DISTINCT booker_id) as cnt FROM reservations
WHERE facility_id = ? AND reservation_date BETWEEN ? AND ?
AND status NOT IN ('cancelled', 'no_show')",
[$facilityId, $dateFrom, $dateTo]
);
$attendance = [];
if ($playground['grid_id']) {
$attendance = $this->db->selectOne(
"SELECT COUNT(*) as total,
SUM(CASE WHEN status = 'present' THEN 1 ELSE 0 END) as present_count,
ROUND(AVG(CASE WHEN status = 'present' THEN 100.0 ELSE 0.0 END), 1) as rate
FROM facility_attendance WHERE grid_id = ? AND attendance_date BETWEEN ? AND ?",
[(int) $playground['grid_id'], $dateFrom, $dateTo]
);
}
return [
'playground' => $playground,
'total_revenue' => (float) ($revenue['total'] ?? 0),
'total_bookings' => (int) ($revenue['bookings'] ?? 0),
'unique_users' => (int) ($uniqueUsers['cnt'] ?? 0),
'attendance' => $attendance ?: ['total' => 0, 'present_count' => 0, 'rate' => 0],
'period' => ['from' => $dateFrom, 'to' => $dateTo],
];
}
public function getDateRange(string $filter, ?string $from = null, ?string $to = null): array
{
$today = date('Y-m-d');
return match ($filter) {
'daily' => ['from' => $today, 'to' => $today],
'weekly' => ['from' => date('Y-m-d', strtotime('monday this week')), 'to' => date('Y-m-d', strtotime('sunday this week'))],
'monthly' => ['from' => date('Y-m-01'), 'to' => date('Y-m-t')],
'yearly' => ['from' => date('Y-01-01'), 'to' => date('Y-12-31')],
'3years' => ['from' => date('Y-m-d', strtotime('-3 years')), 'to' => $today],
'5years' => ['from' => date('Y-m-d', strtotime('-5 years')), 'to' => $today],
'custom' => ['from' => $from ?? $today, 'to' => $to ?? $today],
default => ['from' => date('Y-m-01'), 'to' => date('Y-m-t')],
};
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlaygroundAdmin\Services;
use App\Core\Response;
final class DashboardExportService
{
public function exportClubWide(array $data, array $range): Response
{
$html = $this->buildHeader('التقرير الرياضي الشامل للنادي', $range);
$html .= '<div style="text-align:center;margin:20px 0;">';
$html .= $this->stat('إجمالي الإيرادات', number_format($data['total_revenue'], 2) . ' ج.م');
$html .= $this->stat('إيرادات الحجوزات', number_format($data['booking_revenue'], 2) . ' ج.م');
$html .= $this->stat('إيرادات الأوقات الحرة', number_format($data['free_time_revenue'], 2) . ' ج.م');
$html .= $this->stat('اللاعبين الفريدين', (string) $data['unique_players']);
$html .= $this->stat('المدربين النشطين', (string) $data['active_coaches']);
$html .= $this->stat('نسبة الحضور', $data['avg_attendance_rate'] . '%');
$html .= '</div>';
if (!empty($data['bookings_by_facility'])) {
$html .= '<h3>الإيرادات حسب المرفق</h3>';
$html .= '<table><tr><th>المرفق</th><th>النوع</th><th>الحجوزات</th><th>الإيرادات</th></tr>';
foreach ($data['bookings_by_facility'] as $row) {
$html .= '<tr><td>' . e($row['facility_name']) . '</td><td>' . e($row['facility_type']) . '</td>';
$html .= '<td>' . $row['bookings'] . '</td><td>' . number_format((float) $row['revenue'], 2) . ' ج.م</td></tr>';
}
$html .= '</table>';
}
if (!empty($data['playground_stats'])) {
$html .= '<h3>إحصائيات الملاعب</h3>';
$html .= '<table><tr><th>الملعب</th><th>النوع</th><th>الحجوزات</th><th>الإيرادات</th></tr>';
foreach ($data['playground_stats'] as $row) {
$html .= '<tr><td>' . e($row['playground_name_ar']) . '</td><td>' . e($row['playground_type']) . '</td>';
$html .= '<td>' . $row['bookings'] . '</td><td>' . number_format((float) $row['revenue'], 2) . ' ج.م</td></tr>';
}
$html .= '</table>';
}
if (!empty($data['monthly_trend'])) {
$html .= '<h3>الاتجاه الشهري</h3>';
$html .= '<table><tr><th>الشهر</th><th>الحجوزات</th><th>الإيرادات</th></tr>';
foreach ($data['monthly_trend'] as $row) {
$html .= '<tr><td>' . $row['month'] . '</td><td>' . $row['bookings'] . '</td>';
$html .= '<td>' . number_format((float) $row['revenue'], 2) . ' ج.م</td></tr>';
}
$html .= '</table>';
}
$html .= $this->buildFooter();
$response = new Response();
return $response->html($html, 200, [
'Content-Type' => 'text/html; charset=utf-8',
'Content-Disposition' => 'attachment; filename="club_sports_report_' . date('Y-m-d') . '.html"',
]);
}
public function exportPlayground(array $data, array $range): Response
{
$name = $data['playground']['playground_name_ar'] ?? 'ملعب';
$html = $this->buildHeader('تقرير ' . e($name), $range);
$html .= '<div style="text-align:center;margin:20px 0;">';
$html .= $this->stat('إجمالي الإيرادات', number_format($data['total_revenue'], 2) . ' ج.م');
$html .= $this->stat('عدد الحجوزات', (string) $data['total_bookings']);
$html .= $this->stat('المستخدمين الفريدين', (string) $data['unique_users']);
$html .= $this->stat('نسبة الحضور', ($data['attendance']['rate'] ?? 0) . '%');
$html .= '</div>';
$html .= $this->buildFooter();
$response = new Response();
return $response->html($html, 200, [
'Content-Type' => 'text/html; charset=utf-8',
'Content-Disposition' => 'attachment; filename="playground_report_' . date('Y-m-d') . '.html"',
]);
}
private function buildHeader(string $title, array $range): string
{
return '<!DOCTYPE html><html dir="rtl" lang="ar"><head><meta charset="utf-8"><title>' . e($title) . '</title>'
. '<style>body{font-family:Cairo,sans-serif;padding:20px;direction:rtl;}'
. 'table{width:100%;border-collapse:collapse;margin:15px 0;}'
. 'th,td{border:1px solid #ddd;padding:8px;text-align:right;}'
. 'th{background:#f4f4f4;}h1,h3{text-align:center;}'
. '.stat{display:inline-block;margin:10px;padding:15px;border:1px solid #ddd;border-radius:5px;min-width:130px;text-align:center;}</style></head><body>'
. '<h1>' . e($title) . '</h1>'
. '<p style="text-align:center;">الفترة: من ' . $range['from'] . ' إلى ' . $range['to'] . '</p>';
}
private function buildFooter(): string
{
return '<p style="text-align:center;color:#888;margin-top:30px;">تم إنشاء التقرير: ' . date('Y-m-d H:i') . '</p></body></html>';
}
private function stat(string $label, string $value): string
{
return '<div class="stat"><strong>' . e($label) . '</strong><br>' . $value . '</div>';
}
}
<?php
declare(strict_types=1);
namespace App\Modules\PlaygroundAdmin\Services;
use App\Core\App;
final class PlaygroundMirrorService
{
private \App\Core\Database $db;
public function __construct()
{
$this->db = App::getInstance()->db();
}
public function getMirrorState(int $playgroundId, string $date): array
{
$playground = $this->db->selectOne("SELECT * FROM playground_configurations WHERE id = ?", [$playgroundId]);
if (!$playground) {
return [];
}
$facilityId = (int) $playground['facility_id'];
$hours = $this->getOperatingHours($playground);
$reservations = $this->db->select(
"SELECT r.id, r.start_time, r.end_time, r.booker_type, r.booker_id, r.status, r.total_amount,
CASE WHEN r.booker_type = 'player' THEN p.name_ar
WHEN r.booker_type = 'member' THEN m.name_ar
ELSE 'ضيف' END as booker_name
FROM reservations r
LEFT JOIN players p ON r.booker_type = 'player' AND p.id = r.booker_id
LEFT JOIN members m ON r.booker_type = 'member' AND m.id = r.booker_id
WHERE r.facility_id = ? AND r.reservation_date = ? AND r.status NOT IN ('cancelled', 'no_show')
ORDER BY r.start_time",
[$facilityId, $date]
);
$privateMatches = $this->db->select(
"SELECT id, start_time, end_time, team_a_name, team_b_name, match_type, total_cost, status
FROM private_match_bookings
WHERE facility_id = ? AND match_date = ? AND status NOT IN ('cancelled')
ORDER BY start_time",
[$facilityId, $date]
);
$scheduledSlots = [];
$activeSched = $this->db->selectOne(
"SELECT id FROM playground_monthly_schedules WHERE playground_id = ? AND status = 'active' LIMIT 1",
[$playgroundId]
);
if ($activeSched) {
$scheduledSlots = $this->db->select(
"SELECT pss.*, c.name_ar as coach_name, a.name_ar as academy_name
FROM playground_schedule_slots pss
LEFT JOIN coaches c ON c.id = pss.coach_id
LEFT JOIN academies a ON a.id = pss.academy_id
WHERE pss.schedule_id = ? AND pss.slot_date = ?
ORDER BY pss.slot_hour, pss.row_number",
[(int) $activeSched['id'], $date]
);
}
$mirrorGrid = [];
foreach ($hours as $hour) {
$hourStr = $hour . ':00';
$nextHour = date('H:i:s', strtotime($hourStr) + 3600);
$hourData = [
'hour' => $hour,
'reservations' => [],
'private_matches' => [],
'training_slots' => [],
'status' => 'free',
];
foreach ($reservations as $res) {
if ($res['start_time'] < $nextHour && $res['end_time'] > $hourStr) {
$hourData['reservations'][] = $res;
$hourData['status'] = 'booked';
}
}
foreach ($privateMatches as $pm) {
if ($pm['start_time'] < $nextHour && $pm['end_time'] > $hourStr) {
$hourData['private_matches'][] = $pm;
$hourData['status'] = 'booked';
}
}
foreach ($scheduledSlots as $slot) {
if ($slot['slot_hour'] === $hourStr) {
$hourData['training_slots'][] = $slot;
$hourData['status'] = 'training';
}
}
$mirrorGrid[] = $hourData;
}
return [
'playground' => $playground,
'date' => $date,
'hours' => $mirrorGrid,
'has_rows' => (bool) $playground['has_rows'],
'row_count' => (int) ($playground['row_count'] ?? 0),
];
}
private function getOperatingHours(array $playground): array
{
$opHours = json_decode($playground['operating_hours_json'] ?? '{}', true);
$startHour = (int) ($opHours['start'] ?? 7);
$endHour = (int) ($opHours['end'] ?? 22);
$hours = [];
for ($h = $startHour; $h < $endHour; $h++) {
$hours[] = str_pad((string) $h, 2, '0', STR_PAD_LEFT) . ':00';
}
return $hours;
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الحضور - <?= e($playground['playground_name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<form method="post" action="/playgrounds/<?= (int) $playground['id'] ?>/attendance/remind" class="d-inline">
<?= csrf_field() ?>
<button type="submit" class="btn btn-outline-warning" onclick="return confirm('إرسال تذكيرات؟')">إرسال تذكيرات</button>
</form>
<a href="/playgrounds/<?= (int) $playground['id'] ?>" class="btn btn-outline-secondary">رجوع</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card mb-3">
<div class="card-body">
<form method="get" class="row g-3 align-items-end">
<div class="col-auto">
<label class="form-label">التاريخ</label>
<input type="date" name="date" class="form-control" value="<?= e($date) ?>">
</div>
<div class="col-auto d-flex align-items-end">
<button type="submit" class="btn btn-primary">عرض</button>
</div>
</form>
</div>
</div>
<?php if (empty($slots) && empty($attendance)): ?>
<div class="card" style="padding:40px;text-align:center;">
<p style="color:#6B7280;">لا توجد خانات تدريب لهذا اليوم أو لا يوجد شبكة مرتبطة بالملعب</p>
</div>
<?php else: ?>
<form method="post" action="/playgrounds/<?= (int) $playground['id'] ?>/attendance">
<?= csrf_field() ?>
<input type="hidden" name="attendance_date" value="<?= e($date) ?>">
<?php if (!empty($slots)): ?>
<div class="card mb-3">
<div class="card-header">المدربين المجدولين</div>
<div class="card-body">
<table class="table table-bordered">
<thead><tr><th>المدرب</th><th>الساعة</th><th>الحالة</th><th>ملاحظات</th></tr></thead>
<tbody>
<?php
$coachesSeen = [];
$idx = 0;
foreach ($slots as $slot):
if (empty($slot['coach_id']) || isset($coachesSeen[$slot['coach_id']])) continue;
$coachesSeen[$slot['coach_id']] = true;
?>
<tr>
<td><?= e($slot['coach_name'] ?? '-') ?></td>
<td><?= e($slot['slot_hour']) ?></td>
<td>
<input type="hidden" name="attendance[<?= $idx ?>][entity_type]" value="coach">
<input type="hidden" name="attendance[<?= $idx ?>][entity_id]" value="<?= (int) $slot['coach_id'] ?>">
<input type="hidden" name="attendance[<?= $idx ?>][entity_name]" value="<?= e($slot['coach_name'] ?? '') ?>">
<select name="attendance[<?= $idx ?>][status]" class="form-select form-select-sm">
<option value="present">حاضر</option>
<option value="absent">غائب</option>
<option value="late">متأخر</option>
<option value="excused">بعذر</option>
</select>
</td>
<td><input type="text" name="attendance[<?= $idx ?>][notes]" class="form-control form-control-sm"></td>
</tr>
<?php $idx++; endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php if (!empty($attendance)): ?>
<div class="card mb-3">
<div class="card-header">سجل الحضور المسجل</div>
<div class="card-body">
<table class="table table-bordered table-sm">
<thead><tr><th>الاسم</th><th>النوع</th><th>الحالة</th><th>الوقت</th></tr></thead>
<tbody>
<?php foreach ($attendance as $rec): ?>
<tr>
<td><?= e($rec['entity_name_cache'] ?? '-') ?></td>
<td><?= $rec['entity_type'] === 'coach' ? 'مدرب' : 'متدرب' ?></td>
<td>
<?php if ($rec['status'] === 'present'): ?><span class="badge bg-success">حاضر</span>
<?php elseif ($rec['status'] === 'absent'): ?><span class="badge bg-danger">غائب</span>
<?php elseif ($rec['status'] === 'late'): ?><span class="badge bg-warning text-dark">متأخر</span>
<?php else: ?><span class="badge bg-secondary">بعذر</span><?php endif; ?>
</td>
<td><?= e($rec['check_in_time'] ?? '-') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<button type="submit" class="btn btn-success btn-lg">حفظ الحضور</button>
</form>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>التقرير الرياضي الشامل<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sports-dashboard/export?filter=<?= e($filter) ?>&from=<?= e($data['period']['from'] ?? '') ?>&to=<?= e($data['period']['to'] ?? '') ?>"
class="btn btn-outline-success" target="_blank">تصدير التقرير</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card mb-3">
<div class="card-body">
<form method="get" class="row g-3 align-items-end">
<div class="col-auto">
<label class="form-label">الفترة</label>
<select name="filter" class="form-select" onchange="toggleCustomDates(this)">
<option value="daily" <?= $filter === 'daily' ? 'selected' : '' ?>>يومي</option>
<option value="weekly" <?= $filter === 'weekly' ? 'selected' : '' ?>>أسبوعي</option>
<option value="monthly" <?= $filter === 'monthly' ? 'selected' : '' ?>>شهري</option>
<option value="yearly" <?= $filter === 'yearly' ? 'selected' : '' ?>>سنوي</option>
<option value="3years" <?= $filter === '3years' ? 'selected' : '' ?>>3 سنوات</option>
<option value="5years" <?= $filter === '5years' ? 'selected' : '' ?>>5 سنوات</option>
<option value="custom" <?= $filter === 'custom' ? 'selected' : '' ?>>تحديد تاريخ</option>
</select>
</div>
<div class="col-auto custom-dates" style="<?= $filter !== 'custom' ? 'display:none' : '' ?>">
<label class="form-label">من</label>
<input type="date" name="from" class="form-control" value="<?= e($data['period']['from'] ?? '') ?>">
</div>
<div class="col-auto custom-dates" style="<?= $filter !== 'custom' ? 'display:none' : '' ?>">
<label class="form-label">إلى</label>
<input type="date" name="to" class="form-control" value="<?= e($data['period']['to'] ?? '') ?>">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary">عرض</button>
</div>
</form>
</div>
</div>
<!-- Stats Cards -->
<div class="row mb-4">
<div class="col-md-2">
<div class="card bg-success text-white">
<div class="card-body text-center">
<h6>إجمالي الإيرادات</h6>
<h4><?= number_format($data['total_revenue'] ?? 0, 2) ?> ج.م</h4>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card bg-primary text-white">
<div class="card-body text-center">
<h6>إيرادات الحجوزات</h6>
<h4><?= number_format($data['booking_revenue'] ?? 0, 2) ?> ج.م</h4>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card bg-info text-white">
<div class="card-body text-center">
<h6>الأوقات الحرة</h6>
<h4><?= number_format($data['free_time_revenue'] ?? 0, 2) ?> ج.م</h4>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card bg-dark text-white">
<div class="card-body text-center">
<h6>اللاعبين الفريدين</h6>
<h4><?= $data['unique_players'] ?? 0 ?></h4>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card bg-secondary text-white">
<div class="card-body text-center">
<h6>المدربين النشطين</h6>
<h4><?= $data['active_coaches'] ?? 0 ?></h4>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card bg-warning text-dark">
<div class="card-body text-center">
<h6>نسبة الحضور</h6>
<h4><?= $data['avg_attendance_rate'] ?? 0 ?>%</h4>
</div>
</div>
</div>
</div>
<!-- Revenue by Facility -->
<?php if (!empty($data['bookings_by_facility'])): ?>
<div class="card mb-3">
<div class="card-header">الإيرادات حسب المرفق</div>
<div class="card-body p-0">
<table class="table table-bordered table-hover mb-0">
<thead><tr><th>المرفق</th><th>النوع</th><th>الحجوزات</th><th>الإيرادات</th></tr></thead>
<tbody>
<?php foreach ($data['bookings_by_facility'] as $row): ?>
<tr>
<td><?= e($row['facility_name']) ?></td>
<td><?= e($row['facility_type']) ?></td>
<td><?= $row['bookings'] ?></td>
<td><?= number_format((float) $row['revenue'], 2) ?> ج.م</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- Playground Stats -->
<?php if (!empty($data['playground_stats'])): ?>
<div class="card mb-3">
<div class="card-header">إحصائيات الملاعب</div>
<div class="card-body p-0">
<table class="table table-bordered table-hover mb-0">
<thead><tr><th>الملعب</th><th>النوع</th><th>الحجوزات</th><th>الإيرادات</th></tr></thead>
<tbody>
<?php foreach ($data['playground_stats'] as $row): ?>
<tr>
<td><?= e($row['playground_name_ar']) ?></td>
<td><?= e($row['playground_type']) ?></td>
<td><?= $row['bookings'] ?></td>
<td><?= number_format((float) $row['revenue'], 2) ?> ج.م</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- Monthly Trend -->
<?php if (!empty($data['monthly_trend'])): ?>
<div class="card mb-3">
<div class="card-header">الاتجاه الشهري</div>
<div class="card-body p-0">
<table class="table table-bordered table-hover mb-0">
<thead><tr><th>الشهر</th><th>الحجوزات</th><th>الإيرادات</th></tr></thead>
<tbody>
<?php foreach ($data['monthly_trend'] as $row): ?>
<tr>
<td><?= $row['month'] ?></td>
<td><?= $row['bookings'] ?></td>
<td><?= number_format((float) $row['revenue'], 2) ?> ج.م</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<script>
function toggleCustomDates(el) {
document.querySelectorAll('.custom-dates').forEach(d => {
d.style.display = el.value === 'custom' ? '' : 'none';
});
}
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إنشاء ملعب جديد<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="container-fluid" style="max-width:700px;">
<form method="post" action="/playgrounds">
<?= csrf_field() ?>
<div class="card mb-3">
<div class="card-header">بيانات الملعب</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">اسم الملعب (عربي) *</label>
<input type="text" name="playground_name_ar" class="form-control" value="<?= e(old('playground_name_ar')) ?>" required>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">النوع *</label>
<select name="playground_type" class="form-select" id="typeSelect" onchange="toggleRows()" required>
<option value="">اختر النوع</option>
<?php foreach ($types as $key => $label): ?>
<option value="<?= $key ?>" <?= old('playground_type') === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label">المرفق *</label>
<select name="facility_id" class="form-select" required>
<option value="">اختر المرفق</option>
<?php foreach ($facilities as $f): ?>
<option value="<?= (int) $f['id'] ?>" <?= old('facility_id') == $f['id'] ? 'selected' : '' ?>><?= e($f['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div id="rowSection" style="display:none;">
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">عدد الحارات/الصفوف</label>
<input type="number" name="row_count" class="form-control" value="<?= e(old('row_count')) ?>" min="1">
</div>
<div class="col-md-6">
<label class="form-label">مسمى الصف</label>
<input type="text" name="row_label_ar" class="form-control" value="<?= e(old('row_label_ar') ?: 'حارة') ?>">
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">ساعة البدء</label>
<input type="number" name="operating_hours[start]" class="form-control" value="<?= e(old('operating_hours')['start'] ?? '7') ?>" min="0" max="23">
</div>
<div class="col-md-6">
<label class="form-label">ساعة الانتهاء</label>
<input type="number" name="operating_hours[end]" class="form-control" value="<?= e(old('operating_hours')['end'] ?? '22') ?>" min="1" max="24">
</div>
</div>
<div class="mb-3">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-control" rows="3"><?= e(old('notes')) ?></textarea>
</div>
</div>
</div>
<button type="submit" class="btn btn-success btn-lg">حفظ الملعب</button>
<a href="/playgrounds" class="btn btn-secondary btn-lg">إلغاء</a>
</form>
</div>
<script>
const rowTypes = ['bowling', 'swimming'];
function toggleRows() {
const type = document.getElementById('typeSelect').value;
document.getElementById('rowSection').style.display = rowTypes.includes(type) ? '' : 'none';
}
toggleRows();
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>لوحة تحكم - <?= e($data['playground']['playground_name_ar'] ?? 'ملعب') ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/playgrounds/<?= $playground_id ?>/dashboard/export?filter=<?= e($filter) ?>&from=<?= e($data['period']['from'] ?? '') ?>&to=<?= e($data['period']['to'] ?? '') ?>"
class="btn btn-outline-success" target="_blank">تصدير التقرير</a>
<a href="/playgrounds/<?= $playground_id ?>" class="btn btn-outline-secondary">رجوع</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card mb-3">
<div class="card-body">
<form method="get" class="row g-3 align-items-end">
<div class="col-auto">
<label class="form-label">الفترة</label>
<select name="filter" class="form-select" onchange="toggleCustomDates(this)">
<option value="daily" <?= $filter === 'daily' ? 'selected' : '' ?>>يومي</option>
<option value="weekly" <?= $filter === 'weekly' ? 'selected' : '' ?>>أسبوعي</option>
<option value="monthly" <?= $filter === 'monthly' ? 'selected' : '' ?>>شهري</option>
<option value="yearly" <?= $filter === 'yearly' ? 'selected' : '' ?>>سنوي</option>
<option value="3years" <?= $filter === '3years' ? 'selected' : '' ?>>3 سنوات</option>
<option value="5years" <?= $filter === '5years' ? 'selected' : '' ?>>5 سنوات</option>
<option value="custom" <?= $filter === 'custom' ? 'selected' : '' ?>>تحديد تاريخ</option>
</select>
</div>
<div class="col-auto custom-dates" style="<?= $filter !== 'custom' ? 'display:none' : '' ?>">
<label class="form-label">من</label>
<input type="date" name="from" class="form-control" value="<?= e($data['period']['from'] ?? '') ?>">
</div>
<div class="col-auto custom-dates" style="<?= $filter !== 'custom' ? 'display:none' : '' ?>">
<label class="form-label">إلى</label>
<input type="date" name="to" class="form-control" value="<?= e($data['period']['to'] ?? '') ?>">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary">عرض</button>
</div>
</form>
</div>
</div>
<div class="row mb-4">
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body text-center">
<h6>إجمالي الإيرادات</h6>
<h3><?= number_format($data['total_revenue'] ?? 0, 2) ?> ج.م</h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body text-center">
<h6>عدد الحجوزات</h6>
<h3><?= $data['total_bookings'] ?? 0 ?></h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body text-center">
<h6>المستخدمين الفريدين</h6>
<h3><?= $data['unique_users'] ?? 0 ?></h3>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-dark">
<div class="card-body text-center">
<h6>نسبة الحضور</h6>
<h3><?= $data['attendance']['rate'] ?? 0 ?>%</h3>
</div>
</div>
</div>
</div>
<script>
function toggleCustomDates(el) {
document.querySelectorAll('.custom-dates').forEach(d => {
d.style.display = el.value === 'custom' ? '' : 'none';
});
}
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تعديل الملعب - <?= e($playground['playground_name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="container-fluid" style="max-width:700px;">
<form method="post" action="/playgrounds/<?= (int) $playground['id'] ?>">
<?= csrf_field() ?>
<div class="card mb-3">
<div class="card-header">بيانات الملعب</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">اسم الملعب (عربي) *</label>
<input type="text" name="playground_name_ar" class="form-control" value="<?= e($playground['playground_name_ar']) ?>" required>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">النوع *</label>
<select name="playground_type" class="form-select" id="typeSelect" onchange="toggleRows()" required>
<?php foreach ($types as $key => $label): ?>
<option value="<?= $key ?>" <?= $playground['playground_type'] === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6">
<label class="form-label">المرفق *</label>
<select name="facility_id" class="form-select" required>
<?php foreach ($facilities as $f): ?>
<option value="<?= (int) $f['id'] ?>" <?= (int) $playground['facility_id'] === (int) $f['id'] ? 'selected' : '' ?>><?= e($f['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div id="rowSection" style="display:none;">
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">عدد الحارات/الصفوف</label>
<input type="number" name="row_count" class="form-control" value="<?= (int) ($playground['row_count'] ?? 0) ?>" min="1">
</div>
<div class="col-md-6">
<label class="form-label">مسمى الصف</label>
<input type="text" name="row_label_ar" class="form-control" value="<?= e($playground['row_label_ar'] ?? 'حارة') ?>">
</div>
</div>
</div>
<?php $hours = json_decode($playground['operating_hours_json'] ?? '{}', true); ?>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">ساعة البدء</label>
<input type="number" name="operating_hours[start]" class="form-control" value="<?= (int) ($hours['start'] ?? 7) ?>" min="0" max="23">
</div>
<div class="col-md-6">
<label class="form-label">ساعة الانتهاء</label>
<input type="number" name="operating_hours[end]" class="form-control" value="<?= (int) ($hours['end'] ?? 22) ?>" min="1" max="24">
</div>
</div>
<div class="mb-3">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-control" rows="3"><?= e($playground['notes'] ?? '') ?></textarea>
</div>
</div>
</div>
<button type="submit" class="btn btn-success btn-lg">حفظ التعديلات</button>
<a href="/playgrounds/<?= (int) $playground['id'] ?>" class="btn btn-secondary btn-lg">إلغاء</a>
</form>
</div>
<script>
const rowTypes = ['bowling', 'swimming'];
function toggleRows() {
const type = document.getElementById('typeSelect').value;
document.getElementById('rowSection').style.display = rowTypes.includes(type) ? '' : 'none';
}
toggleRows();
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إدارة الملاعب<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/playgrounds/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'); ?>
<div class="card mb-3">
<div class="card-body">
<form method="get" class="row g-3 align-items-end">
<div class="col-auto">
<label class="form-label">النوع</label>
<select name="type" class="form-select">
<option value="">الكل</option>
<?php foreach ($types as $key => $label): ?>
<option value="<?= $key ?>" <?= ($filters['type'] ?? '') === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-auto">
<label class="form-label">الحالة</label>
<select name="is_active" class="form-select">
<option value="">الكل</option>
<option value="1" <?= ($filters['is_active'] ?? '') === '1' ? 'selected' : '' ?>>نشط</option>
<option value="0" <?= ($filters['is_active'] ?? '') === '0' ? 'selected' : '' ?>>غير نشط</option>
</select>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary">بحث</button>
</div>
</form>
</div>
</div>
<?php if (empty($playgrounds)): ?>
<div class="card" style="padding:60px;text-align:center;">
<i data-lucide="layout-grid" style="width:48px;height:48px;color:#9CA3AF;margin-bottom:16px;"></i>
<p style="font-size:16px;color:#6B7280;margin-bottom:16px;">لا توجد ملاعب بعد</p>
<a href="/playgrounds/create" class="btn btn-primary">إنشاء أول ملعب</a>
</div>
<?php else: ?>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:20px;">
<?php foreach ($playgrounds as $pg): ?>
<div class="card" style="padding:24px;position:relative;">
<?php if (!$pg['is_active']): ?>
<span style="position:absolute;top:12px;left:12px;background:#FEE2E2;color:#991B1B;font-size:11px;padding:2px 8px;border-radius:4px;">غير نشط</span>
<?php endif; ?>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
<div style="width:44px;height:44px;border-radius:10px;background:#DBEAFE;display:flex;align-items:center;justify-content:center;">
<i data-lucide="square" style="width:22px;height:22px;color:#2563EB;"></i>
</div>
<div>
<h3 style="font-size:16px;font-weight:700;margin:0;"><?= e($pg['playground_name_ar']) ?></h3>
<span style="font-size:12px;color:#6B7280;"><?= e($types[$pg['playground_type']] ?? $pg['playground_type']) ?> - <?= e($pg['facility_name'] ?? '') ?></span>
</div>
</div>
<?php if ($pg['has_rows']): ?>
<div style="font-size:13px;color:#374151;margin-bottom:12px;">
<i data-lucide="rows-3" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;color:#9CA3AF;"></i>
<?= (int) $pg['row_count'] ?> <?= e($pg['row_label_ar'] ?? 'حارة') ?>
</div>
<?php endif; ?>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<a href="/playgrounds/<?= (int) $pg['id'] ?>" class="btn btn-sm btn-outline-primary">عرض</a>
<a href="/playgrounds/<?= (int) $pg['id'] ?>/mirror" class="btn btn-sm btn-outline-info">المرآة</a>
<a href="/playgrounds/<?= (int) $pg['id'] ?>/schedules" class="btn btn-sm btn-outline-secondary">الجداول</a>
<a href="/playgrounds/<?= (int) $pg['id'] ?>/dashboard" class="btn btn-sm btn-outline-success">لوحة التحكم</a>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if ($pagination['last_page'] > 1): ?>
<nav class="mt-4">
<ul class="pagination justify-content-center">
<?php for ($p = 1; $p <= $pagination['last_page']; $p++): ?>
<li class="page-item <?= $p === $pagination['current_page'] ? 'active' : '' ?>">
<a class="page-link" href="?page=<?= $p ?>&type=<?= e($filters['type'] ?? '') ?>&is_active=<?= e($filters['is_active'] ?? '') ?>"><?= $p ?></a>
</li>
<?php endfor; ?>
</ul>
</nav>
<?php endif; ?>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>المرآة - <?= e($playground['playground_name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/playgrounds/<?= (int) $playground['id'] ?>" class="btn btn-outline-secondary">رجوع للملعب</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card mb-3">
<div class="card-body">
<form method="get" action="/playgrounds/<?= (int) $playground['id'] ?>/mirror" class="row g-3 align-items-end">
<div class="col-auto">
<label class="form-label">التاريخ</label>
<input type="date" name="date" id="mirrorDate" class="form-control" value="<?= e($date) ?>">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary">عرض</button>
</div>
<div class="col-auto">
<button type="button" class="btn btn-outline-info" onclick="refreshMirror()">
<i data-lucide="refresh-cw" style="width:14px;height:14px;vertical-align:middle;"></i> تحديث
</button>
</div>
</form>
</div>
</div>
<div id="mirrorGrid">
<?php if (empty($state['hours'])): ?>
<div class="card" style="padding:40px;text-align:center;">
<p style="color:#6B7280;">لا توجد بيانات لهذا اليوم</p>
</div>
<?php else: ?>
<div class="card">
<div class="card-body p-0">
<table class="table table-bordered table-hover mb-0">
<thead>
<tr>
<th style="width:80px;text-align:center;">الساعة</th>
<th>الحالة</th>
<th>التفاصيل</th>
</tr>
</thead>
<tbody>
<?php foreach ($state['hours'] as $hourData): ?>
<tr>
<td style="text-align:center;font-weight:700;vertical-align:middle;">
<?= e($hourData['hour']) ?>
</td>
<td style="vertical-align:middle;">
<?php if ($hourData['status'] === 'free'): ?>
<span class="badge bg-success">متاح</span>
<?php elseif ($hourData['status'] === 'booked'): ?>
<span class="badge bg-danger">محجوز</span>
<?php elseif ($hourData['status'] === 'training'): ?>
<span class="badge bg-warning text-dark">تدريب</span>
<?php endif; ?>
</td>
<td>
<?php foreach ($hourData['reservations'] as $res): ?>
<div class="mb-1" style="font-size:13px;">
<i data-lucide="user" style="width:12px;height:12px;vertical-align:middle;"></i>
<?= e($res['booker_name'] ?? '-') ?>
<span style="color:#6B7280;"> (<?= e($res['start_time']) ?> - <?= e($res['end_time']) ?>)</span>
<span class="badge bg-light text-dark"><?= number_format((float) ($res['total_amount'] ?? 0), 2) ?> ج.م</span>
</div>
<?php endforeach; ?>
<?php foreach ($hourData['private_matches'] as $pm): ?>
<div class="mb-1" style="font-size:13px;">
<i data-lucide="swords" style="width:12px;height:12px;vertical-align:middle;"></i>
<?= e($pm['team_a_name'] ?? '') ?> ضد <?= e($pm['team_b_name'] ?? '') ?>
<span style="color:#6B7280;"> (<?= e($pm['match_type'] ?? '') ?>)</span>
</div>
<?php endforeach; ?>
<?php foreach ($hourData['training_slots'] as $slot): ?>
<div class="mb-1" style="font-size:13px;">
<i data-lucide="dumbbell" style="width:12px;height:12px;vertical-align:middle;"></i>
<?= e($slot['coach_name'] ?? '-') ?>
<?php if (!empty($slot['academy_name'])): ?>
- <?= e($slot['academy_name']) ?>
<?php endif; ?>
<?php if (!empty($slot['age_group_ar'])): ?>
<span style="color:#6B7280;">(<?= e($slot['age_group_ar']) ?>)</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php if ($hourData['status'] === 'free'): ?>
<span style="font-size:12px;color:#9CA3AF;">-</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>
<script>
function refreshMirror() {
const date = document.getElementById('mirrorDate').value;
fetch('/playgrounds/<?= (int) $playground['id'] ?>/mirror/state?date=' + date)
.then(r => r.json())
.then(data => {
if (data.hours && data.hours.length > 0) {
location.href = '/playgrounds/<?= (int) $playground['id'] ?>/mirror/' + date;
}
});
}
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= $mode === 'create' ? 'إنشاء جدول جديد' : 'تعديل الجدول' ?> - <?= e($playground['playground_name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$action = $mode === 'create'
? '/playgrounds/' . (int) $playground['id'] . '/schedules'
: '/playgrounds/' . (int) $playground['id'] . '/schedules/' . (int) $schedule['id'];
$workingDays = $schedule ? json_decode($schedule['working_days_json'] ?? '[]', true) : [];
$dayNames = ['السبت', 'الأحد', 'الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة'];
?>
<div class="container-fluid" style="max-width:700px;">
<form method="post" action="<?= $action ?>">
<?= csrf_field() ?>
<div class="card mb-3">
<div class="card-header">بيانات الجدول</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">اسم الجدول *</label>
<input type="text" name="plan_name_ar" class="form-control" value="<?= e($schedule['plan_name_ar'] ?? '') ?>" required>
</div>
<?php if ($mode === 'create'): ?>
<div class="mb-3">
<label class="form-label">شهر الخطة *</label>
<input type="date" name="plan_month" class="form-control" value="<?= date('Y-m-01') ?>" required>
</div>
<?php endif; ?>
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">ساعة البدء</label>
<input type="time" name="day_start_time" class="form-control" value="<?= e($schedule['day_start_time'] ?? '07:00:00') ?>">
</div>
<div class="col-md-6">
<label class="form-label">ساعة الانتهاء</label>
<input type="time" name="day_end_time" class="form-control" value="<?= e($schedule['day_end_time'] ?? '22:00:00') ?>">
</div>
</div>
<div class="mb-3">
<label class="form-label">أيام العمل</label>
<div class="d-flex flex-wrap gap-3">
<?php for ($d = 0; $d < 7; $d++): ?>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="working_days[]" value="<?= $d ?>" id="day<?= $d ?>"
<?= in_array($d, $workingDays) ? 'checked' : '' ?>>
<label class="form-check-label" for="day<?= $d ?>"><?= $dayNames[$d] ?></label>
</div>
<?php endfor; ?>
</div>
</div>
<?php if ($mode === 'edit' && $schedule['status'] === 'draft'): ?>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="status" value="active" id="activateCheck">
<label class="form-check-label" for="activateCheck">تفعيل الجدول</label>
</div>
</div>
<?php endif; ?>
<div class="mb-3">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-control" rows="3"><?= e($schedule['notes'] ?? '') ?></textarea>
</div>
</div>
</div>
<button type="submit" class="btn btn-success btn-lg">حفظ</button>
<a href="/playgrounds/<?= (int) $playground['id'] ?>/schedules" class="btn btn-secondary btn-lg">إلغاء</a>
</form>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= e($schedule['plan_name_ar']) ?> - <?= e($playground['playground_name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/playgrounds/<?= (int) $playground['id'] ?>/schedules/<?= (int) $schedule['id'] ?>/edit" class="btn btn-warning">تعديل</a>
<a href="/playgrounds/<?= (int) $playground['id'] ?>/schedules" class="btn btn-outline-secondary">رجوع</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$startTime = $schedule['day_start_time'] ?? '07:00:00';
$endTime = $schedule['day_end_time'] ?? '22:00:00';
$startHour = (int) substr($startTime, 0, 2);
$endHour = (int) substr($endTime, 0, 2);
?>
<div class="card mb-3">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto">
<span class="badge bg-<?= $schedule['status'] === 'active' ? 'success' : 'secondary' ?>"><?= $schedule['status'] === 'active' ? 'نشط' : 'مسودة' ?></span>
</div>
<div class="col-auto">الشهر: <?= e($schedule['plan_month']) ?></div>
<div class="col">
<form method="get" class="d-flex gap-2 align-items-center">
<label class="form-label mb-0">التاريخ:</label>
<input type="date" name="date" class="form-control form-control-sm" value="<?= e($date) ?>" style="width:auto;">
<button type="submit" class="btn btn-sm btn-primary">عرض</button>
</form>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">خانات يوم <?= e($date) ?></div>
<div class="card-body p-0">
<table class="table table-bordered table-hover mb-0">
<thead>
<tr>
<th style="width:80px;">الساعة</th>
<?php if ($playground['has_rows']): ?>
<th>الصف</th>
<?php endif; ?>
<th>المدرب</th>
<th>الأكاديمية</th>
<th>الفئة</th>
<th>إجراء</th>
</tr>
</thead>
<tbody>
<?php
$slotsByHour = [];
foreach ($slots as $s) {
$h = substr($s['slot_hour'], 0, 2);
$slotsByHour[$h][] = $s;
}
?>
<?php for ($h = $startHour; $h < $endHour; $h++):
$hourKey = str_pad((string) $h, 2, '0', STR_PAD_LEFT);
$hourSlots = $slotsByHour[$hourKey] ?? [];
?>
<?php if (empty($hourSlots)): ?>
<tr>
<td style="text-align:center;font-weight:700;"><?= $hourKey ?>:00</td>
<?php if ($playground['has_rows']): ?><td>-</td><?php endif; ?>
<td colspan="3" style="color:#9CA3AF;">فارغ</td>
<td><a href="/playgrounds/<?= (int) $playground['id'] ?>/schedules/<?= (int) $schedule['id'] ?>/slots/<?= e($date) ?>/<?= $hourKey ?>" class="btn btn-sm btn-outline-primary">تعيين</a></td>
</tr>
<?php else: ?>
<?php foreach ($hourSlots as $i => $slot): ?>
<tr>
<?php if ($i === 0): ?>
<td style="text-align:center;font-weight:700;" rowspan="<?= count($hourSlots) ?>"><?= $hourKey ?>:00</td>
<?php endif; ?>
<?php if ($playground['has_rows']): ?>
<td><?= $slot['row_number'] ? (e($playground['row_label_ar'] ?? 'حارة') . ' ' . $slot['row_number']) : '-' ?></td>
<?php endif; ?>
<td><?= e($slot['coach_name'] ?? '-') ?></td>
<td><?= e($slot['academy_name'] ?? '-') ?></td>
<td><?= e($slot['age_group_ar'] ?? '-') ?> <?= $slot['gender'] ? '(' . e($slot['gender']) . ')' : '' ?></td>
<?php if ($i === 0): ?>
<td rowspan="<?= count($hourSlots) ?>"><a href="/playgrounds/<?= (int) $playground['id'] ?>/schedules/<?= (int) $schedule['id'] ?>/slots/<?= e($date) ?>/<?= $hourKey ?>" class="btn btn-sm btn-outline-warning">تعديل</a></td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
<?php endif; ?>
<?php endfor; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الجداول الشهرية - <?= e($playground['playground_name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/playgrounds/<?= (int) $playground['id'] ?>/schedules/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> جدول جديد</a>
<a href="/playgrounds/<?= (int) $playground['id'] ?>" class="btn btn-outline-secondary">رجوع</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php if (empty($schedules)): ?>
<div class="card" style="padding:60px;text-align:center;">
<i data-lucide="calendar" style="width:48px;height:48px;color:#9CA3AF;margin-bottom:16px;"></i>
<p style="font-size:16px;color:#6B7280;">لا توجد جداول شهرية</p>
<a href="/playgrounds/<?= (int) $playground['id'] ?>/schedules/create" class="btn btn-primary">إنشاء أول جدول</a>
</div>
<?php else: ?>
<div class="card">
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>اسم الجدول</th>
<th>الشهر</th>
<th>ساعات العمل</th>
<th>الحالة</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($schedules as $sched): ?>
<tr>
<td><?= e($sched['plan_name_ar']) ?></td>
<td><?= e($sched['plan_month']) ?></td>
<td><?= e($sched['day_start_time'] ?? '07:00') ?> - <?= e($sched['day_end_time'] ?? '22:00') ?></td>
<td>
<?php if ($sched['status'] === 'active'): ?>
<span class="badge bg-success">نشط</span>
<?php elseif ($sched['status'] === 'draft'): ?>
<span class="badge bg-secondary">مسودة</span>
<?php else: ?>
<span class="badge bg-dark">مؤرشف</span>
<?php endif; ?>
</td>
<td>
<a href="/playgrounds/<?= (int) $playground['id'] ?>/schedules/<?= (int) $sched['id'] ?>" class="btn btn-sm btn-outline-primary">عرض</a>
<a href="/playgrounds/<?= (int) $playground['id'] ?>/schedules/<?= (int) $sched['id'] ?>/edit" class="btn btn-sm btn-outline-warning">تعديل</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= e($playground['playground_name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/playgrounds/<?= (int) $playground['id'] ?>/edit" class="btn btn-warning">تعديل</a>
<form method="post" action="/playgrounds/<?= (int) $playground['id'] ?>/toggle" class="d-inline">
<?= csrf_field() ?>
<button type="submit" class="btn <?= $playground['is_active'] ? 'btn-outline-danger' : 'btn-outline-success' ?>">
<?= $playground['is_active'] ? 'تعطيل' : 'تفعيل' ?>
</button>
</form>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="row">
<div class="col-md-8">
<div class="card mb-3">
<div class="card-header">بيانات الملعب</div>
<div class="card-body">
<table class="table table-borderless">
<tr><th style="width:200px;">الاسم</th><td><?= e($playground['playground_name_ar']) ?></td></tr>
<tr><th>النوع</th><td><?= e($types[$playground['playground_type']] ?? $playground['playground_type']) ?></td></tr>
<tr><th>المرفق</th><td><?= e($playground['facility_name'] ?? '-') ?></td></tr>
<tr><th>الحالة</th><td><span class="badge bg-<?= $playground['is_active'] ? 'success' : 'secondary' ?>"><?= $playground['is_active'] ? 'نشط' : 'غير نشط' ?></span></td></tr>
<?php if ($playground['has_rows']): ?>
<tr><th><?= e($playground['row_label_ar'] ?? 'حارة') ?></th><td><?= (int) $playground['row_count'] ?></td></tr>
<?php endif; ?>
<?php $hours = json_decode($playground['operating_hours_json'] ?? '{}', true); ?>
<?php if (!empty($hours)): ?>
<tr><th>ساعات العمل</th><td><?= ($hours['start'] ?? 7) ?>:00 - <?= ($hours['end'] ?? 22) ?>:00</td></tr>
<?php endif; ?>
<?php if ($playground['notes']): ?>
<tr><th>ملاحظات</th><td><?= e($playground['notes']) ?></td></tr>
<?php endif; ?>
</table>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">إجراءات سريعة</div>
<div class="card-body d-grid gap-2">
<a href="/playgrounds/<?= (int) $playground['id'] ?>/mirror" class="btn btn-outline-info">المرآة (الحالة اللحظية)</a>
<a href="/playgrounds/<?= (int) $playground['id'] ?>/schedules" class="btn btn-outline-secondary">الجداول الشهرية</a>
<a href="/playgrounds/<?= (int) $playground['id'] ?>/attendance" class="btn btn-outline-primary">تسجيل الحضور</a>
<a href="/playgrounds/<?= (int) $playground['id'] ?>/dashboard" class="btn btn-outline-success">لوحة التحكم</a>
</div>
</div>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تعيين خانة <?= e($hour) ?>:00 - <?= e($playground['playground_name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php $hasRows = (bool) $playground['has_rows']; $rowCount = (int) ($playground['row_count'] ?? 1); ?>
<div class="container-fluid" style="max-width:900px;">
<div class="card mb-3">
<div class="card-body">
<strong>التاريخ:</strong> <?= e($date) ?> | <strong>الساعة:</strong> <?= e($hour) ?>:00
</div>
</div>
<form method="post" action="/playgrounds/<?= (int) $playground['id'] ?>/schedules/<?= $schedule_id ?>/slots/<?= e($date) ?>/<?= e($hour) ?>">
<?= csrf_field() ?>
<div id="slotContainer">
<?php
$existing = [];
foreach ($slots as $s) {
$existing[$s['row_number'] ?? 0] = $s;
}
$count = $hasRows ? $rowCount : max(1, count($slots));
for ($r = 0; $r < $count; $r++):
$rowNum = $hasRows ? ($r + 1) : null;
$slot = $existing[$rowNum] ?? ($existing[0] ?? null);
if (!$hasRows && $r > 0) $slot = $slots[$r] ?? null;
?>
<div class="card mb-3 slot-card">
<div class="card-header">
<?php if ($hasRows): ?>
<?= e($playground['row_label_ar'] ?? 'حارة') ?> <?= $r + 1 ?>
<input type="hidden" name="slots[<?= $r ?>][row_number]" value="<?= $r + 1 ?>">
<?php else: ?>
خانة <?= $r + 1 ?>
<?php endif; ?>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">المدرب</label>
<select name="slots[<?= $r ?>][coach_id]" class="form-select">
<option value="">-- بدون --</option>
<?php foreach ($coaches as $c): ?>
<option value="<?= (int) $c['id'] ?>" <?= ($slot['coach_id'] ?? '') == $c['id'] ? 'selected' : '' ?>><?= e($c['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label">الأكاديمية</label>
<select name="slots[<?= $r ?>][academy_id]" class="form-select">
<option value="">-- بدون --</option>
<?php foreach ($academies as $a): ?>
<option value="<?= (int) $a['id'] ?>" <?= ($slot['academy_id'] ?? '') == $a['id'] ? 'selected' : '' ?>><?= e($a['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label">نوع المجموعة</label>
<input type="text" name="slots[<?= $r ?>][group_type]" class="form-control" value="<?= e($slot['group_type'] ?? '') ?>">
</div>
<div class="col-md-3">
<label class="form-label">أقصى عدد</label>
<input type="number" name="slots[<?= $r ?>][max_trainees]" class="form-control" value="<?= (int) ($slot['max_trainees'] ?? 0) ?>" min="0">
</div>
<div class="col-md-3">
<label class="form-label">الفئة العمرية</label>
<input type="text" name="slots[<?= $r ?>][age_group_ar]" class="form-control" value="<?= e($slot['age_group_ar'] ?? '') ?>">
</div>
<div class="col-md-3">
<label class="form-label">الجنس</label>
<select name="slots[<?= $r ?>][gender]" class="form-select">
<option value="">مختلط</option>
<option value="male" <?= ($slot['gender'] ?? '') === 'male' ? 'selected' : '' ?>>ذكور</option>
<option value="female" <?= ($slot['gender'] ?? '') === 'female' ? 'selected' : '' ?>>إناث</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">ملاحظات</label>
<input type="text" name="slots[<?= $r ?>][notes]" class="form-control" value="<?= e($slot['notes'] ?? '') ?>">
</div>
</div>
</div>
</div>
<?php endfor; ?>
</div>
<button type="submit" class="btn btn-success btn-lg">حفظ الخانة</button>
<a href="/playgrounds/<?= (int) $playground['id'] ?>/schedules/<?= $schedule_id ?>?date=<?= e($date) ?>" class="btn btn-secondary btn-lg">إلغاء</a>
</form>
</div>
<?php $__template->endSection(); ?>
<?php
declare(strict_types=1);
use App\Core\Registries\PermissionRegistry;
use App\Core\Registries\MenuRegistry;
PermissionRegistry::register('playground', 'إدارة الملاعب', [
'playground.view' => 'عرض الملاعب',
'playground.manage' => 'إدارة الملاعب',
'playground.schedule' => 'إدارة جداول الملاعب',
'playground.attendance' => 'تسجيل حضور الملاعب',
'playground.dashboard' => 'لوحة تحكم الملاعب',
]);
MenuRegistry::register('playground', [
'label' => 'الملاعب',
'icon' => 'bi-geo-alt',
'route' => '/playgrounds',
'permission' => 'playground.view',
'order' => 35,
'children' => [
['label' => 'قائمة الملاعب', 'route' => '/playgrounds', 'permission' => 'playground.view'],
['label' => 'لوحة التحكم الرياضية', 'route' => '/sports-dashboard', 'permission' => 'playground.dashboard'],
],
]);
<?php
declare(strict_types=1);
return [
// Auth
['POST', '/api/v1/coach/auth/login', 'TrainerPortal\Controllers\Api\CoachAuthController@login', ['cors'], null],
['POST', '/api/v1/coach/auth/logout', 'TrainerPortal\Controllers\Api\CoachAuthController@logout', ['cors', 'coach_auth'], null],
// Profile & Academy
['GET', '/api/v1/coach/profile', 'TrainerPortal\Controllers\Api\CoachProfileController@profile', ['cors', 'coach_auth'], null],
['GET', '/api/v1/coach/academy', 'TrainerPortal\Controllers\Api\CoachProfileController@academy', ['cors', 'coach_auth'], null],
// Schedule
['GET', '/api/v1/coach/schedule', 'TrainerPortal\Controllers\Api\CoachScheduleController@weeklySchedule', ['cors', 'coach_auth'], null],
['GET', '/api/v1/coach/sessions', 'TrainerPortal\Controllers\Api\CoachScheduleController@sessions', ['cors', 'coach_auth'], null],
['GET', '/api/v1/coach/groups', 'TrainerPortal\Controllers\Api\CoachScheduleController@groups', ['cors', 'coach_auth'], null],
['GET', '/api/v1/coach/groups/{id:\d+}/players', 'TrainerPortal\Controllers\Api\CoachScheduleController@groupPlayers', ['cors', 'coach_auth'], null],
// Day Off
['POST', '/api/v1/coach/day-off', 'TrainerPortal\Controllers\Api\DayOffController@request', ['cors', 'coach_auth'], null],
['GET', '/api/v1/coach/day-off', 'TrainerPortal\Controllers\Api\DayOffController@myRequests', ['cors', 'coach_auth'], null],
['GET', '/api/v1/coach/day-off/{id:\d+}', 'TrainerPortal\Controllers\Api\DayOffController@requestDetails', ['cors', 'coach_auth'], null],
// Replacement
['GET', '/api/v1/coach/replacements', 'TrainerPortal\Controllers\Api\ReplacementController@available', ['cors', 'coach_auth'], null],
['POST', '/api/v1/coach/replacements/{id:\d+}/volunteer', 'TrainerPortal\Controllers\Api\ReplacementController@volunteer', ['cors', 'coach_auth'], null],
];
<?php
declare(strict_types=1);
namespace App\Modules\TrainerPortal\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Core\Exceptions\ValidationException;
final class CoachAuthController extends ApiController
{
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
}
public function login(Request $request): Response
{
try {
$data = $this->validatedJson([
'phone' => 'required|string',
'password' => 'required|string',
]);
} catch (ValidationException $e) {
return $this->errorResponse('بيانات غير صالحة', 422, $e->errors());
}
$db = App::getInstance()->db();
$coach = $db->selectOne(
"SELECT id, name_ar, phone, password_hash, is_archived FROM coaches WHERE phone = ? AND is_archived = 0",
[$data['phone']]
);
if (!$coach || empty($coach['password_hash']) || !password_verify($data['password'], $coach['password_hash'])) {
return $this->errorResponse('رقم الهاتف أو كلمة المرور غير صحيحة', 401);
}
$config = App::getInstance()->config('api.token', []);
$ttlHours = $config['coach_ttl_hours'] ?? 2160;
$token = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', time() + ($ttlHours * 3600));
$db->insert('coach_tokens', [
'coach_id' => $coach['id'],
'token' => $token,
'name' => 'portal',
'expires_at' => $expiresAt,
]);
$db->update('coaches', ['last_login_at' => date('Y-m-d H:i:s')], 'id = ?', [$coach['id']]);
return $this->successResponse([
'token' => $token,
'expires_at' => $expiresAt,
'coach' => [
'id' => (int) $coach['id'],
'name' => $coach['name_ar'],
'phone' => $coach['phone'],
],
], 201);
}
public function logout(Request $request): Response
{
$token = $request->bearerToken();
$db = App::getInstance()->db();
$db->update('coach_tokens', ['is_revoked' => 1], '`token` = ?', [$token]);
return $this->successResponse(['logged_out' => true]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\TrainerPortal\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
final class CoachProfileController extends ApiController
{
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
}
public function profile(Request $request): Response
{
$coach = App::getInstance()->currentCoach();
$db = App::getInstance()->db();
$full = $db->selectOne(
"SELECT id, name_ar, name_en, phone, email, employment_type, payment_model,
session_rate, monthly_salary, certifications_json, session_types_json,
is_active, created_at
FROM coaches WHERE id = ?",
[$coach->id]
);
$disciplines = $db->select(
"SELECT cd.discipline_id, sd.name_ar as discipline_name
FROM coach_disciplines cd
INNER JOIN sport_disciplines sd ON sd.id = cd.discipline_id
WHERE cd.coach_id = ?",
[$coach->id]
);
$full['disciplines'] = $disciplines;
return $this->successResponse($full);
}
public function academy(Request $request): Response
{
$coach = App::getInstance()->currentCoach();
$db = App::getInstance()->db();
$assignments = $db->select(
"SELECT caa.id, caa.academy_id, caa.role, caa.assigned_at,
a.name_ar as academy_name, a.description_ar,
ac.id as contract_id, ac.contract_type, ac.revenue_share_percent,
ac.start_date, ac.end_date, ac.status as contract_status, ac.contract_image_path
FROM coach_academy_assignments caa
INNER JOIN academies a ON a.id = caa.academy_id
LEFT JOIN academy_contracts ac ON ac.academy_id = a.id AND ac.status = 'active'
WHERE caa.coach_id = ? AND caa.is_active = 1",
[$coach->id]
);
return $this->successResponse($assignments);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\TrainerPortal\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
final class CoachScheduleController extends ApiController
{
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
}
public function weeklySchedule(Request $request): Response
{
$coach = App::getInstance()->currentCoach();
$db = App::getInstance()->db();
$weekStart = $request->get('week', date('Y-m-d', strtotime('monday this week')));
$weekEnd = date('Y-m-d', strtotime($weekStart . ' +6 days'));
$sessions = $db->select(
"SELECT ts.id, ts.session_date, ts.start_time, ts.end_time, ts.session_type, ts.status,
ts.players_expected, ts.players_attended,
tg.name_ar as group_name, tg.group_type,
f.name_ar as facility_name,
sd.name_ar as discipline_name
FROM training_sessions ts
INNER JOIN training_groups tg ON tg.id = ts.group_id
LEFT JOIN facilities f ON f.id = ts.facility_id
LEFT JOIN sport_disciplines sd ON sd.id = tg.discipline_id
WHERE ts.coach_id = ? AND ts.session_date BETWEEN ? AND ?
ORDER BY ts.session_date, ts.start_time",
[$coach->id, $weekStart, $weekEnd]
);
return $this->successResponse($sessions);
}
public function sessions(Request $request): Response
{
$coach = App::getInstance()->currentCoach();
$db = App::getInstance()->db();
$from = $request->get('from', date('Y-m-d'));
$to = $request->get('to', date('Y-m-d', strtotime('+30 days')));
$sessions = $db->select(
"SELECT ts.id, ts.session_date, ts.start_time, ts.end_time, ts.session_type, ts.status,
ts.players_expected, ts.players_attended,
tg.name_ar as group_name, tg.group_type,
f.name_ar as facility_name
FROM training_sessions ts
INNER JOIN training_groups tg ON tg.id = ts.group_id
LEFT JOIN facilities f ON f.id = ts.facility_id
WHERE ts.coach_id = ? AND ts.session_date BETWEEN ? AND ?
ORDER BY ts.session_date, ts.start_time",
[$coach->id, $from, $to]
);
return $this->successResponse($sessions);
}
public function groups(Request $request): Response
{
$coach = App::getInstance()->currentCoach();
$db = App::getInstance()->db();
$groups = $db->select(
"SELECT tg.id, tg.name_ar, tg.group_type, tg.day_of_week, tg.start_time, tg.end_time,
tg.current_count, tg.max_capacity, tg.gender_restriction, tg.age_from, tg.age_to,
f.name_ar as facility_name,
sd.name_ar as discipline_name,
al.name_ar as level_name
FROM training_groups tg
LEFT JOIN facilities f ON f.id = tg.facility_id
LEFT JOIN sport_disciplines sd ON sd.id = tg.discipline_id
LEFT JOIN academy_levels al ON al.id = tg.level_id
WHERE tg.coach_id = ? AND tg.is_active = 1 AND tg.is_archived = 0
ORDER BY tg.day_of_week, tg.start_time",
[$coach->id]
);
return $this->successResponse($groups);
}
public function groupPlayers(Request $request, string $id): Response
{
$coach = App::getInstance()->currentCoach();
$db = App::getInstance()->db();
$group = $db->selectOne(
"SELECT id FROM training_groups WHERE id = ? AND coach_id = ?",
[(int) $id, $coach->id]
);
if (!$group) {
return $this->errorResponse('المجموعة غير موجودة أو ليست من مجموعاتك', 404);
}
$players = $db->select(
"SELECT p.id, p.name_ar, p.phone, p.date_of_birth, p.gender,
p.medical_status, p.medical_expiry_date,
gm.joined_at, gm.status as membership_status
FROM group_memberships gm
INNER JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = ? AND gm.status = 'active'
ORDER BY p.name_ar",
[(int) $id]
);
return $this->successResponse($players);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\TrainerPortal\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Core\Exceptions\ValidationException;
use App\Modules\TrainerPortal\Services\CoachDayOffService;
final class DayOffController extends ApiController
{
private CoachDayOffService $dayOffService;
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
$this->dayOffService = new CoachDayOffService();
}
public function request(Request $request): Response
{
try {
$data = $this->validatedJson([
'date' => 'required|string',
'reason' => 'nullable|string|max:500',
]);
} catch (ValidationException $e) {
return $this->errorResponse('بيانات غير صالحة', 422, $e->errors());
}
$coach = App::getInstance()->currentCoach();
$result = $this->dayOffService->requestDayOff((int) $coach->id, $data['date'], $data['reason'] ?? null);
if (isset($result['error'])) {
return $this->errorResponse($result['error'], 422);
}
return $this->successResponse($result, 201);
}
public function myRequests(Request $request): Response
{
$coach = App::getInstance()->currentCoach();
$requests = $this->dayOffService->getMyRequests((int) $coach->id);
return $this->successResponse($requests);
}
public function requestDetails(Request $request, string $id): Response
{
$coach = App::getInstance()->currentCoach();
$details = $this->dayOffService->getRequestDetails((int) $id, (int) $coach->id);
if (!$details) {
return $this->errorResponse('الطلب غير موجود', 404);
}
return $this->successResponse($details);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\TrainerPortal\Controllers\Api;
use App\Core\ApiController;
use App\Core\App;
use App\Core\Request;
use App\Core\Response;
use App\Modules\TrainerPortal\Services\ReplacementService;
final class ReplacementController extends ApiController
{
private ReplacementService $replacementService;
public function __construct(Request $request)
{
parent::__construct();
$this->request = $request;
$this->replacementService = new ReplacementService();
}
public function available(Request $request): Response
{
$coach = App::getInstance()->currentCoach();
$requests = $this->replacementService->getAvailableRequests((int) $coach->id);
return $this->successResponse($requests);
}
public function volunteer(Request $request, string $id): Response
{
$coach = App::getInstance()->currentCoach();
$result = $this->replacementService->volunteer((int) $id, (int) $coach->id);
if (isset($result['error'])) {
return $this->errorResponse($result['error'], 422);
}
return $this->successResponse($result);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\TrainerPortal\Services;
use App\Core\App;
use App\Core\EventBus;
final class CoachDayOffService
{
private \App\Core\Database $db;
public function __construct()
{
$this->db = App::getInstance()->db();
}
public function requestDayOff(int $coachId, string $date, ?string $reason): array
{
$existing = $this->db->selectOne(
"SELECT id FROM coach_day_off_requests WHERE coach_id = ? AND request_date = ? AND status NOT IN ('rejected')",
[$coachId, $date]
);
if ($existing) {
return ['error' => 'لديك طلب إجازة لهذا اليوم بالفعل'];
}
$affectedSessions = $this->db->select(
"SELECT ts.id, ts.group_id, ts.start_time, ts.end_time, tg.name_ar as group_name
FROM training_sessions ts
INNER JOIN training_groups tg ON tg.id = ts.group_id
WHERE ts.coach_id = ? AND ts.session_date = ? AND ts.status = 'scheduled'",
[$coachId, $date]
);
$this->db->insert('coach_day_off_requests', [
'coach_id' => $coachId,
'request_date' => $date,
'reason_ar' => $reason,
'status' => 'pending',
'affected_sessions_json' => json_encode($affectedSessions, JSON_UNESCAPED_UNICODE),
]);
$requestId = (int) $this->db->selectOne("SELECT LAST_INSERT_ID() as id")['id'];
$coach = $this->db->selectOne("SELECT name_ar FROM coaches WHERE id = ?", [$coachId]);
EventBus::dispatch('coach.day_off_requested', [
'coach_id' => $coachId,
'coach_name' => $coach['name_ar'] ?? '',
'request_id' => $requestId,
'request_date' => $date,
'affected_sessions' => count($affectedSessions),
]);
return [
'id' => $requestId,
'affected_sessions' => count($affectedSessions),
];
}
public function getMyRequests(int $coachId): array
{
return $this->db->select(
"SELECT id, request_date, reason_ar, status, replacement_coach_id,
replacement_volunteered_at, reviewed_at, admin_notes, created_at
FROM coach_day_off_requests
WHERE coach_id = ?
ORDER BY created_at DESC",
[$coachId]
);
}
public function getRequestDetails(int $requestId, int $coachId): ?array
{
$request = $this->db->selectOne(
"SELECT dor.*, c.name_ar as replacement_name
FROM coach_day_off_requests dor
LEFT JOIN coaches c ON c.id = dor.replacement_coach_id
WHERE dor.id = ? AND dor.coach_id = ?",
[$requestId, $coachId]
);
if ($request && $request['affected_sessions_json']) {
$request['affected_sessions'] = json_decode($request['affected_sessions_json'], true);
}
return $request;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\TrainerPortal\Services;
use App\Core\App;
use App\Core\EventBus;
final class ReplacementService
{
private \App\Core\Database $db;
public function __construct()
{
$this->db = App::getInstance()->db();
}
public function getAvailableRequests(int $coachId): array
{
return $this->db->select(
"SELECT dor.id, dor.coach_id, dor.request_date, dor.affected_sessions_json, dor.created_at,
c.name_ar as coach_name
FROM coach_day_off_requests dor
INNER JOIN coaches c ON c.id = dor.coach_id
WHERE dor.status = 'pending' AND dor.coach_id != ? AND dor.replacement_coach_id IS NULL
AND dor.request_date >= CURDATE()
ORDER BY dor.request_date ASC",
[$coachId]
);
}
public function volunteer(int $requestId, int $volunteerCoachId): array
{
$request = $this->db->selectOne(
"SELECT * FROM coach_day_off_requests WHERE id = ? AND status = 'pending' AND replacement_coach_id IS NULL",
[$requestId]
);
if (!$request) {
return ['error' => 'الطلب غير متاح أو تم تغطيته بالفعل'];
}
if ((int) $request['coach_id'] === $volunteerCoachId) {
return ['error' => 'لا يمكنك التطوع لتغطية إجازتك الخاصة'];
}
$conflict = $this->db->selectOne(
"SELECT id FROM training_sessions
WHERE coach_id = ? AND session_date = ? AND status = 'scheduled'",
[$volunteerCoachId, $request['request_date']]
);
if ($conflict) {
return ['error' => 'لديك تدريب في نفس اليوم، لا يمكنك التطوع'];
}
$this->db->update('coach_day_off_requests', [
'replacement_coach_id' => $volunteerCoachId,
'replacement_volunteered_at' => date('Y-m-d H:i:s'),
'status' => 'covered',
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$requestId]);
$affectedSessions = json_decode($request['affected_sessions_json'] ?? '[]', true);
foreach ($affectedSessions as $session) {
$this->db->update('training_sessions', [
'coach_id' => $volunteerCoachId,
], 'id = ? AND status = ?', [(int) $session['id'], 'scheduled']);
}
$volunteerCoach = $this->db->selectOne("SELECT name_ar FROM coaches WHERE id = ?", [$volunteerCoachId]);
$originalCoach = $this->db->selectOne("SELECT name_ar FROM coaches WHERE id = ?", [(int) $request['coach_id']]);
EventBus::dispatch('coach.replacement_confirmed', [
'original_coach_id' => (int) $request['coach_id'],
'original_coach_name' => $originalCoach['name_ar'] ?? '',
'replacement_coach_id' => $volunteerCoachId,
'replacement_name' => $volunteerCoach['name_ar'] ?? '',
'request_id' => $requestId,
'request_date' => $request['request_date'],
]);
return [
'success' => true,
'message' => 'تم تأكيد التطوع كبديل بنجاح',
];
}
}
<?php
declare(strict_types=1);
use App\Core\EventBus;
use App\Modules\PlayerApi\Services\PlayerNotificationService;
EventBus::listen('coach.day_off_requested', function (array $data) {
$db = \App\Core\App::getInstance()->db();
$players = $db->select(
"SELECT gm.player_id FROM group_memberships gm
INNER JOIN training_groups tg ON tg.id = gm.group_id
WHERE tg.coach_id = ? AND gm.status = 'active'",
[$data['coach_id']]
);
foreach ($players as $p) {
PlayerNotificationService::notify(
(int) $p['player_id'],
'session',
'إلغاء تدريب',
'تم إلغاء تدريب يوم ' . ($data['request_date'] ?? '') . ' بسبب إجازة المدرب',
$data
);
}
});
EventBus::listen('coach.replacement_confirmed', function (array $data) {
$db = \App\Core\App::getInstance()->db();
$players = $db->select(
"SELECT gm.player_id FROM group_memberships gm
INNER JOIN training_groups tg ON tg.id = gm.group_id
WHERE tg.coach_id = ? AND gm.status = 'active'",
[$data['original_coach_id']]
);
foreach ($players as $p) {
PlayerNotificationService::notify(
(int) $p['player_id'],
'session',
'مدرب بديل',
'سيحل المدرب ' . ($data['replacement_name'] ?? '') . ' محل المدرب يوم ' . ($data['request_date'] ?? ''),
$data
);
}
});
<?php
declare(strict_types=1);
namespace App\Shared\Services;
use App\Core\Response;
final class PdfExportService
{
public static function renderToHtml(string $viewPath, array $data, string $filename): Response
{
$modulePath = str_replace('.', '/', $viewPath);
$filePath = __DIR__ . '/../../Modules/' . $modulePath . '.php';
if (!file_exists($filePath)) {
$response = new Response();
return $response->html('<h1>Template not found</h1>', 404);
}
extract($data, EXTR_SKIP);
ob_start();
include $filePath;
$html = ob_get_clean();
$response = new Response();
return $response->html($html, 200, [
'Content-Type' => 'text/html; charset=utf-8',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}
public static function renderToPdf(string $html, string $filename): Response
{
$wkhtmltopdf = self::findBinary();
if ($wkhtmltopdf) {
$tmpInput = tempnam(sys_get_temp_dir(), 'pdf_in_') . '.html';
$tmpOutput = tempnam(sys_get_temp_dir(), 'pdf_out_') . '.pdf';
file_put_contents($tmpInput, $html);
$cmd = escapeshellarg($wkhtmltopdf)
. ' --encoding utf-8 --page-size A4 --margin-top 10 --margin-bottom 10'
. ' ' . escapeshellarg($tmpInput)
. ' ' . escapeshellarg($tmpOutput);
exec($cmd, $output, $returnCode);
@unlink($tmpInput);
if ($returnCode === 0 && file_exists($tmpOutput)) {
$pdfContent = file_get_contents($tmpOutput);
@unlink($tmpOutput);
$response = new Response();
return $response->html($pdfContent, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Length' => (string) strlen($pdfContent),
]);
}
@unlink($tmpOutput);
}
$response = new Response();
return $response->html($html, 200, [
'Content-Type' => 'text/html; charset=utf-8',
'Content-Disposition' => 'attachment; filename="' . str_replace('.pdf', '.html', $filename) . '"',
]);
}
private static function findBinary(): ?string
{
$paths = [
'/usr/local/bin/wkhtmltopdf',
'/usr/bin/wkhtmltopdf',
'/snap/bin/wkhtmltopdf',
];
foreach ($paths as $path) {
if (file_exists($path) && is_executable($path)) {
return $path;
}
}
$which = trim((string) shell_exec('which wkhtmltopdf 2>/dev/null'));
if (!empty($which) && is_executable($which)) {
return $which;
}
return null;
}
}
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS player_tokens (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
player_id INT UNSIGNED NOT NULL,
token VARCHAR(64) NOT NULL,
name VARCHAR(100) NOT NULL DEFAULT 'mobile',
device_info VARCHAR(255) NULL,
push_token VARCHAR(500) NULL,
last_used_at DATETIME NULL,
expires_at DATETIME NULL,
is_revoked TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_player_token (token),
KEY idx_player_id (player_id),
KEY idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS player_tokens",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS player_notifications (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
player_id INT UNSIGNED NOT NULL,
category VARCHAR(30) NOT NULL,
title_ar VARCHAR(300) NOT NULL,
body_ar TEXT NULL,
data_json JSON NULL,
is_read TINYINT(1) NOT NULL DEFAULT 0,
read_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_player_read (player_id, is_read, created_at),
KEY idx_category (category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS player_notifications",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE players
ADD COLUMN password_hash VARCHAR(255) NULL AFTER phone,
ADD COLUMN otp_code VARCHAR(6) NULL AFTER password_hash,
ADD COLUMN otp_expires_at DATETIME NULL AFTER otp_code,
ADD COLUMN last_login_at DATETIME NULL AFTER otp_expires_at;
ALTER TABLE players ADD INDEX idx_players_phone (phone)",
'down' => "
ALTER TABLE players
DROP INDEX idx_players_phone,
DROP COLUMN last_login_at,
DROP COLUMN otp_expires_at,
DROP COLUMN otp_code,
DROP COLUMN password_hash",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS coach_tokens (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
coach_id INT UNSIGNED NOT NULL,
token VARCHAR(64) NOT NULL,
name VARCHAR(100) NOT NULL DEFAULT 'portal',
last_used_at DATETIME NULL,
expires_at DATETIME NULL,
is_revoked TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_coach_token (token),
KEY idx_coach_id (coach_id),
KEY idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS coach_tokens",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE coaches
ADD COLUMN password_hash VARCHAR(255) NULL AFTER phone,
ADD COLUMN otp_code VARCHAR(6) NULL AFTER password_hash,
ADD COLUMN otp_expires_at DATETIME NULL AFTER otp_code,
ADD COLUMN last_login_at DATETIME NULL AFTER otp_expires_at",
'down' => "
ALTER TABLE coaches
DROP COLUMN last_login_at,
DROP COLUMN otp_expires_at,
DROP COLUMN otp_code,
DROP COLUMN password_hash",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS free_time_entries (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
player_id INT UNSIGNED NULL,
member_id INT UNSIGNED NULL,
facility_id INT UNSIGNED NOT NULL,
entry_date DATE NOT NULL,
entry_time TIME NOT NULL,
exit_time TIME NULL,
activity_type VARCHAR(30) NOT NULL,
is_member TINYINT(1) NOT NULL DEFAULT 0,
amount_paid DECIMAL(15,2) NOT NULL DEFAULT 0.00,
payment_id INT UNSIGNED NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_facility_date (facility_id, entry_date),
KEY idx_player (player_id),
KEY idx_member (member_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS free_time_entries",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS coach_day_off_requests (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
coach_id INT UNSIGNED NOT NULL,
request_date DATE NOT NULL,
reason_ar TEXT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
replacement_coach_id INT UNSIGNED NULL,
replacement_volunteered_at DATETIME NULL,
affected_sessions_json JSON NULL,
reviewed_by INT UNSIGNED NULL,
reviewed_at DATETIME NULL,
admin_notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL,
KEY idx_coach_date (coach_id, request_date),
KEY idx_status (status),
KEY idx_replacement (replacement_coach_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS coach_day_off_requests",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS pool_hour_plans (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
monthly_plan_id INT UNSIGNED NOT NULL,
plan_date DATE NOT NULL,
hour_slot TIME NOT NULL,
period VARCHAR(10) NOT NULL DEFAULT 'day',
grid_snapshot_json JSON NOT NULL,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL,
UNIQUE KEY uk_plan_date_hour (monthly_plan_id, plan_date, hour_slot),
KEY idx_plan_date (plan_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS pool_hour_plans",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS facility_attendance (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
grid_id INT UNSIGNED NOT NULL,
schedule_id INT UNSIGNED NULL,
monthly_plan_id INT UNSIGNED NULL,
attendance_date DATE NOT NULL,
hour_slot TIME NULL,
entity_type VARCHAR(20) NOT NULL,
entity_id INT UNSIGNED NOT NULL,
entity_name_cache VARCHAR(200) NULL,
status VARCHAR(20) NOT NULL DEFAULT 'present',
check_in_time TIME NULL,
notes TEXT NULL,
recorded_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_grid_date (grid_id, attendance_date),
KEY idx_entity (entity_type, entity_id),
KEY idx_plan (monthly_plan_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS facility_attendance",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS playground_configurations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
facility_id INT UNSIGNED NOT NULL,
playground_name_ar VARCHAR(200) NOT NULL,
playground_type VARCHAR(30) NOT NULL,
has_rows TINYINT(1) NOT NULL DEFAULT 0,
row_count INT UNSIGNED NULL,
row_label_ar VARCHAR(50) NOT NULL DEFAULT 'حارة',
grid_id INT UNSIGNED NULL,
operating_hours_json JSON NULL,
notes TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL,
KEY idx_facility (facility_id),
KEY idx_type (playground_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS playground_configurations",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS playground_monthly_schedules (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
playground_id INT UNSIGNED NOT NULL,
plan_month DATE NOT NULL,
plan_name_ar VARCHAR(200) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
working_days_json JSON NULL,
day_start_time TIME NOT NULL DEFAULT '07:00:00',
day_end_time TIME NOT NULL DEFAULT '22:00:00',
notes TEXT NULL,
created_by INT UNSIGNED NULL,
activated_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL,
KEY idx_playground (playground_id),
KEY idx_month (plan_month),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS playground_monthly_schedules",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS playground_schedule_slots (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
schedule_id INT UNSIGNED NOT NULL,
slot_date DATE NOT NULL,
slot_hour TIME NOT NULL,
row_number INT UNSIGNED NULL,
coach_id INT UNSIGNED NULL,
academy_id INT UNSIGNED NULL,
group_type VARCHAR(30) NULL,
max_trainees INT UNSIGNED NULL,
age_group_ar VARCHAR(50) NULL,
gender VARCHAR(10) NULL,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL,
KEY idx_schedule_date_hour (schedule_id, slot_date, slot_hour),
KEY idx_coach (coach_id),
KEY idx_academy (academy_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS playground_schedule_slots",
];
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$templates = [
[
'template_code' => 'player_enrolled',
'name_ar' => 'تأكيد التسجيل في رياضة',
'message_template_ar' => 'مرحباً {player_name}، تم تسجيلك بنجاح في مجموعة {group_name}. نتمنى لك تجربة ممتعة!',
'trigger_event' => 'player.enrolled',
],
[
'template_code' => 'player_booking_confirmed',
'name_ar' => 'تأكيد حجز مرفق',
'message_template_ar' => 'تم تأكيد حجزك في {facility_name} بتاريخ {date} من {start_time} إلى {end_time}.',
'trigger_event' => 'player.booking_confirmed',
],
[
'template_code' => 'player_booking_cancelled',
'name_ar' => 'إلغاء حجز',
'message_template_ar' => 'تم إلغاء حجزك في {facility_name} بتاريخ {date}.',
'trigger_event' => 'player.booking_cancelled',
],
[
'template_code' => 'player_free_time_entry',
'name_ar' => 'دخول وقت حر',
'message_template_ar' => 'تم تسجيل دخولك في {facility_name}. استمتع بوقتك!',
'trigger_event' => 'player.free_time_entry',
],
[
'template_code' => 'player_transfer_approved',
'name_ar' => 'قبول طلب نقل',
'message_template_ar' => 'تمت الموافقة على طلب نقلك إلى مجموعة {to_group_name}.',
'trigger_event' => 'player.transfer_result',
],
[
'template_code' => 'player_evaluation_completed',
'name_ar' => 'اكتمال التقييم',
'message_template_ar' => 'تم تقييمك من المدرب. يمكنك الاطلاع على نتيجتك من خلال التطبيق.',
'trigger_event' => 'player.evaluation_completed',
],
[
'template_code' => 'player_medical_expiring',
'name_ar' => 'انتهاء كشف طبي',
'message_template_ar' => 'تنبيه: كشفك الطبي سينتهي خلال 7 أيام. يرجى تجديده لمواصلة التدريب.',
'trigger_event' => 'player.medical_expiring',
],
[
'template_code' => 'coach_day_off_admin',
'name_ar' => 'طلب إجازة مدرب',
'message_template_ar' => 'المدرب {coach_name} طلب إجازة يوم {request_date}. {sessions_count} حصص ستتأثر.',
'trigger_event' => 'coach.day_off_requested',
],
[
'template_code' => 'coach_replacement_confirmed',
'name_ar' => 'تأكيد مدرب بديل',
'message_template_ar' => 'تم تأكيد المدرب {replacement_name} كبديل يوم {request_date}.',
'trigger_event' => 'coach.replacement_confirmed',
],
[
'template_code' => 'coach_session_reminder',
'name_ar' => 'تذكير بحصة تدريبية',
'message_template_ar' => 'تذكير: لديك حصة تدريبية غداً الساعة {session_time} في {facility_name}.',
'trigger_event' => 'coach.session_reminder',
],
[
'template_code' => 'facility_attendance_absent',
'name_ar' => 'إشعار غياب',
'message_template_ar' => 'تم تسجيل غيابك يوم {attendance_date}. إذا كان لديك عذر يرجى التواصل مع الإدارة.',
'trigger_event' => 'facility.attendance_absent',
],
[
'template_code' => 'facility_payment_reminder',
'name_ar' => 'تذكير بالدفع',
'message_template_ar' => 'تذكير: يرجى سداد المستحقات المالية في أقرب وقت لتجنب إيقاف العضوية.',
'trigger_event' => 'facility.payment_reminder',
],
];
foreach ($templates as $t) {
$existing = $db->selectOne(
"SELECT id FROM sms_templates WHERE template_code = ?",
[$t['template_code']]
);
if (!$existing) {
$db->insert('sms_templates', [
'template_code' => $t['template_code'],
'name_ar' => $t['name_ar'],
'message_template_ar' => $t['message_template_ar'],
'trigger_event' => $t['trigger_event'],
'is_active' => 1,
]);
}
}
$triggers = [
[
'trigger_code' => 'player_enrolled',
'trigger_name_ar' => 'تسجيل لاعب في رياضة',
'event_name' => 'player.enrolled',
'channel' => 'player_push',
'recipient_type' => 'player',
],
[
'trigger_code' => 'player_booking_confirmed',
'trigger_name_ar' => 'تأكيد حجز لاعب',
'event_name' => 'player.booking_confirmed',
'channel' => 'player_push',
'recipient_type' => 'player',
],
[
'trigger_code' => 'player_booking_cancelled',
'trigger_name_ar' => 'إلغاء حجز لاعب',
'event_name' => 'player.booking_cancelled',
'channel' => 'player_push',
'recipient_type' => 'player',
],
[
'trigger_code' => 'player_free_time_entry',
'trigger_name_ar' => 'دخول وقت حر',
'event_name' => 'player.free_time_entry',
'channel' => 'player_push',
'recipient_type' => 'player',
],
[
'trigger_code' => 'player_transfer_result',
'trigger_name_ar' => 'نتيجة طلب نقل',
'event_name' => 'player.transfer_result',
'channel' => 'player_push',
'recipient_type' => 'player',
],
[
'trigger_code' => 'player_evaluation_completed',
'trigger_name_ar' => 'اكتمال تقييم اللاعب',
'event_name' => 'player.evaluation_completed',
'channel' => 'player_push',
'recipient_type' => 'player',
],
[
'trigger_code' => 'player_medical_expiring',
'trigger_name_ar' => 'انتهاء كشف طبي',
'event_name' => 'player.medical_expiring',
'channel' => 'player_push',
'recipient_type' => 'player',
],
[
'trigger_code' => 'coach_day_off_requested',
'trigger_name_ar' => 'طلب إجازة مدرب - إشعار الإدارة',
'event_name' => 'coach.day_off_requested',
'channel' => 'sms',
'recipient_type' => 'group_players',
],
[
'trigger_code' => 'coach_replacement_confirmed',
'trigger_name_ar' => 'تأكيد مدرب بديل',
'event_name' => 'coach.replacement_confirmed',
'channel' => 'sms',
'recipient_type' => 'coach',
],
[
'trigger_code' => 'coach_session_reminder',
'trigger_name_ar' => 'تذكير حصة مدرب',
'event_name' => 'coach.session_reminder',
'channel' => 'sms',
'recipient_type' => 'coach',
],
[
'trigger_code' => 'facility_attendance_absent',
'trigger_name_ar' => 'إشعار غياب مرفق',
'event_name' => 'facility.attendance_absent',
'channel' => 'player_push',
'recipient_type' => 'player',
],
[
'trigger_code' => 'facility_payment_reminder',
'trigger_name_ar' => 'تذكير دفع مرفق',
'event_name' => 'facility.payment_reminder',
'channel' => 'sms',
'recipient_type' => 'direct',
],
];
foreach ($triggers as $t) {
$existing = $db->selectOne(
"SELECT id FROM notification_triggers WHERE trigger_code = ?",
[$t['trigger_code']]
);
if (!$existing) {
$templateRow = $db->selectOne(
"SELECT id FROM sms_templates WHERE trigger_event = ? LIMIT 1",
[$t['event_name']]
);
$db->insert('notification_triggers', [
'trigger_code' => $t['trigger_code'],
'trigger_name_ar' => $t['trigger_name_ar'],
'event_name' => $t['event_name'],
'template_id' => $templateRow ? (int) $templateRow['id'] : null,
'is_active' => 1,
'channel' => $t['channel'],
'recipient_type' => $t['recipient_type'],
'priority' => 5,
]);
}
}
};
# Comprehensive Implementation Plan
## Features Gap Analysis & Implementation Roadmap
Based on analysis of 15 reference ERP system PDFs (4S Technology) compared against our existing Club ERP system.
---
# PART 1: GAP ANALYSIS
## Features We ALREADY Have (Fully Implemented)
| # | Feature | Our Module | Status |
|---|---------|-----------|--------|
| 1 | General Ledger & Chart of Accounts | Accounting | Full hierarchical COA, GL posting, multi-type accounts |
| 2 | Journal Entries (create/post/reverse) | Accounting | Manual + auto-posting from events |
| 3 | Trial Balance & Financial Statements | Accounting | TB, P&L, Balance Sheet, Consolidated BS |
| 4 | Cost Centers | Accounting | Full CRUD + budget allocation |
| 5 | Budgets (account + cost-center level) | Accounting | With variance analysis |
| 6 | Bank Accounts & Reconciliation | Accounting | Multi-bank, item-level reconciliation |
| 7 | Negotiable Instruments & Portfolios | Accounting | Checks, notes, portfolio management |
| 8 | Fiscal Year Management | Accounting | Create, close, multi-year |
| 9 | Period Closing | Accounting | Month open/close |
| 10 | Accounts Receivable / Payable | Accounting | AR/AP aging reports |
| 11 | Opening Balances | Accounting | Opening entries + snapshots |
| 12 | Multi-dimensional Accounting | Accounting | Configurable dimensions |
| 13 | Employee Management | HR | Full profiles, departments, job titles |
| 14 | Attendance Tracking | HR | Daily bulk entry, monthly views |
| 15 | Leave Management | HR | Request/approve, balance tracking, calendar |
| 16 | Payroll Processing | HR | Multi-period, calculate/approve/pay |
| 17 | Salary Structures & Components | HR | Template-based with components |
| 18 | Employment Contracts | HR | Create, renew, terminate |
| 19 | Social Insurance Records | HR | Form 1 & Form 6 generation |
| 20 | Tax Records | HR | Per-employee tracking |
| 21 | Employee Loans | HR | Request, approve, disburse |
| 22 | End of Service | HR | Calculate, approve, pay |
| 23 | Performance Reviews | HR | Cycles + individual reviews |
| 24 | Disciplinary Actions | HR | With appeals workflow |
| 25 | Inventory Items & Categories | Inventory | Full CRUD, hierarchical categories |
| 26 | Multiple Warehouses | Inventory | Multi-warehouse stock tracking |
| 27 | Stock Movements (in/out) | Inventory | Transaction history, balance tracking |
| 28 | Stock Transfers | Inventory | Inter-warehouse with approval |
| 29 | Stock Audits (Physical Count) | Inventory | Count, variance, adjustment approval |
| 30 | Supplier Management | Inventory | Master data, history |
| 31 | Purchase Orders (basic) | Inventory + Procurement | With approval workflow |
| 32 | Fixed Assets & Depreciation | Inventory | Asset register, depreciation |
| 33 | Low Stock Alerts | Inventory | Reorder level monitoring |
| 34 | Expiry Date Tracking | Inventory | Batch-level expiry |
| 35 | POS / Sales Transactions | Sales | Item selection, customer lookup |
| 36 | Sales Packages/Bundles | Sales | Pre-configured bundles |
| 37 | Sales Refunds | Sales | Partial/full refund |
| 38 | Sales Void | Sales | Transaction cancellation |
| 39 | Purchase Requisitions | Procurement | Create, submit, approve, convert |
| 40 | Goods Received Notes | Procurement | GRN with inspection |
| 41 | Vendor Invoices | Procurement | Entry, verification, approval |
| 42 | Three-Way Matching | Procurement | PR-GRN-Invoice matching |
| 43 | Vendor Payments | Procurement | With approval workflow |
| 44 | Returns to Vendor | Procurement | Full lifecycle tracking |
| 45 | Payment Processing (cash/check/card) | Payments | Multi-method, event-driven |
| 46 | Receipts | Receipts | Auto-generation, printing, voiding |
| 47 | Installment Plans | Installments | Auto-schedule, interest calculation |
| 48 | Cashier Queue | Cashier | Central payment processing |
| 49 | Work Schedules | HR | Template-based |
| 50 | Holidays | HR | Holiday master |
| 51 | Employee Documents | HR | Upload, verify, archive |
| 52 | Daily/Monthly Sales Reports | Sales | By day, month, item |
| 53 | Procurement Reports | Procurement | Volume, performance, overdue |
---
## Features We DON'T Have (Gaps)
### CATEGORY A: High-Impact Business Features (Core ERP Capabilities)
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| A1 | Configurable Movement Specifications Engine (26+ params per movement type) | Sales, Inventory, Purchasing | HIGH | HIGH |
| A2 | Multi-segment hierarchical item codes (XX-XX-XX-XX-XXXXXX) | Basic ERP, Inventory, Sales | MEDIUM | MEDIUM |
| A3 | Bill of Materials (BOM) / Sub-components for composite items | Basic ERP, Inventory | HIGH | HIGH |
| A4 | Supplier Price Quote solicitation & evaluation workflow | Purchasing | HIGH | MEDIUM |
| A5 | Side-by-side quote comparison across suppliers | Purchasing | HIGH | MEDIUM |
| A6 | Credit limit system for customers with auto-calculated allowed limit | Sales | HIGH | LOW |
| A7 | Supplier credit limit with auto-calculated allowed limit | Purchasing | HIGH | LOW |
| A8 | Documentary Credits (اعتمادات مستندية) | Banks | MEDIUM | HIGH |
| A9 | Letter of Guarantee management (خطابات ضمان) | Banks | MEDIUM | HIGH |
| A10 | Bank Loan management | Banks | MEDIUM | MEDIUM |
| A11 | Debit/Credit settlements across entities (cross-entity) | Purchasing, Banks | MEDIUM | MEDIUM |
| A12 | Sales Representative & Commission management | Sales | MEDIUM | MEDIUM |
| A13 | Multi-currency transactions (full support) | Basic ERP | MEDIUM | HIGH |
| A14 | Hijri/Gregorian dual calendar | Basic ERP | LOW | MEDIUM |
| A15 | Franchise-based supplier account calculation (3 methods) | Purchasing | LOW | MEDIUM |
### CATEGORY B: Fixed Assets Enhancements
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| B1 | Asset categories with configurable depreciation methods per category | Fixed Assets | HIGH | MEDIUM |
| B2 | Asset location/site tracking with transfers | Fixed Assets | MEDIUM | LOW |
| B3 | Asset custody tracking (عهدة) with employee assignment | Fixed Assets | HIGH | LOW |
| B4 | Asset revaluation | Fixed Assets | MEDIUM | MEDIUM |
| B5 | Asset insurance tracking per asset | Fixed Assets | LOW | LOW |
| B6 | Multiple depreciation methods (straight-line, declining balance, production-units, sum-of-years) | Fixed Assets | HIGH | MEDIUM |
| B7 | Asset improvement/addition tracking | Fixed Assets | MEDIUM | LOW |
| B8 | Partial disposal (sell/scrap portion of asset) | Fixed Assets | LOW | MEDIUM |
### CATEGORY C: HR & Payroll Enhancements
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| C1 | Overtime system with configurable rates per period/type | HR, Wages | HIGH | MEDIUM |
| C2 | Shift management with rotation and assignment | Attendance | MEDIUM | MEDIUM |
| C3 | Fingerprint/biometric device integration | Attendance | HIGH | HIGH |
| C4 | Attendance violation auto-detection (late, early leave, absence) | Attendance | HIGH | MEDIUM |
| C5 | Permission requests (hourly leaves) | Attendance | MEDIUM | LOW |
| C6 | Mission/travel tracking | Attendance | LOW | LOW |
| C7 | Overtime request & approval workflow | Attendance | MEDIUM | LOW |
| C8 | Social insurance auto-calculation (employer + employee shares with legal limits) | Wages | HIGH | MEDIUM |
| C9 | Progressive tax bracket auto-calculation | Wages | HIGH | MEDIUM |
| C10 | Configurable allowance/deduction formulas | Wages | MEDIUM | MEDIUM |
| C11 | Employee bonus system with configurable criteria | Wages | MEDIUM | LOW |
| C12 | Salary hold/release mechanism | Wages | LOW | LOW |
| C13 | Retroactive salary adjustments | Wages | MEDIUM | MEDIUM |
| C14 | Monthly salary comparison report (variance analysis) | Wages | LOW | LOW |
### CATEGORY D: Purchasing & Procurement Enhancements
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| D1 | Internal Purchase Request → Quote → Evaluation → PO full cycle | Purchasing | HIGH | MEDIUM |
| D2 | Purchase delay/overdue delivery tracking | Purchasing | HIGH | LOW |
| D3 | Item price deviation monitoring & alerts | Purchasing | MEDIUM | LOW |
| D4 | Item vs. budget comparison for purchases | Purchasing | MEDIUM | LOW |
| D5 | Daily item purchases analysis | Purchasing | LOW | LOW |
| D6 | Supplier debt aging report | Purchasing | MEDIUM | LOW |
| D7 | Payment scheduling/installments for suppliers (جدولة) | Purchasing | MEDIUM | MEDIUM |
| D8 | Specific PO balance tracking (qty requested vs received vs remaining, delivery %) | Purchasing | HIGH | LOW |
| D9 | Items linked to suppliers cross-reference report | Purchasing | LOW | LOW |
| D10 | Movement approval workflow (بيان اعتماد الحركات) | Purchasing | MEDIUM | MEDIUM |
### CATEGORY E: Banking Enhancements
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| E1 | Check deposit and collection lifecycle tracking (issued→deposited→collected/bounced) | Banks | HIGH | MEDIUM |
| E2 | Check endorsement/transfer (تظهير) | Banks | MEDIUM | MEDIUM |
| E3 | Payment note status update with portfolio-based batch processing | Banks, Purchasing | MEDIUM | LOW |
| E4 | Paper/Check multi-criteria inquiry (by serial, number, bank, date, status) | Purchasing | MEDIUM | LOW |
| E5 | Daily cash movement report for safes/treasuries | Banks | MEDIUM | LOW |
| E6 | Payment notes analysis (summary: direct vs indirect, checks vs promissory) | Purchasing | LOW | LOW |
### CATEGORY F: Sales Enhancements
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| F1 | Customer discount rates (per-customer per-item pricing) | Sales | MEDIUM | MEDIUM |
| F2 | Sales returns with automatic warehouse stock update | Sales | HIGH | LOW |
| F3 | Multiple configurable sales movement types (invoice, return, quotation, etc.) | Sales | MEDIUM | MEDIUM |
| F4 | Sales collection notes/checks from customers | Sales | MEDIUM | MEDIUM |
| F5 | Customer account statement (detailed and summary) | Sales | HIGH | LOW |
| F6 | Customer current position report (all customers snapshot) | Sales | MEDIUM | LOW |
| F7 | Customer movement tracking in period | Sales | LOW | LOW |
### CATEGORY G: Inventory Enhancements
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| G1 | Opening balance by item per warehouse (inventory initialization) | Inventory | HIGH | LOW |
| G2 | Item price history tracking (first, current, average purchase prices) | Inventory | MEDIUM | LOW |
| G3 | Supplier-to-item code mapping (supplier part numbers) | Inventory | LOW | LOW |
| G4 | Color/variant/attribute tracking per item group | Inventory | MEDIUM | MEDIUM |
| G5 | Multiple units per item with conversion factors | Inventory | HIGH | MEDIUM |
| G6 | Contractor entity type (separate from customer/supplier) | Inventory | LOW | LOW |
| G7 | Previous years' balance auto-carry-forward | Inventory | MEDIUM | LOW |
| G8 | Configurable stock movement types | Inventory | MEDIUM | MEDIUM |
### CATEGORY H: Reporting & Export Enhancements
| # | Feature | Source PDF | Priority | Complexity |
|---|---------|-----------|----------|-----------|
| H1 | Multi-format report export (Excel, PDF, CSV, HTML) | All PDFs | HIGH | MEDIUM |
| H2 | Report designer with configurable parameters | All PDFs | MEDIUM | HIGH |
| H3 | Report print preview with zoom | All PDFs | LOW | LOW |
| H4 | Supplier account statement (detailed and summary formats) | Purchasing | HIGH | LOW |
| H5 | T-account format for supplier/customer reports | Purchasing, Sales | LOW | LOW |
---
# PART 2: IMPLEMENTATION PLAN
## Phase 1: Quick Wins (Low Complexity, High Impact)
**Timeline: 2-3 weeks**
**Focus: Features that add significant value with minimal development effort**
### 1.1 Customer & Supplier Credit Limits (A6, A7)
- Add `credit_limit` column to members/suppliers tables
- Add `allowed_limit` computed field (credit_limit - total_payments)
- Add warning/block when transaction would exceed limit
- UI: Show credit status on member/supplier profile
### 1.2 Purchase Delay Tracking (D2)
- Add `expected_delivery_date` to purchase_order_items
- Create overdue delivery report (filter by date, supplier, item)
- Dashboard widget for overdue items count
### 1.3 PO Balance Tracking (D8)
- Report showing: qty_ordered vs qty_received vs qty_remaining per PO
- Delivery percentage calculation
- Filter by supplier, date range, item group
### 1.4 Customer Account Statement (F5)
- Detailed report: date, document type, description, debit, credit, running balance
- Summary report: totals only
- Filter by date range, member code
### 1.5 Supplier Account Statement (H4)
- Same as F5 but for suppliers
- Include purchase orders, invoices, payments, returns
### 1.6 Sales Returns with Warehouse Update (F2)
- Enhance refund process to auto-update inventory stock
- Create stock movement record on refund
### 1.7 Inventory Opening Balances (G1)
- Screen to enter opening stock quantities per item per warehouse
- Generate stock movement records for initialization
- Useful for new warehouse setup or year-start
### 1.8 Asset Custody Tracking (B3)
- Add `custodian_employee_id` and `location` to asset_registers
- Asset assignment/transfer form
- Report: assets by employee/location
### 1.9 Asset Location Tracking (B2)
- Add `site_location` field to assets
- Transfer form to move asset between locations
- Asset location history log
---
## Phase 2: Financial & Banking Enhancements (Medium Complexity)
**Timeline: 3-4 weeks**
**Focus: Banking operations and financial instruments**
### 2.1 Check Lifecycle Status Tracking (E1)
- Expand negotiable_instruments with status workflow: issued → deposited → collected/bounced/endorsed
- Status transition actions with date tracking
- Status history log table
### 2.2 Check Endorsement (E2)
- Add endorsement capability (transfer check to another party)
- Track endorsement chain
- Update beneficiary on endorsement
### 2.3 Payment Note Portfolio Batch Processing (E3)
- Batch status update for multiple papers in a portfolio
- Select portfolio → select papers → apply status change
- Bulk operations support
### 2.4 Paper/Check Multi-Criteria Inquiry (E4)
- Advanced search screen for all negotiable instruments
- Filter by: serial, check number, bank, date range (issue/maturity), status, party, type
- Results grid with sort/export
### 2.5 Daily Cash Movement Report (E5)
- Safe/treasury daily movement summary
- Opening balance + inflows - outflows = closing balance
- Per-safe breakdown
### 2.6 Debit/Credit Cross-Entity Settlements (A11)
- Settlement transactions between suppliers, customers, banks, safes
- Purpose types: payment, advance, adjustment
- Auto-post journal entries for both parties
### 2.7 Bank Loan Management (A10)
- Loan master: bank, amount, rate, term, start date
- Amortization schedule generation
- Payment tracking against schedule
- Outstanding balance reporting
---
## Phase 3: Procurement Cycle Enhancement (Medium Complexity)
**Timeline: 3-4 weeks**
**Focus: Complete the procurement cycle with quote management**
### 3.1 Supplier Price Quote Request (A4)
- New entity: `supplier_price_quotes`
- Create quote request linked to purchase requisition
- Send to multiple suppliers (track which suppliers were solicited)
- Supplier response entry: per-item pricing, delivery terms
### 3.2 Quote Evaluation & Comparison (A5)
- Side-by-side comparison screen
- Show all quotes for same PR on one page
- Per-item comparison: price, delivery time, terms
- Winner selection with justification
### 3.3 Quote-to-PO Conversion (D1)
- "Issue Purchase Order" action from evaluation screen
- Auto-populate PO from winning quote
- Link chain: PR → Quotes → Evaluation → PO → GRN → Invoice
### 3.4 Supplier Payment Scheduling (D7)
- Payment plans for large invoices
- Schedule: amount, due date, method (cash/check)
- Track paid vs pending installments
- Alert on upcoming/overdue payments
### 3.5 Item Price Deviation Monitoring (D3)
- Track standard/expected price per item per supplier
- Alert when invoice price deviates beyond threshold
- Deviation report: item, supplier, expected, actual, variance %
### 3.6 Purchase vs Budget Comparison (D4)
- Link purchase categories to budget accounts
- Report: budgeted amount vs actual purchases YTD
- Variance highlighting (over/under budget)
---
## Phase 4: HR & Payroll Enhancements (Medium-High Complexity)
**Timeline: 4-5 weeks**
**Focus: Attendance automation, overtime, and payroll calculation improvements**
### 4.1 Overtime System (C1)
- Overtime types: normal, holiday, night
- Configurable rate multipliers per type (1.5x, 2x, 3x)
- Overtime request submission and approval workflow
- Auto-calculate overtime pay in payroll
### 4.2 Attendance Violation Detection (C4)
- Define rules: grace period, late threshold, early leave threshold
- Auto-detect violations from attendance records vs work schedule
- Violation types: late arrival, early departure, absence, unauthorized
- Penalty rules: deductions after X violations per month
### 4.3 Permission Requests - Hourly Leaves (C5)
- Short-duration leave requests (hours, not days)
- Approval workflow
- Deduct from daily attendance hours
- Monthly summary of permissions taken
### 4.4 Social Insurance Auto-Calculation (C8)
- Egyptian social insurance rules engine
- Basic salary cap, variable salary cap
- Employer share % + Employee share %
- Auto-calculate per payroll run
- Generate Form 2 (monthly contribution)
### 4.5 Progressive Tax Calculation (C9)
- Tax brackets configuration (Egyptian tax law)
- Exemptions and deductions configuration
- Annual tax calculation with monthly distribution
- Year-end reconciliation
### 4.6 Shift Management (C2)
- Shift definitions: start time, end time, break times
- Shift rotation patterns (weekly, bi-weekly, monthly)
- Employee-to-shift assignment
- Shift calendar view
### 4.7 Overtime Request & Approval (C7)
- Pre-approval for planned overtime
- Approval workflow (supervisor → HR)
- Link approved overtime to attendance records
---
## Phase 5: Advanced Inventory & Sales Features (High Complexity)
**Timeline: 5-6 weeks**
**Focus: BOM, multi-unit, movement specifications**
### 5.1 Bill of Materials (BOM) (A3)
- New tables: `bill_of_materials`, `bom_components`
- Define composite items with their sub-components
- Component quantities and units
- BOM versioning
- Auto-deduct components on sale/production of parent item
- Component cost roll-up for pricing
### 5.2 Multiple Units per Item with Conversion (G5)
- Table: `item_units` (item_id, unit_name, conversion_factor, is_base_unit)
- Each item can have: piece, box (12 pieces), carton (48 pieces), etc.
- All movements specify unit; system converts to base unit for stock
- Purchase in one unit, sell in another
### 5.3 Item Color/Variant Tracking (G4)
- Configurable attributes per item category
- Attribute types: color, size, material, etc.
- Stock tracked per variant combination
- Item variant matrix (size × color → stock)
### 5.4 Configurable Stock Movement Types (G8)
- User-definable movement types beyond in/out/transfer
- Each type has: name, direction (in/out/neutral), affects_stock, posts_gl, requires_approval
- Movement specifications per type
- Auto-numbering per type
### 5.5 Sales Representative & Commission (A12)
- Sales rep master data
- Rep assignment to customers/transactions
- Commission rates: flat, percentage, tiered (by volume)
- Commission calculation and reporting
- Commission payment tracking
### 5.6 Per-Customer Pricing (F1)
- Customer-specific discount rates per item/category
- Price lists assignable to customer groups
- Priority: customer price > group price > default price
- Discount reason tracking
### 5.7 Sales Collection (Checks from Customers) (F4)
- Record checks/notes received from customers
- Collection portfolio management
- Status tracking: received → deposited → collected/bounced
- Link to customer account
---
## Phase 6: Configurable Movement Engine & Multi-Currency (Highest Complexity)
**Timeline: 6-8 weeks**
**Focus: System-wide configurability engine**
### 6.1 Movement Specifications Engine (A1)
This is the most complex feature - a meta-configuration system that controls behavior of all transaction types across Sales, Inventory, and Purchasing.
**Core concept:** Each "movement type" (e.g., "Sales Invoice", "Purchase Return", "Stock Transfer") has ~26 configurable parameters that control its behavior:
- Auto-numbering (start number, sequence)
- Whether it affects stock (and direction)
- Whether it posts to GL
- Which accounts to post to
- Whether it requires approval
- Which fields are mandatory
- Which party types are allowed (customer, supplier, warehouse)
- Whether it creates AR/AP entries
- Tax handling
- Discount handling
- Print template selection
- Currency handling
**Implementation approach:**
- `movement_types` table: defines all available movement types
- `movement_specifications` table: key-value parameters per movement type
- Engine class that reads specs and controls movement behavior
- UI for admin to configure each movement type's parameters
- All Sales/Inventory/Procurement modules consume specs from this engine
### 6.2 Multi-Currency Full Support (A13)
- Currency master with exchange rates (daily rates)
- Transaction currency vs. reporting currency
- Multi-currency journal entries
- Gain/loss on exchange rate differences
- Reports in both transaction and reporting currencies
- Customer/supplier balances in their currency
- Period-end revaluation of foreign currency balances
### 6.3 Multi-Segment Item Codes (A2)
- Configurable code structure (segments, lengths, separators)
- Hierarchical: Category - Sub-category - Group - Sub-group - Sequence
- Auto-generate next code within segment
- Search by any segment level
- Reports groupable by any segment
---
## Phase 7: Advanced Financial Features (High Complexity)
**Timeline: 4-5 weeks**
**Focus: Documentary credits, guarantees, advanced banking**
### 7.1 Documentary Credits (A8)
- LC master: issuing bank, beneficiary, amount, currency, terms
- LC lifecycle: opened → shipped → documents presented → paid → closed
- Margin tracking (deposit held by bank)
- Expense allocation to landed cost
- Link to purchase orders and vendor invoices
### 7.2 Letter of Guarantee (A9)
- Guarantee master: bank, beneficiary, amount, type (tender/performance/advance)
- Status lifecycle: requested → issued → active → released/called
- Margin/collateral tracking
- Expiry monitoring and renewal
- Commission/fee tracking
### 7.3 Fixed Asset Enhancements (B1, B4, B6, B7)
- Asset categories with per-category depreciation method
- Multiple methods: straight-line, declining balance, sum-of-years, units-of-production
- Asset revaluation (upward/downward with journal entry)
- Asset improvement/addition (capitalize additional costs)
- Asset insurance tracking
### 7.4 Report Export Engine (H1)
- Universal export functionality for all reports
- Formats: Excel (XLSX), PDF, CSV, HTML
- Configurable columns per export
- Scheduled report generation (email delivery)
---
## Phase 8: Reporting & Analytics Layer
**Timeline: 3-4 weeks**
**Focus: Comprehensive reporting across all modules**
### 8.1 Enhanced Purchasing Reports
- Purchase volume by supplier/item/period
- Supplier performance scoring (delivery time, price variance, quality)
- Purchase request follow-up status
- Cost center expense analysis
- Item purchases by supplier level
### 8.2 Enhanced Sales Reports
- Customer current position (all customers snapshot)
- Customer movement tracking in period
- Sales by rep with commission summary
- Item margin analysis
- Customer lifetime value
### 8.3 Enhanced Inventory Reports
- Item price history (first, current, average)
- Stock valuation by method (FIFO, weighted average, LIFO)
- Dead stock / slow-moving items
- Stock turnover analysis
- Supplier vs item cross-reference
### 8.4 Enhanced Financial Reports
- Cash flow statement
- Payment notes analysis (direct vs indirect, type breakdown)
- Treasury position report
- Budget vs actual across all departments
- Monthly comparison report (this month vs last month vs same month last year)
### 8.5 Dashboard Enhancements
- Executive dashboard: revenue, expenses, cash position, AR/AP aging
- Procurement dashboard: pending approvals, overdue deliveries, budget utilization
- HR dashboard: headcount, attendance %, leave utilization, payroll cost trends
- Inventory dashboard: stock value, movement velocity, expiry warnings
---
# PART 3: IMPLEMENTATION PRIORITIES
## Recommended Execution Order
```
Phase 1 (Quick Wins) ← START HERE: 2-3 weeks
Phase 2 (Banking) ← 3-4 weeks
Phase 3 (Procurement Cycle) ← 3-4 weeks
Phase 4 (HR Enhancements) ← 4-5 weeks
Phase 5 (Inventory & Sales) ← 5-6 weeks
Phase 6 (Movement Engine) ← 6-8 weeks (most complex)
Phase 7 (Advanced Financial) ← 4-5 weeks
Phase 8 (Reporting) ← 3-4 weeks
```
**Total estimated timeline: 30-39 weeks (~7-9 months)**
---
## Critical Path Items
These features have dependencies that affect other features:
1. **Movement Specifications Engine (A1)** - Many other features reference configurable movement types. Building this engine first would make phases 3-5 cleaner, but it's also the most complex. Recommendation: build a simplified version first, enhance later.
2. **Multiple Units per Item (G5)** - Affects all inventory movements, sales, and procurement. Should be implemented before BOM.
3. **Multi-Currency (A13)** - Touches nearly every financial transaction. Can be implemented incrementally (start with supplier/customer level, then transactions, then reporting).
4. **Credit Limits (A6, A7)** - Simple to add, but must be enforced across all transaction entry points (sales, procurement, cashier).
---
## Database Schema Additions (Key Tables Needed)
### Phase 1
```sql
-- Customer/Supplier credit limits (add columns to existing tables)
ALTER TABLE members ADD credit_limit DECIMAL(15,2) DEFAULT 0;
ALTER TABLE suppliers ADD credit_limit DECIMAL(15,2) DEFAULT 0;
-- PO delivery tracking
ALTER TABLE purchase_order_items ADD expected_delivery_date DATE NULL;
-- Asset custody
ALTER TABLE asset_registers ADD custodian_employee_id INT NULL;
ALTER TABLE asset_registers ADD site_location VARCHAR(255) NULL;
CREATE TABLE asset_custody_history (...);
```
### Phase 2
```sql
CREATE TABLE negotiable_instrument_status_history (...);
CREATE TABLE cross_entity_settlements (...);
CREATE TABLE bank_loans (...);
CREATE TABLE bank_loan_schedule (...);
```
### Phase 3
```sql
CREATE TABLE supplier_price_quotes (...);
CREATE TABLE supplier_quote_items (...);
CREATE TABLE quote_evaluations (...);
CREATE TABLE supplier_payment_schedules (...);
CREATE TABLE item_price_history (...);
```
### Phase 4
```sql
CREATE TABLE hr_overtime_types (...);
CREATE TABLE hr_overtime_requests (...);
CREATE TABLE hr_attendance_violations (...);
CREATE TABLE hr_permission_requests (...);
CREATE TABLE hr_shifts (...);
CREATE TABLE hr_shift_assignments (...);
CREATE TABLE hr_insurance_config (...);
CREATE TABLE hr_tax_brackets (...);
```
### Phase 5
```sql
CREATE TABLE bill_of_materials (...);
CREATE TABLE bom_components (...);
CREATE TABLE item_units (...);
CREATE TABLE item_attributes (...);
CREATE TABLE item_attribute_values (...);
CREATE TABLE item_variants (...);
CREATE TABLE sales_representatives (...);
CREATE TABLE sales_commissions (...);
CREATE TABLE customer_price_lists (...);
CREATE TABLE customer_item_prices (...);
```
### Phase 6
```sql
CREATE TABLE movement_types (...);
CREATE TABLE movement_specifications (...);
CREATE TABLE currencies (...);
CREATE TABLE exchange_rates (...);
CREATE TABLE item_code_segments (...);
```
### Phase 7
```sql
CREATE TABLE documentary_credits (...);
CREATE TABLE lc_documents (...);
CREATE TABLE letters_of_guarantee (...);
CREATE TABLE asset_categories (...);
CREATE TABLE asset_revaluations (...);
CREATE TABLE asset_improvements (...);
```
---
## Risk Assessment
| Risk | Impact | Mitigation |
|------|--------|-----------|
| Movement engine complexity | Could delay Phase 6 significantly | Start with simplified version; enhance iteratively |
| Multi-currency GL impact | Affects all financial reports | Implement incrementally; start with transaction recording, then reporting |
| BOM stock deduction performance | Complex queries for nested BOMs | Limit BOM depth; cache component lists |
| Credit limit enforcement gaps | Users bypass limits via different screens | Centralize limit check in a service class called by all transaction entry points |
| Attendance device integration | Hardware-dependent, varied protocols | Abstract via interface; support common protocols (ZKTeco, etc.) |
| Data migration for new features | Existing data needs mapping to new structures | Write migration scripts with safe defaults; don't block existing operations |
---
## Success Metrics per Phase
| Phase | Key Metric |
|-------|-----------|
| 1 | Credit limits actively preventing over-credit transactions; PO delivery tracking in use |
| 2 | 90%+ of check lifecycle managed in system vs. manual tracking |
| 3 | Full PR→Quote→Eval→PO cycle operational; deviation alerts active |
| 4 | Overtime auto-calculated in payroll; violation detection reducing manual review |
| 5 | BOM items selling correctly with component deduction; multi-unit purchases flowing |
| 6 | All new movement types configurable without code changes |
| 7 | Documentary credits fully tracked; multi-method depreciation running |
| 8 | Executive dashboards replacing manual Excel reports |
---
---
# PART 4: النشاط الرياضي (SPORTS ACTIVITY) MODULE ENHANCEMENTS
## Current State Assessment
The sports/activity ecosystem spans 16+ modules that already cover most player and admin requirements:
| Requirement | Module | Status |
|-------------|--------|--------|
| Subscribe to sport | ActivitySubscriptions | ✓ Full monthly billing, group-type pricing |
| Evaluation & group placement | TrainingGroups + Sessions | ✓ Age/gender/skill/trainer/time/size (private=1, small=2-5, group=6-15, team=16+) |
| Switch groups | TrainingGroups | ✓ transferPlayer endpoint with status tracking |
| Book grounds for private matches | Reservations | ✓ Facility conflict detection, multi-booker-type, calendar |
| Free-time entry (pool, etc.) | PlayerAffairs + PoolManagement | ✓ free_swim booking type, access_window_minutes, attendance |
| Membership pricing | Pricing + ActivityPricing | ✓ Tiers by player_type and group_type |
| Invitation books (carnets) | Carnets | ✓ QR code, print, replacement flow |
| Medical forms with scans & expiry | PlayerAffairs | ✓ certificate_type, exam_date/expiry, document_id, approval workflow |
| Pool grid system (cells/lanes) | FacilityGrids | ✓ Generic grid: pool/court/generic, zones, H/V selection |
| Academy/trainer agreements | AcademyContracts | ✓ revenue_share/fixed_rent/hybrid, settlement generation |
| Monthly pool plans (per-day, per-hour) | FacilityGrids | ✓ plan_month, day_of_week, start/end time per zone |
| Facility financial dashboard | FacilityDashboards + PoolManagement | ✓ Day/week/month/custom filters, revenue stats |
| Trainer/trainee attendance | Sessions | ✓ session_attendance, makeup credits |
| Notifications framework | Notifications | ✓ SMS templates, bulk, logs |
| Coach management | Coaches | ✓ Employment types, payment models, availability |
## Gaps to Address
### GAP S1: Carnet Guest Entry & Usage Tracking (HIGH PRIORITY)
**Problem:** Carnets are issued to members but there's no mechanism to track when a guest actually uses an invitation entry. Members get an "invitation book" — they should be able to bring friends who can use club facilities (free-time only: pool free_swim, court bookings, ping pong, etc.) using their carnet allocation.
**Requirements:**
- Each carnet has a configurable number of guest entries (e.g., 10 invitations per carnet)
- When a guest enters using a member's carnet, the system records: which member's carnet, guest name/phone, entry time, which facility, which activity
- Guest entries are limited to free-time/recreational activities only (no academy/training)
- Guest pricing may differ from member pricing
- Members can see their remaining invitation balance
- Admin can view all guest entries per carnet, per member, per date range
**Implementation:**
```sql
-- Add invitation tracking to carnets
ALTER TABLE carnets ADD COLUMN total_invitations INT UNSIGNED NOT NULL DEFAULT 10;
ALTER TABLE carnets ADD COLUMN used_invitations INT UNSIGNED NOT NULL DEFAULT 0;
-- Guest entry log
CREATE TABLE carnet_guest_entries (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
carnet_id BIGINT UNSIGNED NOT NULL,
member_id BIGINT UNSIGNED NOT NULL,
guest_name VARCHAR(200) NOT NULL,
guest_phone VARCHAR(30) NULL,
guest_national_id VARCHAR(20) NULL,
entry_date DATE NOT NULL,
entry_time TIME NOT NULL,
exit_time TIME NULL,
facility_id BIGINT UNSIGNED NULL,
activity_type VARCHAR(50) NOT NULL COMMENT 'free_swim, court_booking, recreation',
reservation_id BIGINT UNSIGNED NULL COMMENT 'linked reservation if applicable',
amount_paid DECIMAL(10,2) NOT NULL DEFAULT 0,
payment_id BIGINT UNSIGNED NULL,
notes TEXT NULL,
recorded_by BIGINT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_cge_carnet (carnet_id),
INDEX idx_cge_member (member_id),
INDEX idx_cge_date (entry_date),
CONSTRAINT fk_cge_carnet FOREIGN KEY (carnet_id) REFERENCES carnets(id),
CONSTRAINT fk_cge_member FOREIGN KEY (member_id) REFERENCES members(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
**Module changes:**
- Carnets module: add `guest-entry` route, controller action, view
- Reservations module: add `booker_type = 'carnet_guest'` support
- PoolManagement: allow `carnet_guest` booking type for free_swim slots
- Pricing: guest-via-carnet fee configuration
---
### GAP S2: Automated Notification Triggers (HIGH PRIORITY)
**Problem:** The Notifications module has the SMS sending infrastructure but no automated triggers. Players/members should receive notifications about session changes, medical expiry, payment reminders, etc.
**Requirements:**
- Medical certificate expiry approaching (7 days, 3 days, expired)
- Training session cancelled/rescheduled
- Subscription payment due reminder (5 days before, on due date, overdue)
- Group change/transfer confirmation
- Reservation confirmation/reminder (day before)
- Carnet invitation balance low (2 remaining)
- Academy announcements
- Membership renewal reminder
- Attendance summary (weekly/monthly)
- Pool schedule change notification
**Implementation:**
```sql
CREATE TABLE notification_triggers (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
trigger_code VARCHAR(50) NOT NULL UNIQUE,
trigger_name_ar VARCHAR(200) NOT NULL,
event_name VARCHAR(100) NOT NULL COMMENT 'EventBus event to listen to',
template_id BIGINT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
channel VARCHAR(20) NOT NULL DEFAULT 'sms' COMMENT 'sms, push, both',
config_json JSON NULL COMMENT 'timing config: days_before, repeat, etc.',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_nt_event (event_name),
INDEX idx_nt_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE notification_subscriptions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
player_id BIGINT UNSIGNED NULL,
member_id BIGINT UNSIGNED NULL,
trigger_code VARCHAR(50) NOT NULL,
is_opted_in TINYINT(1) NOT NULL DEFAULT 1,
channel_preference VARCHAR(20) NULL DEFAULT 'sms',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ns_player (player_id),
INDEX idx_ns_member (member_id),
INDEX idx_ns_trigger (trigger_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
**EventBus listeners to add in Notifications/bootstrap.php:**
- `session.cancelled` → notify enrolled players
- `session.rescheduled` → notify enrolled players
- `medical.expiry_approaching` → notify player (cron-driven)
- `subscription.payment_due` → remind player (cron-driven)
- `group.player_transferred` → notify player of new group
- `reservation.confirmed` → confirm to booker
- `reservation.reminder` → day-before reminder (cron-driven)
- `carnet.low_balance` → notify member (triggered on usage)
- `membership.renewal_due` → remind member (cron-driven)
**Cron job additions:** Add scheduled checks in `cli.php cron` for time-based triggers (medical expiry, payment due dates, reservation reminders).
---
### GAP S3: Enhanced Facility Dashboard with Full Date Range & PDF Export (MEDIUM PRIORITY)
**Problem:** Current FacilityDashboards only supports day/week/month/custom filters. The admin wants: Daily, Weekly, Monthly, Yearly, 5 Years filters + downloadable PDF report.
**Requirements:**
- Add "yearly" and "5 years" filter options
- Financial details: total revenue, revenue by activity type, revenue by booker type, revenue by payment method
- User details: total unique visitors, new vs returning, members vs guests, peak hours
- Occupancy rate by time slot
- Coach utilization stats
- PDF export of the full dashboard with the current filter applied
**Implementation:**
- Extend `FacilityDashboardController@show` with `yearly` and `5years` filter modes
- Add `FacilityDashboardController@exportPdf` endpoint
- Create PDF generation service using the existing template engine (render HTML → convert to PDF using wkhtmltopdf or dompdf)
- Add revenue breakdown by: activity_type, booker_type, payment_method
- Add user analytics: unique visitors, member vs non-member ratio, peak hours heatmap
**Routes to add:**
```php
['GET', '/facilities/{id:\d+}/dashboard/export', 'FacilityDashboards\Controllers\FacilityDashboardController@exportPdf', ['auth'], 'facility.dashboard'],
```
**Schema for pool-specific dashboard (extend PoolDashboardController similarly):**
- Same extended filters (yearly, 5-year)
- Lane-specific revenue
- Academy session revenue vs free_swim revenue
- Coach performance (sessions taught, attendance rate)
- PDF export
---
### GAP S4: Membership-Based Access Control & Members-Only Time Slots (MEDIUM PRIORITY)
**Problem:** Pool/facility time slots should distinguish between "members-only" times and "open" times. Members get special pricing in all activities. The system needs to enforce this at booking time.
**Requirements:**
- Facilities can mark specific time slots as "members_only"
- During members-only time, only members (with active membership + valid card) can enter/book
- Members get member-specific pricing across all activities (pool, courts, recreation)
- Non-members pay higher rates during open times
- Carnet guests follow guest pricing (configurable: same as member or separate tier)
**Implementation:**
```sql
ALTER TABLE facility_time_slots ADD COLUMN access_type VARCHAR(20) NOT NULL DEFAULT 'open' COMMENT 'open, members_only, vip';
ALTER TABLE facility_time_slots ADD COLUMN member_price DECIMAL(10,2) NULL COMMENT 'Override price for members';
ALTER TABLE facility_time_slots ADD COLUMN guest_price DECIMAL(10,2) NULL COMMENT 'Override price for carnet guests';
```
**Service changes:**
- `FacilityPricingService`: add membership check → apply member/guest/public pricing tier
- `ReservationService@store`: validate access_type against booker_type
- `PoolBookingController@store`: check membership validity for members-only slots
---
### GAP S5: Pool Grid Drag-Select UI Enhancement (MEDIUM PRIORITY)
**Problem:** Admin wants to select multiple cells using mouse drag, or select full lanes horizontally/vertically, then assign trainer/academy/trainees to them. The backend supports this (zone_selection_type: cells/row/column/all + zone_selection_json) but the frontend needs an interactive drag-select UI.
**Requirements:**
- Visual grid representation of the pool (rows × columns matching physical dimensions)
- Click to select single cell
- Click + drag to select multiple cells (rectangular selection)
- Click lane header to select full horizontal lane
- Click column header to select full vertical lane
- Selected cells highlight in a chosen color
- After selection: modal/form to assign trainer, academy, age group, gender, max occupants, time slot
- Real-time visual feedback showing which zones are occupied/free/conflicting
- Color coding by academy/trainer/activity type
**Implementation:**
- New JavaScript component in FacilityGrids/Views using vanilla JS (no framework — matches project convention)
- Mouse events: `mousedown` → start selection, `mousemove` → extend selection, `mouseup` → finalize
- AJAX POST to existing `/facility-grids/{id}/schedules` endpoint
- Conflict detection via existing `/facility-grids/{id}/schedules/conflicts` API
---
### GAP S6: Player Evaluation & Skill Rating System (LOW PRIORITY)
**Problem:** Sessions module has `type = 'assessment'` but there's no structured scoring/rating model to formally evaluate players and recommend group placement.
**Requirements:**
- Configurable evaluation criteria per sport (speed, technique, endurance, teamwork, etc.)
- Numeric scoring (1-10) or categorical (beginner/intermediate/advanced/elite)
- Evaluation history per player per discipline
- Coach submits evaluation → system suggests group placement based on score + age + gender preferences
- Player can view their progression over time
**Implementation:**
```sql
CREATE TABLE evaluation_criteria (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
discipline_id BIGINT UNSIGNED NOT NULL,
criterion_name_ar VARCHAR(200) NOT NULL,
criterion_name_en VARCHAR(200) NULL,
max_score INT NOT NULL DEFAULT 10,
weight DECIMAL(3,2) NOT NULL DEFAULT 1.00,
sort_order INT NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_ec_discipline (discipline_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE player_evaluations (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
player_id BIGINT UNSIGNED NOT NULL,
discipline_id BIGINT UNSIGNED NOT NULL,
session_id BIGINT UNSIGNED NULL COMMENT 'assessment session if applicable',
evaluator_coach_id BIGINT UNSIGNED NULL,
evaluation_date DATE NOT NULL,
overall_score DECIMAL(5,2) NULL,
skill_level VARCHAR(20) NULL COMMENT 'beginner, intermediate, advanced, elite',
recommended_group_type VARCHAR(20) NULL,
recommended_group_id BIGINT UNSIGNED NULL,
scores_json JSON NOT NULL COMMENT '[{criterion_id, score, notes}]',
coach_notes TEXT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft' COMMENT 'draft, submitted, approved',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_pe_player (player_id),
INDEX idx_pe_discipline (discipline_id),
INDEX idx_pe_date (evaluation_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
---
### GAP S7: Pool Monthly Plan Naming & Cloning (LOW PRIORITY)
**Problem:** Monthly plans exist via `plan_month` column but there's no formal "plan" entity that groups all schedules for a month and gives it a name like "خطة حمام السباحة - يناير". Also no ability to clone last month's plan as a starting point for the new month.
**Requirements:**
- Named monthly plans: "Swimming Pool Plan - January 2026"
- Plan status: draft → active → archived
- Clone plan from previous month (copy all zone schedules)
- Diff view: what changed between this month and last month
- Working days configuration per plan (exclude Fridays or custom)
- Day/night hours definition per day of week
**Implementation:**
```sql
CREATE TABLE facility_monthly_plans (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
grid_id BIGINT UNSIGNED NOT NULL,
plan_month VARCHAR(7) NOT NULL COMMENT 'YYYY-MM',
plan_name_ar VARCHAR(200) NOT NULL,
plan_name_en VARCHAR(200) NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft' COMMENT 'draft, active, archived',
working_days_json JSON NOT NULL DEFAULT '["1","2","3","4","5","6"]' COMMENT 'day numbers 0-6',
day_hours_start TIME NOT NULL DEFAULT '06:00:00',
day_hours_end TIME NOT NULL DEFAULT '14:00:00',
night_hours_start TIME NOT NULL DEFAULT '14:00:00',
night_hours_end TIME NOT NULL DEFAULT '22:00:00',
cloned_from_id BIGINT UNSIGNED NULL,
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by BIGINT UNSIGNED NULL,
UNIQUE KEY uq_fmp_grid_month (grid_id, plan_month),
INDEX idx_fmp_status (status),
CONSTRAINT fk_fmp_grid FOREIGN KEY (grid_id) REFERENCES facility_grids(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Link existing zone schedules to plan
ALTER TABLE facility_zone_schedules ADD COLUMN plan_id BIGINT UNSIGNED NULL AFTER plan_month;
ALTER TABLE facility_zone_schedules ADD INDEX idx_fzs_plan_id (plan_id);
```
---
## Cross-Module Integration Map
```
┌────────────────────┐
│ Notifications │
│ (automated triggers)│
└────────┬───────────┘
│ EventBus listeners
┌────────────────────────┼─────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────────┐ ┌──────────────┐
│Sessions │ │PlayerAffairs│ │ActivitySubscr.│
│attendance│─────────▶│medical expiry│─────────▶│payment due │
│cancelled │ │card status │ │pricing │
└────┬────┘ └──────┬──────┘ └──────┬───────┘
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────────┐ ┌──────────────┐
│Coaches │ │ Carnets │ │ Pricing │
│scheduling│ │guest entries │ │member/guest │
│payments │ │usage tracking│ │tier pricing │
└─────────┘ └──────┬──────┘ └──────────────┘
┌───────────────┐
│ Reservations │
│carnet_guest │
│booker_type │
└───────┬───────┘
┌───────────────┐ ┌──────────────┐
│FacilityGrids │ │FacilityDash. │
│drag-select UI │──────────▶│yearly/5yr │
│monthly plans │ │PDF export │
└───────────────┘ └──────────────┘
```
## Accounting Integration Points
All financial events from sports activities must post to the General Ledger via EventBus:
| Event | GL Debit | GL Credit | Existing? |
|-------|----------|-----------|-----------|
| Activity subscription paid | Cash/Bank | Revenue - Sports Activities | ✓ via payment.completed |
| Reservation booking paid | Cash/Bank | Revenue - Facility Rentals | ✓ via payment.completed |
| Pool booking paid | Cash/Bank | Revenue - Pool | ✓ via payment.completed |
| Guest entry via carnet paid | Cash/Bank | Revenue - Guest Fees | **NEW** |
| Coach monthly payment | Expense - Coaching | Cash/Bank | **NEW** |
| Academy settlement (revenue share) | Revenue - Academy | Cash/Bank (academy payout) | ✓ via academy settlement |
| Carnet replacement fee | Cash/Bank | Revenue - Carnet Services | ✓ via payment_request |
**New accounting events to wire:**
1. `carnet_guest.entry_paid` → post journal entry for guest fee revenue
2. `coach.payment_processed` → post journal entry for coaching expense
3. All new financial events should follow existing pattern in `Accounting/bootstrap.php`
---
## Implementation Priority for Sports Enhancements
```
GAP S1 (Carnet Guest Entries) ← HIGH: enables invitation book usage [1-2 weeks]
GAP S2 (Notification Triggers) ← HIGH: players need to be informed [2-3 weeks]
GAP S3 (Dashboard + PDF Export) ← MEDIUM: admin reporting needs [1-2 weeks]
GAP S4 (Members-Only Access) ← MEDIUM: pricing/access enforcement [1 week]
GAP S5 (Grid Drag-Select UI) ← MEDIUM: UX improvement for pool admin [1-2 weeks]
GAP S6 (Player Evaluation System) ← LOW: formal scoring model [1-2 weeks]
GAP S7 (Monthly Plan Naming/Clone) ← LOW: plan management convenience [1 week]
```
**Total estimated: 8-13 weeks for all sports enhancements**
---
## Architecture Notes
All new features should follow existing conventions:
- Module structure: `app/Modules/{Name}/` with Controllers, Models, Services, Views
- Route registration in `Routes.php`
- Permissions registered in `bootstrap.php`
- Events dispatched for cross-module integration (especially GL posting)
- Arabic-first UI with RTL layout
- Plain PDO queries (no ORM relations)
- Migration naming: `Phase_NN_NNN_description.php`
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