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 ...@@ -12,6 +12,8 @@ final class App
private ?Router $router = null; private ?Router $router = null;
private array $config = []; private array $config = [];
private ?object $currentEmployee = null; private ?object $currentEmployee = null;
private ?object $currentPlayer = null;
private ?object $currentCoach = null;
private ?array $currentBranch = null; private ?array $currentBranch = null;
private array $bindings = []; private array $bindings = [];
private array $factories = []; private array $factories = [];
...@@ -362,6 +364,26 @@ final class App ...@@ -362,6 +364,26 @@ final class App
return $this->currentEmployee; 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 public function setCurrentBranch(?array $branch): void
{ {
$this->currentBranch = $branch; $this->currentBranch = $branch;
......
...@@ -103,14 +103,16 @@ final class Router ...@@ -103,14 +103,16 @@ final class Router
private function runMiddleware(array $middlewareList, Request $request, callable $final): Response private function runMiddleware(array $middlewareList, Request $request, callable $final): Response
{ {
$middlewareMap = [ $middlewareMap = [
'csrf' => \App\Middleware\CSRFMiddleware::class, 'csrf' => \App\Middleware\CSRFMiddleware::class,
'auth' => \App\Middleware\AuthMiddleware::class, 'auth' => \App\Middleware\AuthMiddleware::class,
'api_auth' => \App\Middleware\ApiAuthMiddleware::class, 'api_auth' => \App\Middleware\ApiAuthMiddleware::class,
'cors' => \App\Middleware\CorsMiddleware::class, 'player_auth' => \App\Middleware\PlayerApiAuthMiddleware::class,
'permission' => \App\Middleware\PermissionMiddleware::class, 'coach_auth' => \App\Middleware\CoachApiAuthMiddleware::class,
'audit' => \App\Middleware\AuditMiddleware::class, 'cors' => \App\Middleware\CorsMiddleware::class,
'rate_limit' => \App\Middleware\RateLimitMiddleware::class, 'permission' => \App\Middleware\PermissionMiddleware::class,
'guest' => null, 'audit' => \App\Middleware\AuditMiddleware::class,
'rate_limit' => \App\Middleware\RateLimitMiddleware::class,
'guest' => null,
]; ];
$stack = $final; $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 [ ...@@ -39,6 +39,23 @@ return [
['POST', '/facility-grids/{gridId:\d+}/plans/{planId:\d+}/archive', 'FacilityGrids\Controllers\MonthlyPlanController@archive', ['auth', 'csrf'], 'facility_grid.manage'], ['POST', '/facility-grids/{gridId:\d+}/plans/{planId:\d+}/archive', 'FacilityGrids\Controllers\MonthlyPlanController@archive', ['auth', 'csrf'], 'facility_grid.manage'],
['GET', '/facility-grids/{gridId:\d+}/plans/{planId:\d+}/diff', 'FacilityGrids\Controllers\MonthlyPlanController@diff', ['auth'], 'facility_grid.view'], ['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) // Legacy redirects (mirror + pool → unified facility-grids)
['GET', '/mirror', 'FacilityGrids\Controllers\RedirectController@mirrorIndex', ['auth'], 'facility_grid.view'], ['GET', '/mirror', 'FacilityGrids\Controllers\RedirectController@mirrorIndex', ['auth'], 'facility_grid.view'],
['GET', '/mirror/{id:\d+}', 'FacilityGrids\Controllers\RedirectController@mirrorShow', ['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; ...@@ -7,6 +7,7 @@ use App\Core\App;
use App\Core\Logger; use App\Core\Logger;
use App\Modules\Notifications\Models\NotificationTrigger; use App\Modules\Notifications\Models\NotificationTrigger;
use App\Modules\Notifications\Models\ScheduledNotification; use App\Modules\Notifications\Models\ScheduledNotification;
use App\Modules\PlayerApi\Services\PlayerNotificationService;
final class NotificationTriggerService final class NotificationTriggerService
{ {
...@@ -24,6 +25,13 @@ final class NotificationTriggerService ...@@ -24,6 +25,13 @@ final class NotificationTriggerService
foreach ($triggers as $trigger) { foreach ($triggers as $trigger) {
try { try {
$channel = $trigger['channel'] ?? 'sms';
if ($channel === 'player_push') {
$queued += self::processPlayerPushChannel($trigger, $data, $eventName);
continue;
}
$recipients = self::resolveRecipients($trigger['recipient_type'], $data); $recipients = self::resolveRecipients($trigger['recipient_type'], $data);
if (empty($recipients)) { if (empty($recipients)) {
Logger::debug("No recipients found for trigger {$trigger['trigger_code']} on event {$eventName}"); Logger::debug("No recipients found for trigger {$trigger['trigger_code']} on event {$eventName}");
...@@ -94,6 +102,43 @@ final class NotificationTriggerService ...@@ -94,6 +102,43 @@ final class NotificationTriggerService
return $queued; 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. * Resolve recipients based on recipient type and event data context.
* Returns array of ['type' => string, 'id' => int, 'phone' => string]. * Returns array of ['type' => string, 'id' => int, 'phone' => string].
......
...@@ -223,5 +223,103 @@ EventBus::listen('session.feedback_submitted', function (array $data): void { ...@@ -223,5 +223,103 @@ EventBus::listen('session.feedback_submitted', function (array $data): void {
} }
}, -10); }, -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 ───────────────────────────── // ─── SMS/WhatsApp Notification Service for Parents ─────────────────────────────
\App\Modules\Notifications\Services\SmsNotificationService::registerListeners(); \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(); ?>
This diff is collapsed.
<?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(); ?>
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment