Commit d11abbce authored by Mahmoud Aglan's avatar Mahmoud Aglan

Phase 52: Medical approval workflow, mirror grid, facility dashboard, pricing integration

- Medical records: upload with certificate type (ممارس/أكاديمي/دولي), auto-validity
  calculation, Board of Trustees approval workflow (pending → approved/rejected)
- New /medical-approvals page for مجلس الأمناء with approve/reject actions
- Mirror (المراية): interactive grid system for facility scheduling - create grids,
  assign coaches/academies to boxes, manage trainees (max 5 per box)
- Facility dashboard: per-facility stats with day/week/month/custom date filtering
- FacilityPricingService: reads club pricing rules from system_config, integrates
  with reservation creation as rate fallback
- Pricing seed: official 2025 club ruleset for non-educational activities stored
  in system_config (member/non-member rates, entry tickets, general fees)
- Updated sidebar menu with medical approvals link
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent d0ef9ed6
...@@ -27,6 +27,7 @@ MenuRegistry::register('sports_activities', [ ...@@ -27,6 +27,7 @@ MenuRegistry::register('sports_activities', [
['label_ar' => 'المدربين', 'label_en' => 'Coaches', 'route' => '/coaches', 'permission' => 'coach.view', 'order' => 4], ['label_ar' => 'المدربين', 'label_en' => 'Coaches', 'route' => '/coaches', 'permission' => 'coach.view', 'order' => 4],
['label_ar' => 'المجموعات التدريبية','label_en' => 'Training Groups', 'route' => '/training-groups', 'permission' => 'training_group.view', 'order' => 4.2], ['label_ar' => 'المجموعات التدريبية','label_en' => 'Training Groups', 'route' => '/training-groups', 'permission' => 'training_group.view', 'order' => 4.2],
['label_ar' => 'شئون اللاعبين', 'label_en' => 'Players', 'route' => '/players', 'permission' => 'player.view', 'order' => 4.5], ['label_ar' => 'شئون اللاعبين', 'label_en' => 'Players', 'route' => '/players', 'permission' => 'player.view', 'order' => 4.5],
['label_ar' => 'اعتماد السجلات الطبية','label_en' => 'Medical Approvals', 'route' => '/medical-approvals', 'permission' => 'player.approve_medical', 'order' => 4.6],
['label_ar' => 'الحصص التدريبية', 'label_en' => 'Training Sessions', 'route' => '/sessions', 'permission' => 'session.view', 'order' => 4.7], ['label_ar' => 'الحصص التدريبية', 'label_en' => 'Training Sessions', 'route' => '/sessions', 'permission' => 'session.view', 'order' => 4.7],
['label_ar' => 'الحضور والغياب', 'label_en' => 'Attendance', 'route' => '/attendance', 'permission' => 'player.view', 'order' => 5], ['label_ar' => 'الحضور والغياب', 'label_en' => 'Attendance', 'route' => '/attendance', 'permission' => 'player.view', 'order' => 5],
['label_ar' => 'الحجوزات', 'label_en' => 'Reservations', 'route' => '/reservations', 'permission' => 'reservation.view', 'order' => 6], ['label_ar' => 'الحجوزات', 'label_en' => 'Reservations', 'route' => '/reservations', 'permission' => 'reservation.view', 'order' => 6],
......
...@@ -12,53 +12,93 @@ class FacilityDashboardController extends Controller ...@@ -12,53 +12,93 @@ class FacilityDashboardController extends Controller
{ {
public function show(Request $request, string $id): Response public function show(Request $request, string $id): Response
{ {
$this->authorize('facility.dashboard');
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$facility = $db->selectOne("SELECT * FROM facilities WHERE id = ? AND is_archived = 0", [(int) $id]); $facility = $db->selectOne("SELECT * FROM facilities WHERE id = ? AND is_archived = 0", [(int) $id]);
if (!$facility) { if (!$facility) {
return $this->redirect('/facilities')->withError('المرفق غير موجود'); return $this->redirect('/facilities')->withError('المرفق غير موجود');
} }
$today = date('Y-m-d'); $today = date('Y-m-d');
$monthStart = date('Y-m-01');
$weekStart = date('Y-m-d', strtotime('monday this week')); // Date filter: day, week, month, or custom range
$filterMode = trim((string) $request->get('filter', 'day'));
$dateFrom = trim((string) $request->get('date_from', ''));
$dateTo = trim((string) $request->get('date_to', ''));
switch ($filterMode) {
case 'week':
$periodStart = date('Y-m-d', strtotime('monday this week'));
$periodEnd = date('Y-m-d', strtotime('sunday this week'));
$periodLabel = 'هذا الأسبوع';
break;
case 'month':
$periodStart = date('Y-m-01');
$periodEnd = date('Y-m-t');
$periodLabel = 'هذا الشهر';
break;
case 'custom':
$periodStart = $dateFrom ?: $today;
$periodEnd = $dateTo ?: $today;
$periodLabel = $periodStart . ' — ' . $periodEnd;
break;
default: // day
$periodStart = $today;
$periodEnd = $today;
$periodLabel = 'اليوم';
break;
}
$todayBookings = $db->select( $todayBookings = $db->select(
"SELECT * FROM reservations WHERE facility_id = ? AND reservation_date = ? AND status NOT IN ('cancelled') ORDER BY start_time ASC", "SELECT * FROM reservations WHERE facility_id = ? AND reservation_date = ? AND status NOT IN ('cancelled') ORDER BY start_time ASC",
[(int) $id, $today] [(int) $id, $today]
); );
$periodBookings = $db->select(
"SELECT * FROM reservations WHERE facility_id = ? AND reservation_date BETWEEN ? AND ? AND status NOT IN ('cancelled') ORDER BY reservation_date ASC, start_time ASC",
[(int) $id, $periodStart, $periodEnd]
);
$periodRevenue = $db->selectOne(
"SELECT COALESCE(SUM(total_amount), 0) AS total FROM reservations WHERE facility_id = ? AND reservation_date BETWEEN ? AND ? AND payment_id IS NOT NULL AND status NOT IN ('cancelled')",
[(int) $id, $periodStart, $periodEnd]
);
$todayRevenue = $db->selectOne( $todayRevenue = $db->selectOne(
"SELECT COALESCE(SUM(total_amount), 0) AS total FROM reservations WHERE facility_id = ? AND reservation_date = ? AND payment_id IS NOT NULL AND status NOT IN ('cancelled')", "SELECT COALESCE(SUM(total_amount), 0) AS total FROM reservations WHERE facility_id = ? AND reservation_date = ? AND payment_id IS NOT NULL AND status NOT IN ('cancelled')",
[(int) $id, $today] [(int) $id, $today]
); );
$weekStart = date('Y-m-d', strtotime('monday this week'));
$weekRevenue = $db->selectOne( $weekRevenue = $db->selectOne(
"SELECT COALESCE(SUM(total_amount), 0) AS total FROM reservations WHERE facility_id = ? AND reservation_date >= ? AND payment_id IS NOT NULL AND status NOT IN ('cancelled')", "SELECT COALESCE(SUM(total_amount), 0) AS total FROM reservations WHERE facility_id = ? AND reservation_date >= ? AND reservation_date <= ? AND payment_id IS NOT NULL AND status NOT IN ('cancelled')",
[(int) $id, $weekStart] [(int) $id, $weekStart, date('Y-m-d', strtotime('sunday this week'))]
); );
$monthStart = date('Y-m-01');
$monthRevenue = $db->selectOne( $monthRevenue = $db->selectOne(
"SELECT COALESCE(SUM(total_amount), 0) AS total FROM reservations WHERE facility_id = ? AND reservation_date >= ? AND payment_id IS NOT NULL AND status NOT IN ('cancelled')", "SELECT COALESCE(SUM(total_amount), 0) AS total FROM reservations WHERE facility_id = ? AND reservation_date >= ? AND reservation_date <= ? AND payment_id IS NOT NULL AND status NOT IN ('cancelled')",
[(int) $id, $monthStart] [(int) $id, $monthStart, date('Y-m-t')]
);
$monthBookingCount = $db->selectOne(
"SELECT COUNT(*) AS cnt FROM reservations WHERE facility_id = ? AND reservation_date >= ? AND status NOT IN ('cancelled')",
[(int) $id, $monthStart]
); );
$upcomingBookings = $db->select( $upcomingBookings = $db->select(
"SELECT * FROM reservations WHERE facility_id = ? AND reservation_date >= ? AND status = 'confirmed' ORDER BY reservation_date ASC, start_time ASC LIMIT 5", "SELECT * FROM reservations WHERE facility_id = ? AND reservation_date > ? AND status = 'confirmed' ORDER BY reservation_date ASC, start_time ASC LIMIT 10",
[(int) $id, $today] [(int) $id, $today]
); );
return $this->view('FacilityDashboards.Views.dashboard', [ return $this->view('FacilityDashboards.Views.dashboard', [
'facility' => $facility, 'facility' => $facility,
'todayBookings' => $todayBookings, 'todayBookings' => $todayBookings,
'periodBookings' => $periodBookings,
'todayRevenue' => (float) ($todayRevenue['total'] ?? 0), 'todayRevenue' => (float) ($todayRevenue['total'] ?? 0),
'weekRevenue' => (float) ($weekRevenue['total'] ?? 0), 'weekRevenue' => (float) ($weekRevenue['total'] ?? 0),
'monthRevenue' => (float) ($monthRevenue['total'] ?? 0), 'monthRevenue' => (float) ($monthRevenue['total'] ?? 0),
'monthBookingCount' => (int) ($monthBookingCount['cnt'] ?? 0), 'periodRevenue' => (float) ($periodRevenue['total'] ?? 0),
'periodLabel' => $periodLabel,
'filterMode' => $filterMode,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'upcomingBookings' => $upcomingBookings, 'upcomingBookings' => $upcomingBookings,
]); ]);
} }
......
...@@ -13,45 +13,166 @@ class MirrorDisplayController extends Controller ...@@ -13,45 +13,166 @@ class MirrorDisplayController extends Controller
{ {
public function index(Request $request): Response public function index(Request $request): Response
{ {
$facilityType = trim((string) $request->get('type', '')); $this->authorize('facility.mirror');
$disciplineId = $request->get('discipline_id', '') !== '' ? (int) $request->get('discipline_id') : null;
$states = MirrorDisplayService::getFacilityStates( $grids = MirrorDisplayService::getAllGrids();
$facilityType ?: null,
$disciplineId $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('FacilityDashboards.Views.mirror_index', [
'grids' => $grids,
'facilities' => $facilities,
]);
}
public function createGrid(Request $request): Response
{
$this->authorize('facility.mirror');
$facilityId = (int) $request->post('facility_id', 0);
$nameAr = trim((string) $request->post('name_ar', ''));
$rows = max(1, min(10, (int) $request->post('rows_count', 4)));
$cols = max(1, min(12, (int) $request->post('cols_count', 6)));
if ($facilityId <= 0 || $nameAr === '') {
return $this->redirect('/mirror')->withError('يجب اختيار المرفق وإدخال الاسم');
}
$gridId = MirrorDisplayService::createGrid($facilityId, $nameAr, $rows, $cols);
return $this->redirect('/mirror/' . $gridId)->withSuccess('تم إنشاء المراية بنجاح');
}
public function show(Request $request, string $id): Response
{
$this->authorize('facility.mirror');
$grid = MirrorDisplayService::getGrid((int) $id);
if (!$grid) {
return $this->redirect('/mirror')->withError('المراية غير موجودة');
}
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$facilityTypes = $db->select( $coaches = $db->select(
"SELECT DISTINCT facility_type FROM facilities WHERE is_active = 1 AND is_archived = 0 ORDER BY facility_type" "SELECT id, full_name_ar FROM coaches WHERE is_active = 1 AND is_archived = 0 ORDER BY full_name_ar"
);
$academies = $db->select(
"SELECT id, name_ar FROM academies WHERE is_active = 1 ORDER BY name_ar"
); );
$disciplines = $db->select( $players = $db->select(
"SELECT id, name_ar FROM sport_disciplines WHERE is_active = 1 AND is_archived = 0 ORDER BY name_ar" "SELECT id, full_name_ar FROM players WHERE is_archived = 0 AND card_status = 'active' ORDER BY full_name_ar"
); );
return $this->view('FacilityDashboards.Views.mirror', [ return $this->view('FacilityDashboards.Views.mirror', [
'states' => $states, 'grid' => $grid,
'facilityTypes' => $facilityTypes, 'coaches' => $coaches,
'disciplines' => $disciplines, 'academies' => $academies,
'currentType' => $facilityType, 'players' => $players,
'currentDisc' => $disciplineId,
]); ]);
} }
public function apiState(Request $request): Response public function updateBox(Request $request, string $gridId, string $boxId): Response
{ {
$facilityType = trim((string) $request->get('type', '')); $this->authorize('facility.mirror');
$disciplineId = $request->get('discipline_id', '') !== '' ? (int) $request->get('discipline_id') : null;
$data = [];
if ($request->post('coach_id') !== null) {
$coachId = (int) $request->post('coach_id', 0);
$data['coach_id'] = $coachId > 0 ? $coachId : null;
}
if ($request->post('coach_name') !== null) {
$data['coach_name'] = trim((string) $request->post('coach_name', ''));
}
if ($request->post('academy_id') !== null) {
$academyId = (int) $request->post('academy_id', 0);
$data['academy_id'] = $academyId > 0 ? $academyId : null;
}
if ($request->post('age_group') !== null) {
$data['age_group'] = trim((string) $request->post('age_group', ''));
}
if ($request->post('gender') !== null) {
$data['gender'] = trim((string) $request->post('gender', ''));
}
if ($request->post('max_trainees') !== null) {
$data['max_trainees'] = max(1, min(10, (int) $request->post('max_trainees', 5)));
}
if ($request->post('label') !== null) {
$data['label'] = trim((string) $request->post('label', ''));
}
if (!empty($data)) {
MirrorDisplayService::updateBox((int) $boxId, $data);
}
$states = MirrorDisplayService::getFacilityStates( return $this->redirect('/mirror/' . $gridId)->withSuccess('تم تحديث الخانة');
$facilityType ?: null, }
$disciplineId
public function addTrainee(Request $request, string $gridId, string $boxId): Response
{
$this->authorize('facility.mirror');
$playerId = (int) $request->post('player_id', 0);
$traineeName = trim((string) $request->post('trainee_name', ''));
if ($playerId <= 0 && $traineeName === '') {
return $this->redirect('/mirror/' . $gridId)->withError('يجب اختيار لاعب أو كتابة اسم');
}
$success = MirrorDisplayService::addTrainee(
(int) $boxId,
$playerId > 0 ? $playerId : null,
$traineeName ?: null
); );
if (!$success) {
return $this->redirect('/mirror/' . $gridId)->withError('الخانة ممتلئة — الحد الأقصى 5');
}
return $this->redirect('/mirror/' . $gridId)->withSuccess('تم إضافة المتدرب');
}
public function removeTrainee(Request $request, string $gridId, string $traineeId): Response
{
$this->authorize('facility.mirror');
MirrorDisplayService::removeTrainee((int) $traineeId);
return $this->redirect('/mirror/' . $gridId)->withSuccess('تم إزالة المتدرب');
}
public function moveTrainee(Request $request, string $gridId, string $traineeId): Response
{
$this->authorize('facility.mirror');
$toBoxId = (int) $request->post('to_box_id', 0);
if ($toBoxId <= 0) {
return $this->redirect('/mirror/' . $gridId)->withError('يجب اختيار الخانة المستهدفة');
}
$success = MirrorDisplayService::moveTrainee((int) $traineeId, $toBoxId);
if (!$success) {
return $this->redirect('/mirror/' . $gridId)->withError('الخانة المستهدفة ممتلئة');
}
return $this->redirect('/mirror/' . $gridId)->withSuccess('تم نقل المتدرب');
}
public function apiState(Request $request, string $id): Response
{
$this->authorize('facility.mirror');
$state = MirrorDisplayService::getGridState((int) $id);
return $this->json([ return $this->json([
'states' => $states, 'boxes' => $state,
'timestamp' => date('Y-m-d H:i:s'), 'timestamp' => date('Y-m-d H:i:s'),
'time' => date('H:i'),
]); ]);
} }
public function deleteGrid(Request $request, string $id): Response
{
$this->authorize('facility.mirror');
$db = App::getInstance()->db();
$db->update('mirror_grids', ['is_active' => 0], 'id = ?', [(int) $id]);
return $this->redirect('/mirror')->withSuccess('تم حذف المراية');
}
} }
...@@ -2,7 +2,17 @@ ...@@ -2,7 +2,17 @@
declare(strict_types=1); declare(strict_types=1);
return [ return [
// Mirror Grid System
['GET', '/mirror', 'FacilityDashboards\Controllers\MirrorDisplayController@index', ['auth'], 'facility.mirror'], ['GET', '/mirror', 'FacilityDashboards\Controllers\MirrorDisplayController@index', ['auth'], 'facility.mirror'],
['GET', '/api/mirror/state', 'FacilityDashboards\Controllers\MirrorDisplayController@apiState', ['auth'], 'facility.mirror'], ['POST', '/mirror/create', 'FacilityDashboards\Controllers\MirrorDisplayController@createGrid', ['auth', 'csrf'], 'facility.mirror'],
['GET', '/mirror/{id:\d+}', 'FacilityDashboards\Controllers\MirrorDisplayController@show', ['auth'], 'facility.mirror'],
['POST', '/mirror/{gridId:\d+}/box/{boxId:\d+}', 'FacilityDashboards\Controllers\MirrorDisplayController@updateBox', ['auth', 'csrf'], 'facility.mirror'],
['POST', '/mirror/{gridId:\d+}/box/{boxId:\d+}/trainee', 'FacilityDashboards\Controllers\MirrorDisplayController@addTrainee', ['auth', 'csrf'], 'facility.mirror'],
['POST', '/mirror/{gridId:\d+}/trainee/{traineeId:\d+}/remove', 'FacilityDashboards\Controllers\MirrorDisplayController@removeTrainee', ['auth', 'csrf'], 'facility.mirror'],
['POST', '/mirror/{gridId:\d+}/trainee/{traineeId:\d+}/move', 'FacilityDashboards\Controllers\MirrorDisplayController@moveTrainee', ['auth', 'csrf'], 'facility.mirror'],
['POST', '/mirror/{id:\d+}/delete', 'FacilityDashboards\Controllers\MirrorDisplayController@deleteGrid', ['auth', 'csrf'], 'facility.mirror'],
['GET', '/api/mirror/{id:\d+}/state', 'FacilityDashboards\Controllers\MirrorDisplayController@apiState', ['auth'], 'facility.mirror'],
// Facility Dashboard
['GET', '/facilities/{id:\d+}/dashboard', 'FacilityDashboards\Controllers\FacilityDashboardController@show', ['auth'], 'facility.dashboard'], ['GET', '/facilities/{id:\d+}/dashboard', 'FacilityDashboards\Controllers\FacilityDashboardController@show', ['auth'], 'facility.dashboard'],
]; ];
...@@ -7,102 +7,198 @@ use App\Core\App; ...@@ -7,102 +7,198 @@ use App\Core\App;
final class MirrorDisplayService final class MirrorDisplayService
{ {
public static function getFacilityStates(?string $facilityType = null, ?int $disciplineId = null): array public static function getGrid(int $gridId): ?array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$now = date('H:i:s');
$today = date('Y-m-d'); $grid = $db->selectOne(
"SELECT mg.*, f.name_ar AS facility_name
$sql = "SELECT f.id, f.name_ar, f.name_en, f.facility_type, f.capacity, f.location, FROM mirror_grids mg
f.linked_discipline_id, f.is_active LEFT JOIN facilities f ON f.id = mg.facility_id
FROM facilities f WHERE mg.id = ? AND mg.is_active = 1",
WHERE f.is_active = 1 AND f.is_archived = 0"; [$gridId]
$params = [];
if ($facilityType) {
$sql .= " AND f.facility_type = ?";
$params[] = $facilityType;
}
if ($disciplineId) {
$sql .= " AND f.linked_discipline_id = ?";
$params[] = $disciplineId;
}
$sql .= " ORDER BY f.facility_type ASC, f.name_ar ASC";
$facilities = $db->select($sql, $params);
$states = [];
foreach ($facilities as $f) {
$facilityId = (int) $f['id'];
$currentBooking = $db->selectOne(
"SELECT id, member_id, notes, start_time, end_time, status
FROM reservations
WHERE facility_id = ? AND reservation_date = ? AND status IN ('confirmed', 'checked_in')
AND start_time <= ? AND end_time > ?
ORDER BY start_time ASC LIMIT 1",
[$facilityId, $today, $now, $now]
); );
$nextBooking = $db->selectOne( if (!$grid) {
"SELECT id, notes, start_time, end_time return null;
FROM reservations }
WHERE facility_id = ? AND reservation_date = ? AND status = 'confirmed'
AND start_time > ? $boxes = $db->select(
ORDER BY start_time ASC LIMIT 1", "SELECT mb.*, c.full_name_ar AS coach_full_name, a.name_ar AS academy_name
[$facilityId, $today, $now] FROM mirror_boxes mb
LEFT JOIN coaches c ON c.id = mb.coach_id
LEFT JOIN academies a ON a.id = mb.academy_id
WHERE mb.grid_id = ? AND mb.is_active = 1
ORDER BY mb.row_index ASC, mb.col_index ASC",
[$gridId]
); );
$todayBookings = $db->selectOne( foreach ($boxes as &$box) {
"SELECT COUNT(*) AS cnt FROM reservations $box['trainees'] = $db->select(
WHERE facility_id = ? AND reservation_date = ? AND status NOT IN ('cancelled') "SELECT mbt.*, p.full_name_ar AS player_full_name
", FROM mirror_box_trainees mbt
[$facilityId, $today] LEFT JOIN players p ON p.id = mbt.player_id
WHERE mbt.box_id = ?
ORDER BY mbt.assigned_at ASC",
[(int) $box['id']]
); );
$box['trainee_count'] = count($box['trainees']);
}
unset($box);
$grid['boxes'] = $boxes;
return $grid;
}
public static function getAllGrids(): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT mg.*, f.name_ar AS facility_name
FROM mirror_grids mg
LEFT JOIN facilities f ON f.id = mg.facility_id
WHERE mg.is_active = 1
ORDER BY mg.name_ar ASC"
);
}
public static function createGrid(int $facilityId, string $nameAr, int $rows, int $cols): int
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$gridId = $db->insert('mirror_grids', [
'facility_id' => $facilityId,
'name_ar' => $nameAr,
'rows_count' => $rows,
'cols_count' => $cols,
'is_active' => 1,
'created_by' => $employee ? ($employee->id ?? $employee['id'] ?? null) : null,
]);
if ($currentBooking) { for ($r = 0; $r < $rows; $r++) {
$status = $currentBooking['status'] === 'checked_in' ? 'in_progress' : 'booked'; for ($c = 0; $c < $cols; $c++) {
} else { $db->insert('mirror_boxes', [
$status = 'available'; 'grid_id' => $gridId,
'row_index' => $r,
'col_index' => $c,
'max_trainees' => 5,
'is_active' => 1,
]);
}
} }
$timeUntilNext = null; return $gridId;
if ($nextBooking) {
$diff = strtotime($nextBooking['start_time']) - strtotime($now);
if ($diff > 0) {
$mins = (int) ($diff / 60);
if ($mins < 60) {
$timeUntilNext = $mins . ' دقيقة';
} else {
$hours = (int) ($mins / 60);
$remaining = $mins % 60;
$timeUntilNext = $hours . ' ساعة' . ($remaining > 0 ? ' و ' . $remaining . ' دقيقة' : '');
} }
public static function updateBox(int $boxId, array $data): void
{
$db = App::getInstance()->db();
$db->update('mirror_boxes', $data, 'id = ?', [$boxId]);
} }
public static function addTrainee(int $boxId, ?int $playerId, ?string $traineeName): bool
{
$db = App::getInstance()->db();
$box = $db->selectOne("SELECT id, max_trainees FROM mirror_boxes WHERE id = ?", [$boxId]);
if (!$box) {
return false;
} }
$states[] = [ $currentCount = $db->selectOne(
'facility_id' => $facilityId, "SELECT COUNT(*) AS cnt FROM mirror_box_trainees WHERE box_id = ?",
'name_ar' => $f['name_ar'], [$boxId]
'type' => $f['facility_type'], )['cnt'] ?? 0;
'location' => $f['location'] ?? '',
'capacity' => (int) ($f['capacity'] ?? 0), if ((int) $currentCount >= (int) $box['max_trainees']) {
'current_status' => $status, return false;
'current_booking' => $currentBooking ? [ }
'purpose' => $currentBooking['notes'] ?? '',
'start_time' => $currentBooking['start_time'], $employee = App::getInstance()->currentEmployee();
'end_time' => $currentBooking['end_time'], $db->insert('mirror_box_trainees', [
] : null, 'box_id' => $boxId,
'next_booking' => $nextBooking ? [ 'player_id' => $playerId,
'purpose' => $nextBooking['notes'] ?? '', 'trainee_name' => $traineeName,
'start_time' => $nextBooking['start_time'], 'assigned_by' => $employee ? ($employee->id ?? $employee['id'] ?? null) : null,
'end_time' => $nextBooking['end_time'], ]);
] : null,
'time_until_next' => $timeUntilNext, return true;
'today_count' => (int) ($todayBookings['cnt'] ?? 0), }
public static function removeTrainee(int $traineeId): void
{
$db = App::getInstance()->db();
$db->delete('mirror_box_trainees', 'id = ?', [$traineeId]);
}
public static function moveTrainee(int $traineeId, int $toBoxId): bool
{
$db = App::getInstance()->db();
$box = $db->selectOne("SELECT id, max_trainees FROM mirror_boxes WHERE id = ?", [$toBoxId]);
if (!$box) {
return false;
}
$currentCount = $db->selectOne(
"SELECT COUNT(*) AS cnt FROM mirror_box_trainees WHERE box_id = ?",
[$toBoxId]
)['cnt'] ?? 0;
if ((int) $currentCount >= (int) $box['max_trainees']) {
return false;
}
$db->update('mirror_box_trainees', ['box_id' => $toBoxId], 'id = ?', [$traineeId]);
return true;
}
public static function getGridState(int $gridId): array
{
$db = App::getInstance()->db();
$boxes = $db->select(
"SELECT mb.id, mb.row_index, mb.col_index, mb.label, mb.coach_name,
mb.max_trainees, mb.age_group, mb.gender,
c.full_name_ar AS coach_full_name,
a.name_ar AS academy_name
FROM mirror_boxes mb
LEFT JOIN coaches c ON c.id = mb.coach_id
LEFT JOIN academies a ON a.id = mb.academy_id
WHERE mb.grid_id = ? AND mb.is_active = 1
ORDER BY mb.row_index ASC, mb.col_index ASC",
[$gridId]
);
$result = [];
foreach ($boxes as $box) {
$trainees = $db->select(
"SELECT mbt.id, mbt.trainee_name, p.full_name_ar AS player_name
FROM mirror_box_trainees mbt
LEFT JOIN players p ON p.id = mbt.player_id
WHERE mbt.box_id = ?",
[(int) $box['id']]
);
$result[] = [
'id' => (int) $box['id'],
'row' => (int) $box['row_index'],
'col' => (int) $box['col_index'],
'label' => $box['label'],
'coach' => $box['coach_full_name'] ?? $box['coach_name'] ?? '',
'academy' => $box['academy_name'] ?? '',
'age_group' => $box['age_group'] ?? '',
'gender' => $box['gender'] ?? 'mixed',
'max_trainees' => (int) $box['max_trainees'],
'trainees' => array_map(fn($t) => [
'id' => (int) $t['id'],
'name' => $t['player_name'] ?? $t['trainee_name'] ?? '',
], $trainees),
'trainee_count' => count($trainees),
]; ];
} }
return $states; return $result;
} }
} }
...@@ -8,6 +8,24 @@ ...@@ -8,6 +8,24 @@
<?php $__template->section('content'); ?> <?php $__template->section('content'); ?>
<!-- Date Filter -->
<div class="card" style="margin-bottom:15px;padding:12px 20px;">
<form method="GET" action="/facilities/<?= (int) $facility['id'] ?>/dashboard" style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<span style="font-size:13px;color:#6B7280;">فترة العرض:</span>
<a href="?filter=day" class="btn <?= $filterMode === 'day' ? 'btn-primary' : 'btn-outline' ?>" style="font-size:12px;padding:5px 12px;">اليوم</a>
<a href="?filter=week" class="btn <?= $filterMode === 'week' ? 'btn-primary' : 'btn-outline' ?>" style="font-size:12px;padding:5px 12px;">الأسبوع</a>
<a href="?filter=month" class="btn <?= $filterMode === 'month' ? 'btn-primary' : 'btn-outline' ?>" style="font-size:12px;padding:5px 12px;">الشهر</a>
<span style="color:#D1D5DB;">|</span>
<input type="hidden" name="filter" value="custom">
<input type="date" name="date_from" value="<?= e($dateFrom) ?>" class="form-input" style="width:140px;font-size:12px;padding:5px 8px;direction:ltr;">
<span style="font-size:12px;color:#9CA3AF;">إلى</span>
<input type="date" name="date_to" value="<?= e($dateTo) ?>" class="form-input" style="width:140px;font-size:12px;padding:5px 8px;direction:ltr;">
<button type="submit" class="btn btn-outline" style="font-size:12px;padding:5px 12px;">
<i data-lucide="filter" style="width:12px;height:12px;vertical-align:middle;margin-left:3px;"></i> تصفية
</button>
</form>
</div>
<!-- KPI Cards --> <!-- KPI Cards -->
<div style="display:grid;grid-template-columns:repeat(4, 1fr);gap:15px;margin-bottom:20px;"> <div style="display:grid;grid-template-columns:repeat(4, 1fr);gap:15px;margin-bottom:20px;">
<div class="card" style="padding:20px;text-align:center;border-top:4px solid #0D7377;"> <div class="card" style="padding:20px;text-align:center;border-top:4px solid #0D7377;">
...@@ -28,6 +46,17 @@ ...@@ -28,6 +46,17 @@
</div> </div>
</div> </div>
<?php if ($filterMode !== 'day'): ?>
<!-- Period Revenue -->
<div class="card" style="margin-bottom:20px;padding:15px 20px;display:flex;align-items:center;justify-content:space-between;background:#F0FDFA;border:1px solid #CCFBF1;">
<div>
<span style="font-size:13px;color:#6B7280;">إيرادات الفترة (<?= e($periodLabel) ?>):</span>
<span style="font-size:20px;font-weight:700;color:#059669;margin-right:8px;"><?= number_format($periodRevenue, 0) ?> ج.م</span>
</div>
<span style="font-size:13px;color:#6B7280;"><?= count($periodBookings) ?> حجز</span>
</div>
<?php endif; ?>
<!-- Today's Timeline --> <!-- Today's Timeline -->
<div class="card" style="margin-bottom:20px;"> <div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;"> <div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
...@@ -57,7 +86,7 @@ ...@@ -57,7 +86,7 @@
<?= ['confirmed'=>'مؤكد','checked_in'=>'جاري','completed'=>'مكتمل'][$b['status']] ?? $b['status'] ?> <?= ['confirmed'=>'مؤكد','checked_in'=>'جاري','completed'=>'مكتمل'][$b['status']] ?? $b['status'] ?>
</span> </span>
</div> </div>
<?php if ($b['total_amount'] > 0): ?> <?php if ((float)($b['total_amount'] ?? 0) > 0): ?>
<div style="font-size:12px;color:#059669;font-weight:600;"><?= number_format((float) $b['total_amount'], 0) ?> ج.م</div> <div style="font-size:12px;color:#059669;font-weight:600;"><?= number_format((float) $b['total_amount'], 0) ?> ج.م</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
...@@ -82,7 +111,7 @@ ...@@ -82,7 +111,7 @@
<tr style="color:#6B7280;font-size:12px;"> <tr style="color:#6B7280;font-size:12px;">
<th style="padding:8px;text-align:right;border-bottom:1px solid #E5E7EB;">التاريخ</th> <th style="padding:8px;text-align:right;border-bottom:1px solid #E5E7EB;">التاريخ</th>
<th style="padding:8px;text-align:right;border-bottom:1px solid #E5E7EB;">الوقت</th> <th style="padding:8px;text-align:right;border-bottom:1px solid #E5E7EB;">الوقت</th>
<th style="padding:8px;text-align:right;border-bottom:1px solid #E5E7EB;">الغرض</th> <th style="padding:8px;text-align:right;border-bottom:1px solid #E5E7EB;">ملاحظات</th>
<th style="padding:8px;text-align:left;border-bottom:1px solid #E5E7EB;">المبلغ</th> <th style="padding:8px;text-align:left;border-bottom:1px solid #E5E7EB;">المبلغ</th>
</tr> </tr>
</thead> </thead>
...@@ -92,7 +121,7 @@ ...@@ -92,7 +121,7 @@
<td style="padding:8px;border-bottom:1px solid #F3F4F6;"><?= e($b['reservation_date']) ?></td> <td style="padding:8px;border-bottom:1px solid #F3F4F6;"><?= e($b['reservation_date']) ?></td>
<td style="padding:8px;border-bottom:1px solid #F3F4F6;direction:ltr;text-align:right;"><?= e(substr($b['start_time'],0,5)) ?><?= e(substr($b['end_time'],0,5)) ?></td> <td style="padding:8px;border-bottom:1px solid #F3F4F6;direction:ltr;text-align:right;"><?= e(substr($b['start_time'],0,5)) ?><?= e(substr($b['end_time'],0,5)) ?></td>
<td style="padding:8px;border-bottom:1px solid #F3F4F6;"><?= e($b['notes'] ?? '—') ?></td> <td style="padding:8px;border-bottom:1px solid #F3F4F6;"><?= e($b['notes'] ?? '—') ?></td>
<td style="padding:8px;border-bottom:1px solid #F3F4F6;text-align:left;"><?= $b['total_amount'] > 0 ? number_format((float)$b['total_amount'], 0) . ' ج.م' : '—' ?></td> <td style="padding:8px;border-bottom:1px solid #F3F4F6;text-align:left;"><?= (float)($b['total_amount'] ?? 0) > 0 ? number_format((float)$b['total_amount'], 0) . ' ج.م' : '—' ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
......
<?php $__template->layout('Layout.mirror'); ?> <?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>المراية — عرض مباشر<?php $__template->endSection(); ?> <?php $__template->section('title'); ?>المراية: <?= e($grid['name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/mirror" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> كل المرايات</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?> <?php $__template->section('content'); ?>
<?php <?php
$statusConfig = [ $rows = (int) $grid['rows_count'];
'available' => ['label' => 'متاح', 'color' => '#10B981', 'bg' => '#ECFDF5', 'icon' => 'check-circle'], $cols = (int) $grid['cols_count'];
'booked' => ['label' => 'محجوز', 'color' => '#3B82F6', 'bg' => '#EFF6FF', 'icon' => 'calendar'], $gridId = (int) $grid['id'];
'in_progress' => ['label' => 'جاري', 'color' => '#F59E0B', 'bg' => '#FFFBEB', 'icon' => 'play-circle'],
'maintenance' => ['label' => 'صيانة', 'color' => '#EF4444', 'bg' => '#FEF2F2', 'icon' => 'wrench'], $boxMap = [];
'closed' => ['label' => 'مغلق', 'color' => '#6B7280', 'bg' => '#F9FAFB', 'icon' => 'x-circle'], foreach ($grid['boxes'] as $box) {
]; $key = $box['row_index'] . '_' . $box['col_index'];
$boxMap[$key] = $box;
$typeLabels = [ }
'court' => 'ملعب',
'pool' => 'حمام سباحة', $genderLabels = ['male' => 'أولاد', 'female' => 'بنات', 'mixed' => 'مختلط'];
'gym' => 'صالة رياضية', $genderColors = ['male' => '#2563EB', 'female' => '#EC4899', 'mixed' => '#6B7280'];
'track' => 'مضمار',
'hall' => 'قاعة',
'field' => 'ملعب كبير',
'other' => 'أخرى',
];
?> ?>
<!-- Header Bar --> <!-- Grid Header -->
<div style="position:fixed;top:0;right:0;left:0;z-index:100;background:linear-gradient(135deg, #0D7377, #14919B);padding:15px 30px;display:flex;align-items:center;justify-content:space-between;box-shadow:0 4px 20px rgba(0,0,0,0.15);"> <div class="card" style="margin-bottom:15px;padding:15px 20px;display:flex;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:12px;"> <div style="display:flex;align-items:center;gap:10px;">
<i data-lucide="monitor" style="width:28px;height:28px;color:white;"></i> <i data-lucide="grid-3x3" style="width:20px;height:20px;color:#0D7377;"></i>
<h1 style="margin:0;font-size:22px;color:white;font-weight:700;">المراية — عرض مباشر</h1> <div>
</div> <span style="font-size:15px;font-weight:600;color:#1A1A2E;"><?= e($grid['name_ar']) ?></span>
<div style="display:flex;align-items:center;gap:20px;"> <span style="font-size:12px;color:#6B7280;margin-right:8px;"><?= e($grid['facility_name'] ?? '') ?></span>
<!-- Filters -->
<select id="filterType" style="padding:6px 12px;border-radius:6px;border:none;font-size:13px;background:rgba(255,255,255,0.9);" onchange="applyFilters()">
<option value="">كل الأنواع</option>
<?php foreach ($facilityTypes as $ft): ?>
<option value="<?= e($ft['facility_type']) ?>" <?= $currentType === $ft['facility_type'] ? 'selected' : '' ?>><?= e($typeLabels[$ft['facility_type']] ?? $ft['facility_type']) ?></option>
<?php endforeach; ?>
</select>
<!-- Clock -->
<div id="liveClock" style="font-size:28px;font-weight:700;color:white;font-family:monospace;direction:ltr;"></div>
<!-- Status Indicator -->
<div style="display:flex;align-items:center;gap:6px;">
<span id="syncDot" style="width:10px;height:10px;border-radius:50%;background:#10B981;animation:pulse 2s infinite;"></span>
<span style="font-size:11px;color:rgba(255,255,255,0.8);">مباشر</span>
</div> </div>
</div> </div>
<div style="font-size:12px;color:#6B7280;">
<?= $rows ?> × <?= $cols ?> = <?= $rows * $cols ?> خانة
</div>
</div> </div>
<!-- Legend --> <!-- Interactive Grid -->
<div style="position:fixed;top:65px;right:0;left:0;z-index:99;background:white;padding:10px 30px;border-bottom:1px solid #E5E7EB;display:flex;gap:20px;align-items:center;"> <div style="display:grid;grid-template-columns:repeat(<?= $cols ?>, 1fr);gap:10px;margin-bottom:20px;">
<?php foreach ($statusConfig as $st => $cfg): ?> <?php for ($r = 0; $r < $rows; $r++): ?>
<span style="display:flex;align-items:center;gap:6px;font-size:13px;"> <?php for ($c = 0; $c < $cols; $c++):
<span style="width:16px;height:16px;border-radius:4px;background:<?= $cfg['color'] ?>;display:inline-block;"></span> $key = $r . '_' . $c;
<?= $cfg['label'] ?> $box = $boxMap[$key] ?? null;
</span> $boxId = $box ? (int) $box['id'] : 0;
<?php endforeach; ?> $traineeCount = $box ? (int) ($box['trainee_count'] ?? 0) : 0;
<span style="margin-right:auto;font-size:12px;color:#9CA3AF;" id="lastUpdate">آخر تحديث: <?= date('H:i:s') ?></span> $maxTrainees = $box ? (int) ($box['max_trainees'] ?? 5) : 5;
</div> $isFull = $traineeCount >= $maxTrainees;
$gender = $box['gender'] ?? 'mixed';
<!-- Facility Grid --> $gc = $genderColors[$gender] ?? '#6B7280';
<div id="facilityGrid" style="padding:120px 30px 30px;display:grid;grid-template-columns:repeat(auto-fill, minmax(280px, 1fr));gap:20px;min-height:100vh;"> $coachDisplay = '';
<?php foreach ($states as $s): if (!empty($box['coach_full_name'])) $coachDisplay = $box['coach_full_name'];
$cfg = $statusConfig[$s['current_status']] ?? $statusConfig['closed']; elseif (!empty($box['coach_name'])) $coachDisplay = $box['coach_name'];
$academyDisplay = $box['academy_name'] ?? '';
?> ?>
<div class="mirror-card" data-facility-id="<?= $s['facility_id'] ?>" style=" <div class="mirror-box" data-box-id="<?= $boxId ?>" onclick="openBoxModal(<?= $boxId ?>)"
border-radius:16px; style="border:2px solid <?= $isFull ? '#EF4444' : ($traineeCount > 0 ? '#0D7377' : '#E5E7EB') ?>;
border:3px solid <?= $cfg['color'] ?>40; border-radius:12px;padding:12px;min-height:140px;cursor:pointer;
background:<?= $cfg['bg'] ?>; background:<?= $isFull ? '#FEF2F2' : ($traineeCount > 0 ? '#F0FDFA' : 'white') ?>;
padding:20px; transition:all 0.2s;position:relative;">
transition:all 0.3s; <!-- Box label/position -->
position:relative; <div style="position:absolute;top:4px;left:4px;font-size:10px;color:#9CA3AF;direction:ltr;">
overflow:hidden; <?= $r + 1 ?>,<?= $c + 1 ?>
">
<!-- Status indicator stripe -->
<div style="position:absolute;top:0;right:0;left:0;height:5px;background:<?= $cfg['color'] ?>;"></div>
<!-- Header -->
<div style="display:flex;align-items:start;justify-content:space-between;margin-bottom:12px;">
<div>
<h3 style="margin:0 0 4px;font-size:18px;font-weight:700;color:#1A1A2E;"><?= e($s['name_ar']) ?></h3>
<span style="font-size:12px;color:#6B7280;"><?= e($typeLabels[$s['type']] ?? $s['type']) ?></span>
</div> </div>
<div style="width:44px;height:44px;border-radius:12px;background:<?= $cfg['color'] ?>20;display:flex;align-items:center;justify-content:center;">
<i data-lucide="<?= $cfg['icon'] ?>" style="width:22px;height:22px;color:<?= $cfg['color'] ?>;"></i> <?php if ($box && ($coachDisplay || $academyDisplay || $traineeCount > 0)): ?>
<!-- Coach/Academy -->
<?php if ($coachDisplay): ?>
<div style="font-size:11px;font-weight:600;color:#0D7377;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="<?= e($coachDisplay) ?>">
<i data-lucide="user" style="width:11px;height:11px;vertical-align:middle;margin-left:2px;"></i>
<?= e($coachDisplay) ?>
</div> </div>
<?php endif; ?>
<?php if ($academyDisplay): ?>
<div style="font-size:10px;color:#6B7280;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
<?= e($academyDisplay) ?>
</div> </div>
<?php endif; ?>
<!-- Status Badge --> <!-- Age + Gender -->
<div style="margin-bottom:12px;"> <?php if (!empty($box['age_group']) || $gender !== 'mixed'): ?>
<span style="padding:5px 14px;border-radius:20px;font-size:14px;font-weight:700;background:<?= $cfg['color'] ?>20;color:<?= $cfg['color'] ?>;"> <div style="display:flex;gap:4px;margin-bottom:6px;flex-wrap:wrap;">
<?= $cfg['label'] ?> <?php if (!empty($box['age_group'])): ?>
</span> <span style="font-size:9px;padding:1px 5px;border-radius:4px;background:#F3F4F6;color:#374151;"><?= e($box['age_group']) ?></span>
<?php endif; ?>
<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:<?= $gc ?>15;color:<?= $gc ?>;"><?= $genderLabels[$gender] ?? '' ?></span>
</div> </div>
<?php endif; ?>
<!-- Current/Next Info --> <!-- Trainees -->
<?php if ($s['current_booking']): ?> <div style="display:flex;flex-direction:column;gap:2px;">
<div style="padding:10px;background:white;border-radius:8px;margin-bottom:8px;font-size:13px;"> <?php if (!empty($box['trainees'])):
<div style="color:#6B7280;font-size:11px;margin-bottom:2px;">الآن:</div> foreach ($box['trainees'] as $t):
<div style="color:#1A1A2E;font-weight:600;"><?= e($s['current_booking']['purpose'] ?: 'حجز') ?></div> $tName = $t['player_full_name'] ?? $t['trainee_name'] ?? '';
<div style="color:#6B7280;direction:ltr;text-align:right;font-size:12px;"><?= e($s['current_booking']['start_time']) ?><?= e($s['current_booking']['end_time']) ?></div> ?>
<div style="font-size:10px;color:#374151;background:white;border:1px solid #E5E7EB;border-radius:4px;padding:2px 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="<?= e($tName) ?>">
<?= e($tName) ?>
</div>
<?php endforeach; endif; ?>
</div> </div>
<?php endif; ?>
<?php if ($s['next_booking']): ?> <!-- Count -->
<div style="padding:8px 10px;background:white;border-radius:8px;font-size:12px;display:flex;justify-content:space-between;align-items:center;"> <div style="position:absolute;bottom:4px;left:6px;font-size:10px;font-weight:700;color:<?= $isFull ? '#DC2626' : '#059669' ?>;direction:ltr;">
<span style="color:#6B7280;">التالي: <?= e($s['next_booking']['purpose'] ?: 'حجز') ?></span> <?= $traineeCount ?>/<?= $maxTrainees ?>
<?php if ($s['time_until_next']): ?>
<span style="font-weight:600;color:#D97706;">بعد <?= e($s['time_until_next']) ?></span>
<?php endif; ?>
</div> </div>
<?php elseif ($s['current_status'] === 'available'): ?> <?php else: ?>
<div style="padding:8px 10px;background:white;border-radius:8px;font-size:13px;text-align:center;color:#059669;font-weight:600;"> <div style="display:flex;align-items:center;justify-content:center;height:100%;opacity:0.4;">
لا يوجد حجوزات قادمة <i data-lucide="plus" style="width:24px;height:24px;color:#9CA3AF;"></i>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div>
<?php endfor; ?>
<?php endfor; ?>
</div>
<!-- Footer --> <!-- Box Edit Modal -->
<div style="margin-top:12px;display:flex;justify-content:space-between;font-size:11px;color:#9CA3AF;"> <div id="boxModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;align-items:center;justify-content:center;">
<span>حجوزات اليوم: <?= $s['today_count'] ?></span> <div style="background:white;border-radius:16px;width:500px;max-width:95%;max-height:90vh;overflow-y:auto;box-shadow:0 25px 50px rgba(0,0,0,0.25);">
<?php if ($s['capacity']): ?> <div style="padding:20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<span>سعة: <?= $s['capacity'] ?></span> <h3 style="margin:0;font-size:16px;color:#0D7377;">تعديل الخانة</h3>
<?php endif; ?> <button onclick="closeBoxModal()" style="background:none;border:none;cursor:pointer;padding:5px;">
<i data-lucide="x" style="width:20px;height:20px;color:#6B7280;"></i>
</button>
</div>
<!-- Box Settings Form -->
<form id="boxForm" method="POST" action="">
<?= csrf_field() ?>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:15px;">
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">المدرب</label>
<select name="coach_id" class="form-select" id="modalCoachId">
<option value="0">— بدون —</option>
<?php foreach ($coaches as $coach): ?>
<option value="<?= (int) $coach['id'] ?>"><?= e($coach['full_name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div> </div>
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">أو اسم يدوي</label>
<input type="text" name="coach_name" id="modalCoachName" class="form-input" placeholder="اسم المدرب">
</div> </div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:15px;">
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">الأكاديمية</label>
<select name="academy_id" class="form-select" id="modalAcademyId">
<option value="0">— بدون —</option>
<?php foreach ($academies as $ac): ?>
<option value="<?= (int) $ac['id'] ?>"><?= e($ac['name_ar']) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</div> </select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">الفئة العمرية</label>
<input type="text" name="age_group" id="modalAgeGroup" class="form-input" placeholder="مثال: 4-6" style="direction:ltr;text-align:left;">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:15px;">
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">الجنس</label>
<select name="gender" class="form-select" id="modalGender">
<option value="mixed">مختلط</option>
<option value="male">أولاد</option>
<option value="female">بنات</option>
</select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">أقصى عدد متدربين</label>
<input type="number" name="max_trainees" id="modalMaxTrainees" class="form-input" value="5" min="1" max="10" style="direction:ltr;text-align:center;">
</div>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">
<i data-lucide="save" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> حفظ إعدادات الخانة
</button>
</div>
</form>
<div style="border-top:1px solid #E5E7EB;padding:20px;">
<h4 style="margin:0 0 10px;font-size:14px;color:#374151;">إضافة متدرب</h4>
<form id="traineeForm" method="POST" action="" style="display:flex;gap:8px;flex-wrap:wrap;">
<?= csrf_field() ?>
<select name="player_id" class="form-select" style="flex:1;min-width:150px;">
<option value="0">— اختر لاعب —</option>
<?php foreach ($players as $p): ?>
<option value="<?= (int) $p['id'] ?>"><?= e($p['full_name_ar']) ?></option>
<?php endforeach; ?>
</select>
<input type="text" name="trainee_name" class="form-input" placeholder="أو اكتب اسم يدوي" style="flex:1;min-width:150px;">
<button type="submit" class="btn btn-primary" style="white-space:nowrap;">
<i data-lucide="user-plus" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> إضافة
</button>
</form>
</div>
<?php if (empty($states)): ?> <!-- Current Trainees in Box -->
<div style="padding:120px 30px;text-align:center;"> <div id="modalTrainees" style="border-top:1px solid #E5E7EB;padding:15px 20px;display:none;">
<i data-lucide="monitor-off" style="width:64px;height:64px;color:#D1D5DB;margin-bottom:20px;"></i> <h4 style="margin:0 0 10px;font-size:14px;color:#374151;">المتدربون الحاليون</h4>
<h2 style="color:#6B7280;">لا توجد مرافق نشطة</h2> <div id="traineeList"></div>
</div>
</div>
</div> </div>
<?php endif; ?>
<style> <style>
@keyframes pulse { .mirror-box:hover { transform:scale(1.02); box-shadow:0 4px 12px rgba(0,0,0,0.08); }
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
body { overflow-x: hidden; }
.mirror-card:hover { transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.1); }
</style> </style>
<script> <script>
var gridId = <?= $gridId ?>;
var boxesData = <?= json_encode($grid['boxes'], JSON_UNESCAPED_UNICODE) ?>;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons(); if (typeof lucide !== 'undefined') lucide.createIcons();
updateClock();
setInterval(updateClock, 1000);
setInterval(refreshMirror, 30000);
}); });
function updateClock() { function openBoxModal(boxId) {
var now = new Date(); if (!boxId) return;
var h = String(now.getHours()).padStart(2, '0'); var box = boxesData.find(b => parseInt(b.id) === boxId);
var m = String(now.getMinutes()).padStart(2, '0'); if (!box) return;
var s = String(now.getSeconds()).padStart(2, '0');
document.getElementById('liveClock').textContent = h + ':' + m + ':' + s; document.getElementById('boxForm').action = '/mirror/' + gridId + '/box/' + boxId;
} document.getElementById('traineeForm').action = '/mirror/' + gridId + '/box/' + boxId + '/trainee';
function refreshMirror() { document.getElementById('modalCoachId').value = box.coach_id || '0';
var type = document.getElementById('filterType').value; document.getElementById('modalCoachName').value = box.coach_name || '';
var url = '/api/mirror/state?type=' + encodeURIComponent(type); document.getElementById('modalAcademyId').value = box.academy_id || '0';
document.getElementById('modalAgeGroup').value = box.age_group || '';
fetch(url) document.getElementById('modalGender').value = box.gender || 'mixed';
.then(r => r.json()) document.getElementById('modalMaxTrainees').value = box.max_trainees || 5;
.then(data => {
document.getElementById('lastUpdate').textContent = 'آخر تحديث: ' + data.time; // Render trainees
updateCards(data.states); var traineeDiv = document.getElementById('modalTrainees');
}) var traineeList = document.getElementById('traineeList');
.catch(() => { traineeList.innerHTML = '';
document.getElementById('syncDot').style.background = '#EF4444';
if (box.trainees && box.trainees.length > 0) {
traineeDiv.style.display = 'block';
box.trainees.forEach(function(t) {
var name = t.player_full_name || t.trainee_name || '';
var row = document.createElement('div');
row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:6px 8px;background:#F9FAFB;border-radius:6px;margin-bottom:4px;font-size:13px;';
row.innerHTML = '<span>' + escapeHtml(name) + '</span>' +
'<div style="display:flex;gap:4px;">' +
'<form method="POST" action="/mirror/' + gridId + '/trainee/' + t.id + '/remove" style="margin:0;">' +
'<input type="hidden" name="_token" value="' + getCsrf() + '">' +
'<button type="submit" style="background:none;border:none;cursor:pointer;color:#DC2626;font-size:11px;padding:2px 6px;" title="إزالة">✕</button>' +
'</form>' +
'</div>';
traineeList.appendChild(row);
}); });
} else {
traineeDiv.style.display = 'none';
}
document.getElementById('boxModal').style.display = 'flex';
} }
function updateCards(states) { function closeBoxModal() {
var statusConfig = { document.getElementById('boxModal').style.display = 'none';
available: {label:'متاح', color:'#10B981', bg:'#ECFDF5'}, }
booked: {label:'محجوز', color:'#3B82F6', bg:'#EFF6FF'},
in_progress: {label:'جاري', color:'#F59E0B', bg:'#FFFBEB'},
maintenance: {label:'صيانة', color:'#EF4444', bg:'#FEF2F2'},
closed: {label:'مغلق', color:'#6B7280', bg:'#F9FAFB'},
};
states.forEach(function(s) {
var card = document.querySelector('[data-facility-id="' + s.facility_id + '"]');
if (!card) return;
var cfg = statusConfig[s.current_status] || statusConfig.closed;
card.style.borderColor = cfg.color + '40';
card.style.background = cfg.bg;
var stripe = card.querySelector('div');
if (stripe) stripe.style.background = cfg.color;
});
document.getElementById('syncDot').style.background = '#10B981'; function getCsrf() {
var el = document.querySelector('input[name="_token"]');
return el ? el.value : '';
} }
function applyFilters() { function escapeHtml(str) {
var type = document.getElementById('filterType').value; var div = document.createElement('div');
window.location.href = '/mirror' + (type ? '?type=' + encodeURIComponent(type) : ''); div.textContent = str;
return div.innerHTML;
} }
document.getElementById('boxModal').addEventListener('click', function(e) {
if (e.target === this) closeBoxModal();
});
</script> </script>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>المراية — إدارة الشبكات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Create New Grid -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="plus-circle" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">إنشاء مراية جديدة</h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/mirror/create" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
<?= csrf_field() ?>
<div class="form-group" style="margin:0;flex:1;min-width:200px;">
<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'] ?>"><?= e($f['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="margin:0;flex:1;min-width:180px;">
<label class="form-label">اسم المراية</label>
<input type="text" name="name_ar" class="form-input" required placeholder="مثال: مراية حمام السباحة الأولمبي">
</div>
<div class="form-group" style="margin:0;width:100px;">
<label class="form-label">الصفوف</label>
<input type="number" name="rows_count" class="form-input" value="4" min="1" max="10" style="direction:ltr;text-align:center;">
</div>
<div class="form-group" style="margin:0;width:100px;">
<label class="form-label">الأعمدة</label>
<input type="number" name="cols_count" class="form-input" value="6" min="1" max="12" style="direction:ltr;text-align:center;">
</div>
<button type="submit" class="btn btn-primary" style="height:38px;">
<i data-lucide="grid-3x3" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> إنشاء
</button>
</form>
</div>
</div>
<!-- Existing Grids -->
<?php if (!empty($grids)): ?>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(300px, 1fr));gap:20px;">
<?php foreach ($grids as $g): ?>
<div class="card" style="padding:20px;border-top:4px solid #0D7377;">
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:12px;">
<div>
<h3 style="margin:0 0 4px;font-size:16px;color:#1A1A2E;"><?= e($g['name_ar']) ?></h3>
<div style="font-size:12px;color:#6B7280;"><?= e($g['facility_name'] ?? '') ?></div>
</div>
<div style="width:40px;height:40px;border-radius:10px;background:#0D737715;display:flex;align-items:center;justify-content:center;">
<i data-lucide="grid-3x3" style="width:20px;height:20px;color:#0D7377;"></i>
</div>
</div>
<div style="font-size:13px;color:#6B7280;margin-bottom:15px;">
<?= (int) $g['rows_count'] ?> صفوف × <?= (int) $g['cols_count'] ?> أعمدة
= <?= (int) $g['rows_count'] * (int) $g['cols_count'] ?> خانة
</div>
<div style="display:flex;gap:8px;">
<a href="/mirror/<?= (int) $g['id'] ?>" class="btn btn-primary" style="font-size:13px;flex:1;text-align:center;">
<i data-lucide="eye" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> فتح المراية
</a>
<form method="POST" action="/mirror/<?= (int) $g['id'] ?>/delete" style="margin:0;" onsubmit="return confirm('هل أنت متأكد من حذف هذه المراية؟')">
<?= csrf_field() ?>
<button type="submit" class="btn btn-outline" style="font-size:13px;color:#DC2626;border-color:#DC2626;">
<i data-lucide="trash-2" style="width:14px;height:14px;vertical-align:middle;"></i>
</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="card" style="padding:40px;text-align:center;">
<i data-lucide="grid-3x3" style="width:48px;height:48px;color:#D1D5DB;margin-bottom:12px;"></i>
<p style="color:#6B7280;font-size:14px;">لا توجد مرايات بعد. أنشئ واحدة جديدة للبدء.</p>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
<?php
declare(strict_types=1);
namespace App\Modules\PlayerAffairs\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\PlayerAffairs\Services\MedicalRecordService;
class MedicalApprovalController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('player.approve_medical');
$db = App::getInstance()->db();
$filter = trim((string) $request->get('filter', 'pending'));
$whereClause = match ($filter) {
'approved' => "pmr.approval_status = 'approved'",
'rejected' => "pmr.approval_status = 'rejected'",
default => "pmr.approval_status = 'pending'",
};
$records = $db->select(
"SELECT pmr.*, p.full_name_ar AS player_name, p.registration_serial,
p.player_type, p.photo_path
FROM player_medical_records pmr
LEFT JOIN players p ON p.id = pmr.player_id
WHERE {$whereClause}
ORDER BY pmr.created_at DESC
LIMIT 100"
);
return $this->view('PlayerAffairs.Views.medical_approvals', [
'records' => $records,
'filter' => $filter,
]);
}
public function approve(Request $request, string $id): Response
{
$this->authorize('player.approve_medical');
$employee = App::getInstance()->currentEmployee();
$approvedBy = $employee ? (int) $employee->id : 0;
try {
MedicalRecordService::approveRecord((int) $id, $approvedBy);
} catch (\RuntimeException $e) {
return $this->redirect('/medical-approvals')->withError($e->getMessage());
}
return $this->redirect('/medical-approvals')->withSuccess('تم اعتماد السجل الطبي بنجاح');
}
public function reject(Request $request, string $id): Response
{
$this->authorize('player.approve_medical');
$reason = trim((string) $request->post('rejection_reason', ''));
if (empty($reason)) {
return $this->redirect('/medical-approvals')->withError('يجب إدخال سبب الرفض');
}
$employee = App::getInstance()->currentEmployee();
$rejectedBy = $employee ? (int) $employee->id : 0;
try {
MedicalRecordService::rejectRecord((int) $id, $rejectedBy, $reason);
} catch (\RuntimeException $e) {
return $this->redirect('/medical-approvals')->withError($e->getMessage());
}
return $this->redirect('/medical-approvals')->withSuccess('تم رفض السجل الطبي');
}
}
...@@ -321,27 +321,28 @@ class PlayerController extends Controller ...@@ -321,27 +321,28 @@ class PlayerController extends Controller
{ {
$data = [ $data = [
'record_type' => trim((string) $request->post('record_type', '')), 'record_type' => trim((string) $request->post('record_type', '')),
'certificate_type' => trim((string) $request->post('certificate_type', 'recreational')),
'exam_date' => trim((string) $request->post('exam_date', '')), 'exam_date' => trim((string) $request->post('exam_date', '')),
'expiry_date' => trim((string) $request->post('expiry_date', '')) ?: null, 'expiry_date' => trim((string) $request->post('expiry_date', '')) ?: null,
'doctor_name' => trim((string) $request->post('doctor_name', '')) ?: null, 'doctor_name' => trim((string) $request->post('doctor_name', '')) ?: null,
'clinic_name' => trim((string) $request->post('clinic_name', '')) ?: null, 'clinic_name' => trim((string) $request->post('clinic_name', '')) ?: null,
'result' => trim((string) $request->post('result', '')), 'issuing_authority' => trim((string) $request->post('issuing_authority', '')) ?: null,
'cert_number' => trim((string) $request->post('cert_number', '')) ?: null,
'restrictions' => trim((string) $request->post('restrictions', '')) ?: null, 'restrictions' => trim((string) $request->post('restrictions', '')) ?: null,
'document_id' => $request->post('document_id') ? (int) $request->post('document_id') : null, 'document_id' => $request->post('document_id') ? (int) $request->post('document_id') : null,
'notes' => trim((string) $request->post('notes', '')) ?: null, 'notes' => trim((string) $request->post('notes', '')) ?: null,
]; ];
// Validation
$errors = []; $errors = [];
if (!array_key_exists($data['record_type'], PlayerMedicalRecord::getRecordTypes())) { if (!array_key_exists($data['record_type'], PlayerMedicalRecord::getRecordTypes())) {
$errors[] = 'نوع السجل الطبي غير صالح'; $errors[] = 'نوع السجل الطبي غير صالح';
} }
if (!array_key_exists($data['certificate_type'], PlayerMedicalRecord::getCertificateTypes())) {
$errors[] = 'نوع الشهادة غير صالح';
}
if (empty($data['exam_date'])) { if (empty($data['exam_date'])) {
$errors[] = 'تاريخ الفحص مطلوب'; $errors[] = 'تاريخ الفحص مطلوب';
} }
if (!empty($data['result']) && !array_key_exists($data['result'], PlayerMedicalRecord::getResults())) {
$errors[] = 'نتيجة الفحص غير صالحة';
}
if (!empty($errors)) { if (!empty($errors)) {
$session = App::getInstance()->session(); $session = App::getInstance()->session();
...@@ -355,7 +356,7 @@ class PlayerController extends Controller ...@@ -355,7 +356,7 @@ class PlayerController extends Controller
return $this->redirect('/players/' . $id)->withError($e->getMessage()); return $this->redirect('/players/' . $id)->withError($e->getMessage());
} }
return $this->redirect('/players/' . $id)->withSuccess('تم إضافة السجل الطبي بنجاح'); return $this->redirect('/players/' . $id)->withSuccess('تم رفع السجل الطبي بنجاح — في انتظار اعتماد مجلس الأمناء');
} }
/** /**
......
...@@ -16,11 +16,19 @@ class PlayerMedicalRecord extends Model ...@@ -16,11 +16,19 @@ class PlayerMedicalRecord extends Model
protected static array $fillable = [ protected static array $fillable = [
'player_id', 'player_id',
'record_type', 'record_type',
'certificate_type',
'validity_months',
'exam_date', 'exam_date',
'expiry_date', 'expiry_date',
'doctor_name', 'doctor_name',
'clinic_name', 'clinic_name',
'issuing_authority',
'cert_number',
'result', 'result',
'approval_status',
'approved_by',
'approved_at',
'rejection_reason',
'restrictions', 'restrictions',
'document_id', 'document_id',
'notes', 'notes',
...@@ -41,12 +49,34 @@ class PlayerMedicalRecord extends Model ...@@ -41,12 +49,34 @@ class PlayerMedicalRecord extends Model
]; ];
} }
/**
* Get certificate types with Arabic labels.
*/
public static function getCertificateTypes(): array
{
return [
'recreational' => 'ممارس',
'academy' => 'أكاديمي',
'international' => 'دولي',
];
}
public static function getApprovalStatuses(): array
{
return [
'pending' => 'في الانتظار',
'approved' => 'معتمد',
'rejected' => 'مرفوض',
];
}
/** /**
* Get medical results with Arabic labels. * Get medical results with Arabic labels.
*/ */
public static function getResults(): array public static function getResults(): array
{ {
return [ return [
'pending' => 'في الانتظار',
'fit' => 'لائق', 'fit' => 'لائق',
'conditional' => 'لائق بشروط', 'conditional' => 'لائق بشروط',
'unfit' => 'غير لائق', 'unfit' => 'غير لائق',
......
...@@ -19,6 +19,11 @@ return [ ...@@ -19,6 +19,11 @@ return [
['POST', '/api/players/parse-nid', 'PlayerAffairs\Controllers\PlayerController@parseNid', ['auth'], 'player.register'], ['POST', '/api/players/parse-nid', 'PlayerAffairs\Controllers\PlayerController@parseNid', ['auth'], 'player.register'],
['GET', '/api/players/academies', 'PlayerAffairs\Controllers\PlayerController@apiAcademies', ['auth'], 'player.view'], ['GET', '/api/players/academies', 'PlayerAffairs\Controllers\PlayerController@apiAcademies', ['auth'], 'player.view'],
// Medical Approvals (مجلس الأمناء)
['GET', '/medical-approvals', 'PlayerAffairs\Controllers\MedicalApprovalController@index', ['auth'], 'player.approve_medical'],
['POST', '/medical-approvals/{id:\d+}/approve', 'PlayerAffairs\Controllers\MedicalApprovalController@approve', ['auth', 'csrf'], 'player.approve_medical'],
['POST', '/medical-approvals/{id:\d+}/reject', 'PlayerAffairs\Controllers\MedicalApprovalController@reject', ['auth', 'csrf'], 'player.approve_medical'],
// Attendance // Attendance
['GET', '/attendance', 'PlayerAffairs\Controllers\AttendanceController@index', ['auth'], 'player.view'], ['GET', '/attendance', 'PlayerAffairs\Controllers\AttendanceController@index', ['auth'], 'player.view'],
['POST', '/attendance/record', 'PlayerAffairs\Controllers\AttendanceController@record', ['auth', 'csrf'], 'player.edit'], ['POST', '/attendance/record', 'PlayerAffairs\Controllers\AttendanceController@record', ['auth', 'csrf'], 'player.edit'],
......
...@@ -10,9 +10,15 @@ use App\Modules\PlayerAffairs\Models\PlayerMedicalRecord; ...@@ -10,9 +10,15 @@ use App\Modules\PlayerAffairs\Models\PlayerMedicalRecord;
final class MedicalRecordService final class MedicalRecordService
{ {
/** public static function getCertificateTypes(): array
* Add a medical record for a player and update their medical status. {
*/ return [
'recreational' => ['label' => 'ممارس', 'validity_months' => 12],
'academy' => ['label' => 'أكاديمي', 'validity_months' => 6],
'international' => ['label' => 'دولي', 'validity_months' => 3],
];
}
public static function addRecord(int $playerId, array $data): PlayerMedicalRecord public static function addRecord(int $playerId, array $data): PlayerMedicalRecord
{ {
$player = Player::find($playerId); $player = Player::find($playerId);
...@@ -22,43 +28,107 @@ final class MedicalRecordService ...@@ -22,43 +28,107 @@ final class MedicalRecordService
$employee = App::getInstance()->currentEmployee(); $employee = App::getInstance()->currentEmployee();
$certType = $data['certificate_type'] ?? 'recreational';
$types = self::getCertificateTypes();
$validityMonths = $types[$certType]['validity_months'] ?? 12;
$examDate = $data['exam_date'] ?? date('Y-m-d');
$expiryDate = $data['expiry_date'] ?? date('Y-m-d', strtotime($examDate . ' +' . $validityMonths . ' months'));
$record = PlayerMedicalRecord::create([ $record = PlayerMedicalRecord::create([
'player_id' => $playerId, 'player_id' => $playerId,
'record_type' => $data['record_type'] ?? 'medical_exam', 'record_type' => $data['record_type'] ?? 'fitness_cert',
'exam_date' => $data['exam_date'] ?? date('Y-m-d'), 'certificate_type' => $certType,
'expiry_date' => $data['expiry_date'] ?? null, 'validity_months' => $validityMonths,
'exam_date' => $examDate,
'expiry_date' => $expiryDate,
'doctor_name' => $data['doctor_name'] ?? null, 'doctor_name' => $data['doctor_name'] ?? null,
'clinic_name' => $data['clinic_name'] ?? null, 'clinic_name' => $data['clinic_name'] ?? null,
'result' => $data['result'] ?? 'pending', 'issuing_authority' => $data['issuing_authority'] ?? null,
'cert_number' => $data['cert_number'] ?? null,
'result' => 'pending',
'approval_status' => 'pending',
'restrictions' => $data['restrictions'] ?? null, 'restrictions' => $data['restrictions'] ?? null,
'document_id' => $data['document_id'] ?? null, 'document_id' => $data['document_id'] ?? null,
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
'created_by' => $employee ? (int) $employee->id : null, 'created_by' => $employee ? (int) $employee->id : null,
]); ]);
// Update the player's medical status and expiry based on the new record EventBus::dispatch('player.medical_uploaded', [
$updateData = []; 'player_id' => $playerId,
if (!empty($data['result'])) { 'record_id' => $record->id,
$updateData['medical_status'] = $data['result']; 'certificate_type' => $certType,
]);
return $record;
}
public static function approveRecord(int $recordId, int $approvedBy): void
{
$db = App::getInstance()->db();
$record = $db->selectOne("SELECT * FROM player_medical_records WHERE id = ?", [$recordId]);
if (!$record) {
throw new \RuntimeException('السجل غير موجود');
}
$db->update('player_medical_records', [
'approval_status' => 'approved',
'approved_by' => $approvedBy,
'approved_at' => date('Y-m-d H:i:s'),
'result' => 'fit',
], 'id = ?', [$recordId]);
$player = Player::find((int) $record['player_id']);
if ($player) {
$player->update([
'medical_status' => 'fit',
'medical_expiry_date' => $record['expiry_date'],
]);
} }
if (!empty($data['expiry_date'])) {
$updateData['medical_expiry_date'] = $data['expiry_date']; EventBus::dispatch('player.medical_approved', [
'player_id' => (int) $record['player_id'],
'record_id' => $recordId,
]);
} }
if (!empty($updateData)) {
$player->update($updateData); public static function rejectRecord(int $recordId, int $rejectedBy, string $reason): void
{
$db = App::getInstance()->db();
$record = $db->selectOne("SELECT * FROM player_medical_records WHERE id = ?", [$recordId]);
if (!$record) {
throw new \RuntimeException('السجل غير موجود');
} }
EventBus::dispatch('player.medical_updated', [ $db->update('player_medical_records', [
'player_id' => $playerId, 'approval_status' => 'rejected',
'record_id' => $record->id, 'approved_by' => $rejectedBy,
'approved_at' => date('Y-m-d H:i:s'),
'rejection_reason' => $reason,
'result' => 'unfit',
], 'id = ?', [$recordId]);
EventBus::dispatch('player.medical_rejected', [
'player_id' => (int) $record['player_id'],
'record_id' => $recordId,
'reason' => $reason,
]); ]);
}
return $record; public static function getPendingApprovals(): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT pmr.*, p.full_name_ar AS player_name, p.id AS player_id
FROM player_medical_records pmr
LEFT JOIN players p ON p.id = pmr.player_id
WHERE pmr.approval_status = 'pending'
ORDER BY pmr.created_at DESC"
);
} }
/**
* Check if a player has valid medical clearance.
*/
public static function checkClearance(int $playerId): bool public static function checkClearance(int $playerId): bool
{ {
$player = Player::find($playerId); $player = Player::find($playerId);
...@@ -78,9 +148,6 @@ final class MedicalRecordService ...@@ -78,9 +148,6 @@ final class MedicalRecordService
return $player->medical_expiry_date > date('Y-m-d'); return $player->medical_expiry_date > date('Y-m-d');
} }
/**
* Get players whose medical records expire within the given number of days.
*/
public static function getExpiringRecords(int $daysAhead = 30): array public static function getExpiringRecords(int $daysAhead = 30): array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
......
<?php
use App\Modules\PlayerAffairs\Models\PlayerMedicalRecord;
$__template->layout('Layout.main');
$certTypes = PlayerMedicalRecord::getCertificateTypes();
$recordTypes = PlayerMedicalRecord::getRecordTypes();
?>
<?php $__template->section('title'); ?>اعتماد السجلات الطبية<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filter Tabs -->
<div style="display:flex;gap:8px;margin-bottom:20px;">
<a href="?filter=pending" class="btn <?= $filter === 'pending' ? 'btn-primary' : 'btn-outline' ?>" style="font-size:13px;">
<i data-lucide="clock" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> في الانتظار
</a>
<a href="?filter=approved" class="btn <?= $filter === 'approved' ? 'btn-primary' : 'btn-outline' ?>" style="font-size:13px;">
<i data-lucide="check-circle" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> معتمد
</a>
<a href="?filter=rejected" class="btn <?= $filter === 'rejected' ? 'btn-primary' : 'btn-outline' ?>" style="font-size:13px;">
<i data-lucide="x-circle" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> مرفوض
</a>
</div>
<?php if (empty($records)): ?>
<div class="card" style="padding:40px;text-align:center;">
<i data-lucide="clipboard-check" style="width:48px;height:48px;color:#D1D5DB;margin-bottom:12px;"></i>
<p style="color:#6B7280;font-size:15px;">لا توجد سجلات طبية <?= $filter === 'pending' ? 'في الانتظار' : ($filter === 'approved' ? 'معتمدة' : 'مرفوضة') ?></p>
</div>
<?php else: ?>
<div style="display:grid;gap:15px;">
<?php foreach ($records as $rec):
$recCertType = $rec['certificate_type'] ?? 'recreational';
$certLabel = $certTypes[$recCertType] ?? $recCertType;
$certColorMap = ['recreational' => '#2563EB', 'academy' => '#7C3AED', 'international' => '#DC2626'];
$certColor = $certColorMap[$recCertType] ?? '#6B7280';
$recTypeLabel = $recordTypes[$rec['record_type'] ?? ''] ?? ($rec['record_type'] ?? '—');
?>
<div class="card" style="padding:20px;border-right:4px solid <?= $certColor ?>;">
<div style="display:flex;justify-content:space-between;align-items:start;gap:20px;">
<!-- Player Info -->
<div style="display:flex;gap:15px;align-items:center;flex:1;">
<div style="width:50px;height:50px;border-radius:50%;background:#F3F4F6;display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0;">
<?php if (!empty($rec['photo_path'])): ?>
<img src="/<?= e($rec['photo_path']) ?>" style="width:100%;height:100%;object-fit:cover;">
<?php else: ?>
<i data-lucide="user" style="width:24px;height:24px;color:#9CA3AF;"></i>
<?php endif; ?>
</div>
<div>
<div style="font-size:15px;font-weight:700;color:#1A1A2E;margin-bottom:4px;">
<a href="/players/<?= (int) $rec['player_id'] ?>" style="color:inherit;text-decoration:none;"><?= e($rec['player_name'] ?? '—') ?></a>
</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;color:#6B7280;">
<?php if (!empty($rec['registration_serial'])): ?>
<span><i data-lucide="hash" style="width:11px;height:11px;vertical-align:middle;"></i> <?= e($rec['registration_serial']) ?></span>
<?php endif; ?>
<span><?= e($rec['player_type'] === 'member' ? 'عضو' : 'غير عضو') ?></span>
</div>
</div>
</div>
<!-- Certificate Details -->
<div style="flex:1;">
<div style="display:flex;gap:10px;align-items:center;margin-bottom:8px;">
<span style="display:inline-block;padding:4px 12px;border-radius:10px;font-size:12px;font-weight:600;background:<?= $certColor ?>15;color:<?= $certColor ?>;"><?= e($certLabel) ?></span>
<span style="font-size:13px;color:#374151;font-weight:600;"><?= e($recTypeLabel) ?></span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;font-size:12px;color:#6B7280;">
<span>تاريخ الفحص: <strong style="color:#374151;"><?= e($rec['exam_date'] ?? '—') ?></strong></span>
<span>الانتهاء: <strong style="color:#374151;"><?= e($rec['expiry_date'] ?? '—') ?></strong></span>
<?php if (!empty($rec['doctor_name'])): ?>
<span>الطبيب: <strong style="color:#374151;"><?= e($rec['doctor_name']) ?></strong></span>
<?php endif; ?>
<?php if (!empty($rec['issuing_authority'])): ?>
<span>جهة الإصدار: <strong style="color:#374151;"><?= e($rec['issuing_authority']) ?></strong></span>
<?php endif; ?>
<?php if (!empty($rec['cert_number'])): ?>
<span>رقم الشهادة: <strong style="color:#374151;"><?= e($rec['cert_number']) ?></strong></span>
<?php endif; ?>
<?php if (!empty($rec['clinic_name'])): ?>
<span>العيادة: <strong style="color:#374151;"><?= e($rec['clinic_name']) ?></strong></span>
<?php endif; ?>
</div>
<?php if (!empty($rec['notes'])): ?>
<div style="margin-top:6px;font-size:12px;color:#6B7280;font-style:italic;"><?= e($rec['notes']) ?></div>
<?php endif; ?>
<?php if ($filter === 'rejected' && !empty($rec['rejection_reason'])): ?>
<div style="margin-top:8px;padding:8px 12px;background:#FEF2F2;border:1px solid #FCA5A5;border-radius:6px;font-size:12px;color:#DC2626;">
<strong>سبب الرفض:</strong> <?= e($rec['rejection_reason']) ?>
</div>
<?php endif; ?>
</div>
<!-- Actions -->
<?php if ($filter === 'pending'): ?>
<div style="display:flex;flex-direction:column;gap:8px;flex-shrink:0;">
<form method="POST" action="/medical-approvals/<?= (int) $rec['id'] ?>/approve" style="margin:0;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-primary" style="font-size:12px;padding:8px 16px;width:100%;" onclick="return confirm('تأكيد اعتماد هذا السجل الطبي؟')">
<i data-lucide="check" style="width:13px;height:13px;vertical-align:middle;margin-left:4px;"></i> اعتماد
</button>
</form>
<button type="button" class="btn" style="font-size:12px;padding:8px 16px;color:#DC2626;border:1px solid #FCA5A5;background:#FEF2F2;border-radius:6px;cursor:pointer;width:100%;" onclick="toggleRejectForm(<?= (int) $rec['id'] ?>)">
<i data-lucide="x" style="width:13px;height:13px;vertical-align:middle;margin-left:4px;"></i> رفض
</button>
</div>
<?php endif; ?>
</div>
<?php if ($filter === 'pending'): ?>
<div id="rejectForm-<?= (int) $rec['id'] ?>" style="display:none;margin-top:15px;padding:15px;background:#FEF2F2;border:1px solid #FCA5A5;border-radius:8px;">
<form method="POST" action="/medical-approvals/<?= (int) $rec['id'] ?>/reject">
<?= csrf_field() ?>
<div class="form-group" style="margin:0 0 10px 0;">
<label class="form-label" style="font-size:12px;color:#DC2626;">سبب الرفض <span style="color:#DC2626;">*</span></label>
<textarea name="rejection_reason" class="form-input" rows="2" placeholder="أدخل سبب رفض السجل الطبي..." required></textarea>
</div>
<div style="display:flex;gap:8px;">
<button type="submit" class="btn btn-danger" style="font-size:12px;padding:6px 14px;" onclick="return confirm('تأكيد رفض هذا السجل الطبي؟')">تأكيد الرفض</button>
<button type="button" class="btn btn-outline" style="font-size:12px;padding:6px 14px;" onclick="toggleRejectForm(<?= (int) $rec['id'] ?>)">إلغاء</button>
</div>
</form>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<script>
function toggleRejectForm(id) {
var el = document.getElementById('rejectForm-' + id);
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
...@@ -25,6 +25,8 @@ $typeLabel = ($playerTypes[$playerType] ?? $playerType); ...@@ -25,6 +25,8 @@ $typeLabel = ($playerTypes[$playerType] ?? $playerType);
$typeColor = $playerType === 'member' ? '#0284C7' : '#7C3AED'; $typeColor = $playerType === 'member' ? '#0284C7' : '#7C3AED';
$age = $player->getAge(); $age = $player->getAge();
$recordTypes = PlayerMedicalRecord::getRecordTypes(); $recordTypes = PlayerMedicalRecord::getRecordTypes();
$certTypes = PlayerMedicalRecord::getCertificateTypes();
$approvalStatuses = PlayerMedicalRecord::getApprovalStatuses();
$resultTypes = PlayerMedicalRecord::getResults(); $resultTypes = PlayerMedicalRecord::getResults();
$enrollmentStatuses = AcademyEnrollment::getStatuses(); $enrollmentStatuses = AcademyEnrollment::getStatuses();
?> ?>
...@@ -394,9 +396,9 @@ $enrollmentStatuses = AcademyEnrollment::getStatuses(); ...@@ -394,9 +396,9 @@ $enrollmentStatuses = AcademyEnrollment::getStatuses();
<!-- Add Medical Record Inline Form (hidden by default) --> <!-- Add Medical Record Inline Form (hidden by default) -->
<div id="addMedicalForm" style="display:none;padding:20px;background:#F9FAFB;border-bottom:1px solid #E5E7EB;"> <div id="addMedicalForm" style="display:none;padding:20px;background:#F9FAFB;border-bottom:1px solid #E5E7EB;">
<form method="POST" action="/players/<?= (int) $player->id ?>/medical"> <form method="POST" action="/players/<?= (int) $player->id ?>/medical" enctype="multipart/form-data">
<?= csrf_field() ?> <?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;"> <div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:15px;">
<div class="form-group" style="margin:0;"> <div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">نوع السجل <span style="color:#DC2626;">*</span></label> <label class="form-label" style="font-size:12px;">نوع السجل <span style="color:#DC2626;">*</span></label>
<select name="record_type" class="form-input" required> <select name="record_type" class="form-input" required>
...@@ -406,6 +408,14 @@ $enrollmentStatuses = AcademyEnrollment::getStatuses(); ...@@ -406,6 +408,14 @@ $enrollmentStatuses = AcademyEnrollment::getStatuses();
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">نوع الشهادة <span style="color:#DC2626;">*</span></label>
<select name="certificate_type" class="form-input" required>
<?php foreach ($certTypes as $ctKey => $ctLabel): ?>
<option value="<?= e($ctKey) ?>"><?= e($ctLabel) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="margin:0;"> <div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">تاريخ الفحص <span style="color:#DC2626;">*</span></label> <label class="form-label" style="font-size:12px;">تاريخ الفحص <span style="color:#DC2626;">*</span></label>
<input type="date" name="exam_date" class="form-input" required> <input type="date" name="exam_date" class="form-input" required>
...@@ -413,9 +423,10 @@ $enrollmentStatuses = AcademyEnrollment::getStatuses(); ...@@ -413,9 +423,10 @@ $enrollmentStatuses = AcademyEnrollment::getStatuses();
<div class="form-group" style="margin:0;"> <div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">تاريخ الانتهاء</label> <label class="form-label" style="font-size:12px;">تاريخ الانتهاء</label>
<input type="date" name="expiry_date" class="form-input"> <input type="date" name="expiry_date" class="form-input">
<span style="font-size:10px;color:#9CA3AF;">يحسب تلقائياً إذا تُرك فارغاً</span>
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;margin-top:15px;"> <div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:15px;margin-top:15px;">
<div class="form-group" style="margin:0;"> <div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">اسم الطبيب</label> <label class="form-label" style="font-size:12px;">اسم الطبيب</label>
<input type="text" name="doctor_name" class="form-input" placeholder="اسم الطبيب"> <input type="text" name="doctor_name" class="form-input" placeholder="اسم الطبيب">
...@@ -425,13 +436,12 @@ $enrollmentStatuses = AcademyEnrollment::getStatuses(); ...@@ -425,13 +436,12 @@ $enrollmentStatuses = AcademyEnrollment::getStatuses();
<input type="text" name="clinic_name" class="form-input" placeholder="اسم المكان"> <input type="text" name="clinic_name" class="form-input" placeholder="اسم المكان">
</div> </div>
<div class="form-group" style="margin:0;"> <div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">النتيجة <span style="color:#DC2626;">*</span></label> <label class="form-label" style="font-size:12px;">جهة الإصدار</label>
<select name="result" class="form-input" required> <input type="text" name="issuing_authority" class="form-input" placeholder="جهة إصدار الشهادة">
<option value="">-- اختر --</option> </div>
<?php foreach ($resultTypes as $resKey => $resLabel): ?> <div class="form-group" style="margin:0;">
<option value="<?= e($resKey) ?>"><?= e($resLabel) ?></option> <label class="form-label" style="font-size:12px;">رقم الشهادة</label>
<?php endforeach; ?> <input type="text" name="cert_number" class="form-input" placeholder="رقم الشهادة" style="direction:ltr;text-align:left;">
</select>
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-top:15px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-top:15px;">
...@@ -444,9 +454,12 @@ $enrollmentStatuses = AcademyEnrollment::getStatuses(); ...@@ -444,9 +454,12 @@ $enrollmentStatuses = AcademyEnrollment::getStatuses();
<textarea name="notes" class="form-input" rows="2" placeholder="ملاحظات إضافية..."></textarea> <textarea name="notes" class="form-input" rows="2" placeholder="ملاحظات إضافية..."></textarea>
</div> </div>
</div> </div>
<div style="margin-top:15px;padding:12px;background:#FEF3C7;border:1px solid #FDE68A;border-radius:8px;">
<span style="font-size:12px;color:#92400E;"><i data-lucide="info" style="width:13px;height:13px;vertical-align:middle;margin-left:4px;"></i> بعد الرفع سيتم إرسال السجل لمجلس الأمناء للاعتماد</span>
</div>
<div style="margin-top:15px;display:flex;gap:8px;"> <div style="margin-top:15px;display:flex;gap:8px;">
<button type="submit" class="btn btn-primary" style="font-size:13px;padding:8px 20px;"> <button type="submit" class="btn btn-primary" style="font-size:13px;padding:8px 20px;">
<i data-lucide="check" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> حفظ السجل <i data-lucide="upload" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> رفع السجل الطبي
</button> </button>
<button type="button" class="btn btn-outline" style="font-size:13px;padding:8px 20px;" onclick="toggleMedicalForm()">إلغاء</button> <button type="button" class="btn btn-outline" style="font-size:13px;padding:8px 20px;" onclick="toggleMedicalForm()">إلغاء</button>
</div> </div>
...@@ -459,26 +472,31 @@ $enrollmentStatuses = AcademyEnrollment::getStatuses(); ...@@ -459,26 +472,31 @@ $enrollmentStatuses = AcademyEnrollment::getStatuses();
<thead> <thead>
<tr style="background:#F9FAFB;border-bottom:2px solid #E5E7EB;"> <tr style="background:#F9FAFB;border-bottom:2px solid #E5E7EB;">
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">نوع السجل</th> <th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">نوع السجل</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">نوع الشهادة</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">تاريخ الفحص</th> <th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">تاريخ الفحص</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">تاريخ الانتهاء</th> <th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">تاريخ الانتهاء</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">النتيجة</th> <th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">حالة الاعتماد</th>
<th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">الطبيب</th> <th style="padding:12px 15px;text-align:right;font-weight:600;color:#374151;">الطبيب</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($medicalRecords as $rec): <?php foreach ($medicalRecords as $rec):
$recType = $rec['record_type'] ?? ''; $recType = $rec['record_type'] ?? '';
$recResult = $rec['result'] ?? ''; $recCertType = $rec['certificate_type'] ?? 'recreational';
$resultLabel = $resultTypes[$recResult] ?? $recResult; $recApproval = $rec['approval_status'] ?? 'pending';
$resultColorMap = ['fit' => '#059669', 'conditional' => '#D97706', 'unfit' => '#DC2626']; $approvalColorMap = ['pending' => '#D97706', 'approved' => '#059669', 'rejected' => '#DC2626'];
$resultColor = $resultColorMap[$recResult] ?? '#6B7280'; $approvalColor = $approvalColorMap[$recApproval] ?? '#6B7280';
?> ?>
<tr style="border-bottom:1px solid #F3F4F6;"> <tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:12px 15px;font-weight:600;"><?= e($recordTypes[$recType] ?? $recType) ?></td> <td style="padding:12px 15px;font-weight:600;"><?= e($recordTypes[$recType] ?? $recType) ?></td>
<td style="padding:12px 15px;"><?= e($certTypes[$recCertType] ?? $recCertType) ?></td>
<td style="padding:12px 15px;"><?= e($rec['exam_date'] ?? '—') ?></td> <td style="padding:12px 15px;"><?= e($rec['exam_date'] ?? '—') ?></td>
<td style="padding:12px 15px;"><?= e($rec['expiry_date'] ?? '—') ?></td> <td style="padding:12px 15px;"><?= e($rec['expiry_date'] ?? '—') ?></td>
<td style="padding:12px 15px;"> <td style="padding:12px 15px;">
<span style="display:inline-block;padding:4px 12px;border-radius:10px;font-size:12px;font-weight:600;background:<?= $resultColor ?>15;color:<?= $resultColor ?>;"><?= e($resultLabel) ?></span> <span style="display:inline-block;padding:4px 12px;border-radius:10px;font-size:12px;font-weight:600;background:<?= $approvalColor ?>15;color:<?= $approvalColor ?>;"><?= e($approvalStatuses[$recApproval] ?? $recApproval) ?></span>
<?php if ($recApproval === 'rejected' && !empty($rec['rejection_reason'])): ?>
<div style="font-size:11px;color:#DC2626;margin-top:4px;"><?= e($rec['rejection_reason']) ?></div>
<?php endif; ?>
</td> </td>
<td style="padding:12px 15px;"><?= e($rec['doctor_name'] ?? '—') ?></td> <td style="padding:12px 15px;"><?= e($rec['doctor_name'] ?? '—') ?></td>
</tr> </tr>
......
...@@ -11,6 +11,7 @@ PermissionRegistry::register('player_affairs', [ ...@@ -11,6 +11,7 @@ PermissionRegistry::register('player_affairs', [
'player.manage_card' => ['ar' => 'إدارة كارنيه اللاعب', 'en' => 'Manage Player Card'], 'player.manage_card' => ['ar' => 'إدارة كارنيه اللاعب', 'en' => 'Manage Player Card'],
'player.view_medical' => ['ar' => 'عرض السجل الطبي', 'en' => 'View Medical Records'], 'player.view_medical' => ['ar' => 'عرض السجل الطبي', 'en' => 'View Medical Records'],
'player.manage_medical' => ['ar' => 'إدارة السجل الطبي', 'en' => 'Manage Medical Records'], 'player.manage_medical' => ['ar' => 'إدارة السجل الطبي', 'en' => 'Manage Medical Records'],
'player.approve_medical' => ['ar' => 'اعتماد السجل الطبي', 'en' => 'Approve Medical Records'],
'player.record_attendance' => ['ar' => 'تسجيل الحضور', 'en' => 'Record Attendance'], 'player.record_attendance' => ['ar' => 'تسجيل الحضور', 'en' => 'Record Attendance'],
]); ]);
......
<?php
declare(strict_types=1);
namespace App\Modules\Reservations\Services;
use App\Core\App;
final class FacilityPricingService
{
private static ?array $memberPricing = null;
private static ?array $nonMemberPricing = null;
private static ?array $generalFees = null;
public static function calculateRate(int $facilityId, bool $isMember, string $timeTier = 'AM'): ?float
{
$db = App::getInstance()->db();
$facility = $db->selectOne("SELECT * FROM facilities WHERE id = ?", [$facilityId]);
if (!$facility) {
return null;
}
$rateCol = $timeTier === 'PM'
? ($isMember ? 'hourly_rate_member_pm' : 'hourly_rate_nonmember_pm')
: ($isMember ? 'hourly_rate_member' : 'hourly_rate_nonmember');
$directRate = (float) ($facility[$rateCol] ?? 0);
if ($directRate > 0) {
return $directRate;
}
$facilityType = $facility['facility_type'] ?? '';
if (empty($facilityType)) {
return null;
}
$pricing = $isMember ? self::loadMemberPricing() : self::loadNonMemberPricing();
foreach ($pricing as $rule) {
if (($rule['facility_type'] ?? '') === $facilityType) {
return (float) ($timeTier === 'PM'
? ($rule['price_evening'] ?? 0)
: ($rule['price_morning'] ?? 0));
}
}
return null;
}
public static function getEntryTicketPrice(string $dayType, bool $isAbove12): ?float
{
$db = App::getInstance()->db();
$json = $db->selectOne(
"SELECT config_value FROM system_config WHERE config_key = 'entry_tickets_non_member'"
);
if (!$json) {
return null;
}
$tickets = json_decode($json['config_value'], true) ?: [];
foreach ($tickets as $ticket) {
if (($ticket['day_type'] ?? '') === $dayType) {
return (float) ($isAbove12 ? $ticket['age_above_12'] : $ticket['age_6_to_12']);
}
}
return null;
}
public static function getGeneralFee(string $feeCode): ?float
{
$fees = self::loadGeneralFees();
foreach ($fees as $fee) {
if (($fee['code'] ?? '') === $feeCode) {
return (float) ($fee['amount'] ?? 0);
}
}
return null;
}
public static function getAllPricingForFacilityType(string $facilityType): array
{
$memberPricing = self::loadMemberPricing();
$nonMemberPricing = self::loadNonMemberPricing();
$result = ['member' => null, 'non_member' => null];
foreach ($memberPricing as $rule) {
if (($rule['facility_type'] ?? '') === $facilityType) {
$result['member'] = $rule;
break;
}
}
foreach ($nonMemberPricing as $rule) {
if (($rule['facility_type'] ?? '') === $facilityType) {
$result['non_member'] = $rule;
break;
}
}
return $result;
}
private static function loadMemberPricing(): array
{
if (self::$memberPricing !== null) {
return self::$memberPricing;
}
$db = App::getInstance()->db();
$row = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = 'sports_pricing_member'");
self::$memberPricing = $row ? (json_decode($row['config_value'], true) ?: []) : [];
return self::$memberPricing;
}
private static function loadNonMemberPricing(): array
{
if (self::$nonMemberPricing !== null) {
return self::$nonMemberPricing;
}
$db = App::getInstance()->db();
$row = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = 'sports_pricing_non_member'");
self::$nonMemberPricing = $row ? (json_decode($row['config_value'], true) ?: []) : [];
return self::$nonMemberPricing;
}
private static function loadGeneralFees(): array
{
if (self::$generalFees !== null) {
return self::$generalFees;
}
$db = App::getInstance()->db();
$row = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = 'general_fees'");
self::$generalFees = $row ? (json_decode($row['config_value'], true) ?: []) : [];
return self::$generalFees;
}
}
...@@ -6,7 +6,6 @@ namespace App\Modules\Reservations\Services; ...@@ -6,7 +6,6 @@ namespace App\Modules\Reservations\Services;
use App\Core\App; use App\Core\App;
use App\Core\EventBus; use App\Core\EventBus;
use App\Modules\Reservations\Models\Reservation; use App\Modules\Reservations\Models\Reservation;
use App\Modules\Facilities\Models\Facility;
use App\Modules\Rules\Services\RuleEngine; use App\Modules\Rules\Services\RuleEngine;
class ReservationService class ReservationService
...@@ -49,10 +48,14 @@ class ReservationService ...@@ -49,10 +48,14 @@ class ReservationService
// Calculate unit rate and total amount if facility is set // Calculate unit rate and total amount if facility is set
if (!empty($data['facility_id']) && empty($data['unit_rate'])) { if (!empty($data['facility_id']) && empty($data['unit_rate'])) {
$facility = Facility::find((int) $data['facility_id']);
if ($facility) {
$isMember = in_array($data['booker_type'] ?? '', ['member', 'player']); $isMember = in_array($data['booker_type'] ?? '', ['member', 'player']);
$data['unit_rate'] = $facility->getRate($isMember, $data['time_tier']); $rate = FacilityPricingService::calculateRate(
(int) $data['facility_id'],
$isMember,
$data['time_tier']
);
if ($rate !== null) {
$data['unit_rate'] = $rate;
} }
} }
......
<?php
declare(strict_types=1);
return [
'up' => "CREATE TABLE IF NOT EXISTS `mirror_grids` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`facility_id` BIGINT UNSIGNED NOT NULL,
`name_ar` VARCHAR(300) NOT NULL,
`rows_count` INT UNSIGNED NOT NULL DEFAULT 4,
`cols_count` INT UNSIGNED NOT NULL DEFAULT 6,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
INDEX `idx_mg_facility` (`facility_id`),
CONSTRAINT `fk_mg_facility` FOREIGN KEY (`facility_id`) REFERENCES `facilities`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS `mirror_grids`",
];
<?php
declare(strict_types=1);
return [
'up' => "CREATE TABLE IF NOT EXISTS `mirror_boxes` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`grid_id` BIGINT UNSIGNED NOT NULL,
`row_index` INT UNSIGNED NOT NULL,
`col_index` INT UNSIGNED NOT NULL,
`label` VARCHAR(100) NULL,
`coach_id` BIGINT UNSIGNED NULL,
`coach_name` VARCHAR(300) NULL COMMENT 'Manual name if no coach entity',
`academy_id` BIGINT UNSIGNED NULL,
`max_trainees` INT UNSIGNED NOT NULL DEFAULT 5,
`age_group` VARCHAR(50) NULL COMMENT 'e.g. 4-6, 7-9, 10-12',
`gender` VARCHAR(10) NULL COMMENT 'male, female, mixed',
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_mb_grid` (`grid_id`),
UNIQUE KEY `uq_mb_position` (`grid_id`, `row_index`, `col_index`),
CONSTRAINT `fk_mb_grid` FOREIGN KEY (`grid_id`) REFERENCES `mirror_grids`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS `mirror_boxes`",
];
<?php
declare(strict_types=1);
return [
'up' => "CREATE TABLE IF NOT EXISTS `mirror_box_trainees` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`box_id` BIGINT UNSIGNED NOT NULL,
`player_id` BIGINT UNSIGNED NULL,
`trainee_name` VARCHAR(300) NULL COMMENT 'Manual name if no player entity',
`assigned_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`assigned_by` BIGINT UNSIGNED NULL,
INDEX `idx_mbt_box` (`box_id`),
INDEX `idx_mbt_player` (`player_id`),
CONSTRAINT `fk_mbt_box` FOREIGN KEY (`box_id`) REFERENCES `mirror_boxes`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_mbt_player` FOREIGN KEY (`player_id`) REFERENCES `players`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS `mirror_box_trainees`",
];
<?php
declare(strict_types=1);
return [
'up' => "ALTER TABLE `player_medical_records`
ADD COLUMN `approval_status` VARCHAR(30) NOT NULL DEFAULT 'pending' COMMENT 'pending, approved, rejected' AFTER `result`,
ADD COLUMN `approved_by` BIGINT UNSIGNED NULL AFTER `approval_status`,
ADD COLUMN `approved_at` TIMESTAMP NULL AFTER `approved_by`,
ADD COLUMN `rejection_reason` VARCHAR(500) NULL AFTER `approved_at`,
ADD INDEX `idx_pmr_approval` (`approval_status`)",
'down' => "ALTER TABLE `player_medical_records`
DROP INDEX `idx_pmr_approval`,
DROP COLUMN `rejection_reason`,
DROP COLUMN `approved_at`,
DROP COLUMN `approved_by`,
DROP COLUMN `approval_status`",
];
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* تسعير إيجارات الملاعب والصالات الرياضية بنادي شيراتون
* طبقاً للائحة المعتمدة من مجلس الأمناء - أبريل 2025
* يطبق فقط على: الحجز الحر + إدارة النادي للنشاط (غير الأكاديمي)
*/
return function (Database $db): void {
// تأكد من وجود جدول service_catalog
$tableExists = $db->selectOne(
"SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'service_catalog'"
);
if (!$tableExists) {
return;
}
// تسعير إيجار الملاعب للأعضاء (ثانياً من اللائحة)
$memberPricing = [
['facility_type' => 'football_full', 'name_ar' => 'كرة قدم قانوني', 'duration_minutes' => 60, 'price_morning' => 300, 'price_evening' => 500, 'max_players' => 22],
['facility_type' => 'football_five', 'name_ar' => 'كرة قدم خماسي', 'duration_minutes' => 60, 'price_morning' => 150, 'price_evening' => 200, 'max_players' => 12],
['facility_type' => 'tennis_clay', 'name_ar' => 'تنس أرضي', 'duration_minutes' => 60, 'price_morning' => 75, 'price_evening' => 100, 'max_players' => 4],
['facility_type' => 'padel', 'name_ar' => 'بادي تنس', 'duration_minutes' => 60, 'price_morning' => 100, 'price_evening' => 150, 'max_players' => 4],
['facility_type' => 'squash', 'name_ar' => 'الاسكواش', 'duration_minutes' => 60, 'price_morning' => 75, 'price_evening' => 75, 'max_players' => 2],
['facility_type' => 'bowling', 'name_ar' => 'البولينج', 'duration_minutes' => 0, 'price_morning' => 30, 'price_evening' => 30, 'max_players' => 5],
['facility_type' => 'multi_court', 'name_ar' => 'ملعب متعدد', 'duration_minutes' => 60, 'price_morning' => 50, 'price_evening' => 100, 'max_players' => 15],
['facility_type' => 'table_tennis', 'name_ar' => 'تنس طاولة', 'duration_minutes' => 60, 'price_morning' => 50, 'price_evening' => 50, 'max_players' => 4],
['facility_type' => 'playstation', 'name_ar' => 'بلاي ستيشن', 'duration_minutes' => 60, 'price_morning' => 60, 'price_evening' => 60, 'max_players' => 4],
['facility_type' => 'billiards', 'name_ar' => 'البلياردو', 'duration_minutes' => 60, 'price_morning' => 60, 'price_evening' => 60, 'max_players' => 4],
['facility_type' => 'gym', 'name_ar' => 'صالة اللياقات', 'duration_minutes' => 60, 'price_morning' => 300, 'price_evening' => 300, 'max_players' => 10],
];
// تسعير إيجار الملاعب لغير الأعضاء (رابعاً من اللائحة - ممارسة)
$nonMemberPricing = [
['facility_type' => 'football_full', 'name_ar' => 'كرة قدم قانوني', 'duration_minutes' => 60, 'price_morning' => 200, 'price_evening' => 300, 'max_players' => 22],
['facility_type' => 'football_five', 'name_ar' => 'كرة قدم خماسي', 'duration_minutes' => 60, 'price_morning' => 150, 'price_evening' => 150, 'max_players' => 12],
['facility_type' => 'tennis_clay', 'name_ar' => 'تنس أرضي', 'duration_minutes' => 60, 'price_morning' => 100, 'price_evening' => 150, 'max_players' => 4],
['facility_type' => 'padel', 'name_ar' => 'بادي تنس', 'duration_minutes' => 60, 'price_morning' => 200, 'price_evening' => 300, 'max_players' => 4],
['facility_type' => 'squash', 'name_ar' => 'الاسكواش', 'duration_minutes' => 60, 'price_morning' => 150, 'price_evening' => 150, 'max_players' => 2],
['facility_type' => 'tennis_table', 'name_ar' => 'تنس طاولة', 'duration_minutes' => 60, 'price_morning' => 60, 'price_evening' => 60, 'max_players' => 4],
['facility_type' => 'bowling', 'name_ar' => 'البولينج', 'duration_minutes' => 0, 'price_morning' => 60, 'price_evening' => 60, 'max_players' => 5],
['facility_type' => 'billiards', 'name_ar' => 'البلياردو', 'duration_minutes' => 60, 'price_morning' => 100, 'price_evening' => 100, 'max_players' => 4],
['facility_type' => 'playstation', 'name_ar' => 'بلاي ستيشن', 'duration_minutes' => 30, 'price_morning' => 60, 'price_evening' => 60, 'max_players' => 4],
];
// تذاكر الدخول لغير الأعضاء (أولاً من اللائحة)
$entryTickets = [
['day_type' => 'weekend', 'age_above_12' => 70, 'age_6_to_12' => 40, 'notes' => 'أيام الجمعة والسبت والعطلات والأعياد الرسمية'],
['day_type' => 'weekday', 'age_above_12' => 50, 'age_6_to_12' => 30, 'notes' => 'من يوم الأحد إلى الخميس ماعدا العطلات والأعياد الرسمية'],
];
// رسوم عامة (من ضوابط مالية)
$generalFees = [
['code' => 'HALL_RENT_SMALL_MEMBER', 'name_ar' => 'إيجار القاعة الصغيرة للعضو', 'amount' => 300, 'unit' => 'per_hour'],
['code' => 'HALL_RENT_SMALL_NON_MEMBER', 'name_ar' => 'إيجار القاعة الصغيرة لغير العضو', 'amount' => 600, 'unit' => 'per_hour'],
['code' => 'HALL_RENT_LARGE_MEMBER', 'name_ar' => 'إيجار القاعة الكبيرة للعضو', 'amount' => 500, 'unit' => 'per_hour'],
['code' => 'HALL_RENT_LARGE_NON_MEMBER', 'name_ar' => 'إيجار القاعة الكبيرة لغير العضو', 'amount' => 1000, 'unit' => 'per_hour'],
['code' => 'CAMERA_ENTRY_MEMBER', 'name_ar' => 'رسم دخول كاميرا فيديو للعضو', 'amount' => 100, 'unit' => 'per_hour'],
['code' => 'CAMERA_ENTRY_NON_MEMBER', 'name_ar' => 'رسم دخول كاميرا فيديو لغير العضو', 'amount' => 200, 'unit' => 'per_hour'],
['code' => 'PHOTO_CAMERA_MEMBER', 'name_ar' => 'رسم دخول كاميرا تصوير للعضو', 'amount' => 50, 'unit' => 'per_hour'],
['code' => 'PHOTO_CAMERA_NON_MEMBER', 'name_ar' => 'رسم دخول كاميرا تصوير لغير العضو', 'amount' => 100, 'unit' => 'per_hour'],
['code' => 'DJ_ENTRY_MEMBER', 'name_ar' => 'رسم دخول دي جي للعضو', 'amount' => 100, 'unit' => 'per_hour'],
['code' => 'DJ_ENTRY_NON_MEMBER', 'name_ar' => 'رسم دخول دي جي لغير العضو', 'amount' => 200, 'unit' => 'per_hour'],
['code' => 'PUPPET_SHOW_MEMBER', 'name_ar' => 'شو عرائس وألعاب أطفال للعضو', 'amount' => 75, 'unit' => 'per_hour'],
['code' => 'PUPPET_SHOW_NON_MEMBER', 'name_ar' => 'شو عرائس وألعاب أطفال لغير العضو', 'amount' => 150, 'unit' => 'per_hour'],
['code' => 'FOOD_CART', 'name_ar' => 'رسم دخول عربة (غزل بنات/مشار/آيس كريم)', 'amount' => 200, 'unit' => 'per_day'],
['code' => 'THEATER_RENT_MEMBER', 'name_ar' => 'إيجار المسرح الرومانى للعضو', 'amount' => 500, 'unit' => 'per_hour'],
['code' => 'THEATER_RENT_NON_MEMBER', 'name_ar' => 'إيجار المسرح الرومانى لغير العضو', 'amount' => 1000, 'unit' => 'per_hour'],
['code' => 'SPECIAL_EVENT', 'name_ar' => 'أحداث ذات طابع خاص (تصوير/برامج تلفزيونية)', 'amount' => 7000, 'unit' => 'per_hour'],
['code' => 'SPECIAL_EVENT_12HR', 'name_ar' => 'أحداث ذات طابع خاص (12 ساعة)', 'amount' => 70000, 'unit' => 'per_12_hours'],
['code' => 'WAITING_AREA_MEMBER', 'name_ar' => 'رسم ساحة الانتظار للعضو (3 ساعات)', 'amount' => 10, 'unit' => 'per_day'],
['code' => 'WAITING_AREA_NON_MEMBER', 'name_ar' => 'رسم ساحة الانتظار لغير العضو (3 ساعات)', 'amount' => 20, 'unit' => 'per_day'],
['code' => 'ACADEMY_CARD_FEE', 'name_ar' => 'بدل فاقد كارنيه الأكاديمية', 'amount' => 50, 'unit' => 'one_time'],
['code' => 'MEMBERSHIP_CARD_FEE', 'name_ar' => 'بدل فاقد كارنيه العضوية', 'amount' => 100, 'unit' => 'one_time'],
['code' => 'PARKING_FEE', 'name_ar' => 'غسيل السيارة', 'amount' => 50, 'unit' => 'per_wash'],
];
// إيجار طاولات بالنادي
$tableRentals = [
['location' => 'inside_main', 'name_ar' => 'داخل المبنى الرئيسي', 'price_day' => 300, 'price_week' => 1500, 'price_month' => 5400],
['location' => 'open_court', 'name_ar' => 'الملاعب المفتوحة', 'price_day' => 300, 'price_week' => 1500, 'price_month' => 5400],
['location' => 'pool_area', 'name_ar' => 'حمام السباحة', 'price_day' => 400, 'price_week' => 2000, 'price_month' => 7200],
];
// حجز اللوكر
$lockerPricing = [
['period' => 'monthly', 'amount' => 150],
['period' => 'quarterly', 'amount' => 450],
['period' => 'yearly', 'amount' => 1000],
];
// تراك الدرجات
$trackPricing = [
['period' => 'monthly', 'amount' => 100],
['period' => 'semi_annual', 'amount' => 300],
['period' => 'yearly', 'amount' => 600],
];
// رسوم حمام السباحة (للمؤسسات)
$poolInstitutional = [
['name_ar' => 'حمام سباحة 25 متر', 'price_morning' => 500, 'price_evening' => 800, 'max_players' => 8],
['name_ar' => 'حمام سباحة 50 متر', 'price_morning' => 800, 'price_evening' => 1000, 'max_players' => 12],
];
// حفظ في system_config كـ JSON
$configs = [
['config_key' => 'sports_pricing_member', 'config_value' => json_encode($memberPricing, JSON_UNESCAPED_UNICODE), 'category' => 'sports_pricing', 'description' => 'تسعير الملاعب للأعضاء - لائحة 2025'],
['config_key' => 'sports_pricing_non_member', 'config_value' => json_encode($nonMemberPricing, JSON_UNESCAPED_UNICODE), 'category' => 'sports_pricing', 'description' => 'تسعير الملاعب لغير الأعضاء - لائحة 2025'],
['config_key' => 'entry_tickets_non_member', 'config_value' => json_encode($entryTickets, JSON_UNESCAPED_UNICODE), 'category' => 'sports_pricing', 'description' => 'تذاكر الدخول لغير الأعضاء'],
['config_key' => 'general_fees', 'config_value' => json_encode($generalFees, JSON_UNESCAPED_UNICODE), 'category' => 'sports_pricing', 'description' => 'الرسوم العامة - لائحة 2025'],
['config_key' => 'table_rentals', 'config_value' => json_encode($tableRentals, JSON_UNESCAPED_UNICODE), 'category' => 'sports_pricing', 'description' => 'إيجار الطاولات بالنادي'],
['config_key' => 'locker_pricing', 'config_value' => json_encode($lockerPricing, JSON_UNESCAPED_UNICODE), 'category' => 'sports_pricing', 'description' => 'حجز اللوكر'],
['config_key' => 'track_pricing', 'config_value' => json_encode($trackPricing, JSON_UNESCAPED_UNICODE), 'category' => 'sports_pricing', 'description' => 'تراك الدرجات'],
['config_key' => 'pool_institutional', 'config_value' => json_encode($poolInstitutional, JSON_UNESCAPED_UNICODE), 'category' => 'sports_pricing', 'description' => 'رسوم حمام السباحة للمؤسسات'],
];
foreach ($configs as $config) {
$exists = $db->selectOne(
"SELECT id FROM system_config WHERE config_key = ?",
[$config['config_key']]
);
if ($exists) {
$db->update('system_config', [
'config_value' => $config['config_value'],
'description' => $config['description'],
], 'config_key = ?', [$config['config_key']]);
} else {
$db->insert('system_config', $config);
}
}
};
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