Commit f21af8c5 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add interactive pool grid management system

Lanes × time slots grid with drag selection, bulk assign (training/blocked/maintenance),
bulk clear, date navigation, and real-time AJAX state loading.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 65ee2e2a
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Controllers\Api;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\SportsActivity\Services\PoolGridService;
class PoolGridApiController extends Controller
{
public function state(Request $request, string $id): Response
{
$date = $request->get('date', date('Y-m-d'));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $this->json(['error' => 'تاريخ غير صالح'], 400);
}
$state = PoolGridService::getGridState((int) $id, $date);
if (isset($state['error'])) {
return $this->json(['error' => $state['error']], 404);
}
return $this->json($state);
}
public function assign(Request $request, string $id): Response
{
$body = $request->jsonBody();
$date = trim((string) ($body['date'] ?? ''));
$action = trim((string) ($body['action'] ?? ''));
$cells = $body['cells'] ?? [];
$groupId = !empty($body['group_id']) ? (int) $body['group_id'] : null;
$notes = trim((string) ($body['notes'] ?? ''));
if (!$date || !$action || empty($cells)) {
return $this->json(['error' => 'بيانات ناقصة'], 400);
}
$result = PoolGridService::bulkAssign((int) $id, $date, $action, $cells, $groupId, $notes ?: null);
return $this->json($result, $result['success'] ? 200 : 422);
}
public function clear(Request $request, string $id): Response
{
$body = $request->jsonBody();
$date = trim((string) ($body['date'] ?? ''));
$cells = $body['cells'] ?? [];
if (!$date || empty($cells)) {
return $this->json(['error' => 'بيانات ناقصة'], 400);
}
$result = PoolGridService::bulkClear((int) $id, $date, $cells);
return $this->json($result);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\SportsActivity\Services\PoolGridService;
class PoolGridController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$facilities = $db->select(
"SELECT f.*, d.name_ar as discipline_name
FROM sa_facilities f
LEFT JOIN sa_disciplines d ON d.id = f.discipline_id
WHERE f.is_active = 1 AND f.is_archived = 0
AND f.facility_type = 'pool'
ORDER BY f.name_ar",
[]
);
if (count($facilities) === 1) {
return $this->redirect('/sa/pool-grid/' . $facilities[0]['id']);
}
return $this->view('SportsActivity.Views.pool-grid.index', [
'facilities' => $facilities,
]);
}
public function manage(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$date = $request->get('date', date('Y-m-d'));
$facility = $db->selectOne(
"SELECT * FROM sa_facilities WHERE id = ? AND is_active = 1 AND is_archived = 0",
[(int) $id]
);
if (!$facility) {
return $this->redirect('/sa/pool-grid')->withError('المرفق غير موجود');
}
$groups = $db->select(
"SELECT g.id, g.code, g.name_ar, g.current_count, g.max_capacity,
c.full_name_ar as coach_name, p.name_ar as program_name
FROM sa_groups g
LEFT JOIN sa_coaches c ON c.id = g.coach_id
LEFT JOIN sa_programs p ON p.id = g.program_id
WHERE g.status = 'active' AND g.is_archived = 0
ORDER BY g.name_ar",
[]
);
return $this->view('SportsActivity.Views.pool-grid.manage', [
'facility' => $facility,
'groups' => $groups,
'date' => $date,
]);
}
}
......@@ -138,10 +138,17 @@ return [
['POST', '/sa/waitlist/{id:\d+}/offer', 'SportsActivity\Controllers\WaitlistController@offer', ['auth', 'csrf'], 'sa.waitlist.manage'],
['POST', '/sa/waitlist/{id:\d+}/cancel', 'SportsActivity\Controllers\WaitlistController@cancel', ['auth', 'csrf'], 'sa.waitlist.manage'],
// Pool Grid Management
['GET', '/sa/pool-grid', 'SportsActivity\Controllers\PoolGridController@index', ['auth'], 'sa.pool-grid.manage'],
['GET', '/sa/pool-grid/{id:\d+}', 'SportsActivity\Controllers\PoolGridController@manage', ['auth'], 'sa.pool-grid.manage'],
// JSON APIs (AJAX)
['GET', '/api/sa/schedule/availability', 'SportsActivity\Controllers\Api\ScheduleApiController@availability', ['auth'], 'sa.schedule.view'],
['GET', '/api/sa/schedule/conflicts', 'SportsActivity\Controllers\Api\ScheduleApiController@conflicts', ['auth'], 'sa.schedule.view'],
['GET', '/api/sa/bookings/price-preview', 'SportsActivity\Controllers\Api\BookingApiController@pricePreview', ['auth'], 'sa.booking.create'],
['GET', '/api/sa/players/search', 'SportsActivity\Controllers\Api\PlayerSearchApiController@search', ['auth'], 'sa.player.view'],
['GET', '/api/sa/mirror/{id:\d+}/state', 'SportsActivity\Controllers\Api\MirrorApiController@state', ['auth'], 'sa.mirror.view'],
['GET', '/api/sa/pool-grid/{id:\d+}/state', 'SportsActivity\Controllers\Api\PoolGridApiController@state', ['auth'], 'sa.pool-grid.manage'],
['POST', '/api/sa/pool-grid/{id:\d+}/assign', 'SportsActivity\Controllers\Api\PoolGridApiController@assign', ['auth', 'csrf'], 'sa.pool-grid.manage'],
['POST', '/api/sa/pool-grid/{id:\d+}/clear', 'SportsActivity\Controllers\Api\PoolGridApiController@clear', ['auth', 'csrf'], 'sa.pool-grid.manage'],
];
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
final class PoolGridService
{
public static function getGridState(int $facilityId, string $date): array
{
$db = App::getInstance()->db();
$facility = $db->selectOne(
"SELECT * FROM sa_facilities WHERE id = ? AND is_active = 1 AND is_archived = 0",
[$facilityId]
);
if (!$facility) {
return ['error' => 'المرفق غير موجود'];
}
$units = $db->select(
"SELECT * FROM sa_facility_units WHERE facility_id = ? AND is_active = 1 ORDER BY sort_order",
[$facilityId]
);
$bookings = $db->select(
"SELECT b.*, fu.name_ar as unit_name, fu.max_capacity,
c.full_name_ar as coach_name, g.name_ar as group_name, g.code as group_code
FROM sa_bookings b
JOIN sa_facility_units fu ON fu.id = b.facility_unit_id
LEFT JOIN sa_coaches c ON c.id = b.coach_id
LEFT JOIN sa_groups g ON g.id = b.group_id
WHERE fu.facility_id = ? AND b.booking_date = ?
AND b.status NOT IN ('cancelled')
ORDER BY fu.sort_order, b.start_time",
[$facilityId, $date]
);
$hours = json_decode($facility['operating_hours_json'], true)
?: ['start' => '06:00', 'end' => '22:00', 'slot_minutes' => 60];
$slots = self::buildTimeSlots($hours['start'], $hours['end'], (int) $hours['slot_minutes']);
$grid = [];
foreach ($units as $unit) {
$unitId = (int) $unit['id'];
$unitBookings = array_filter($bookings, fn($b) => (int) $b['facility_unit_id'] === $unitId);
$grid[$unitId] = [];
foreach ($slots as $slot) {
$slotBookings = array_values(array_filter($unitBookings, function ($b) use ($slot) {
return $b['start_time'] < $slot['end'] . ':00' && $b['end_time'] > $slot['start'] . ':00';
}));
$status = 'free';
$label = '';
$bookingId = null;
$bookingType = null;
$spotsUsed = 0;
if (!empty($slotBookings)) {
foreach ($slotBookings as $sb) {
$spotsUsed += (int) $sb['spots_reserved'];
}
$first = $slotBookings[0];
$bookingType = $first['booking_type'];
$bookingId = (int) $first['id'];
if ($bookingType === 'training') {
$status = 'training';
$label = $first['group_name'] ?? 'تدريب';
} elseif ($bookingType === 'blocked') {
$status = 'blocked';
$label = 'محجوب';
} elseif ($bookingType === 'maintenance') {
$status = 'maintenance';
$label = 'صيانة';
} else {
$status = 'hourly';
$label = $first['booker_name'] ?? 'حجز';
}
}
$grid[$unitId][] = [
'start' => $slot['start'],
'end' => $slot['end'],
'status' => $status,
'label' => $label,
'booking_id' => $bookingId,
'booking_type' => $bookingType,
'spots_used' => $spotsUsed,
'max_capacity' => (int) $unit['max_capacity'],
'bookings' => count($slotBookings),
];
}
}
return [
'facility' => $facility,
'units' => $units,
'slots' => $slots,
'grid' => $grid,
'date' => $date,
];
}
public static function bulkAssign(int $facilityId, string $date, string $action, array $cells, ?int $groupId, ?string $notes): array
{
$db = App::getInstance()->db();
$ts = date('Y-m-d H:i:s');
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$validActions = ['training', 'blocked', 'maintenance'];
if (!in_array($action, $validActions, true)) {
return ['success' => false, 'error' => 'إجراء غير صالح'];
}
if ($action === 'training' && !$groupId) {
return ['success' => false, 'error' => 'المجموعة مطلوبة لتعيين تدريب'];
}
$group = null;
$coachId = null;
if ($groupId) {
$group = $db->selectOne("SELECT * FROM sa_groups WHERE id = ? AND status = 'active'", [$groupId]);
if (!$group) {
return ['success' => false, 'error' => 'المجموعة غير موجودة أو غير نشطة'];
}
$coachId = $group['coach_id'] ? (int) $group['coach_id'] : null;
}
$bookingCount = (int) ($db->selectOne("SELECT COUNT(*) as cnt FROM sa_bookings", [])['cnt'] ?? 0);
$assigned = 0;
$skipped = 0;
foreach ($cells as $cell) {
$unitId = (int) ($cell['unit_id'] ?? 0);
$startTime = $cell['start_time'] ?? '';
$endTime = $cell['end_time'] ?? '';
if (!$unitId || !$startTime || !$endTime) {
$skipped++;
continue;
}
$conflict = $db->selectOne(
"SELECT id FROM sa_bookings
WHERE facility_unit_id = ? AND booking_date = ?
AND start_time < ? AND end_time > ?
AND status NOT IN ('cancelled')
AND booking_type IN ('training', 'blocked', 'maintenance')",
[$unitId, $date, $endTime, $startTime]
);
if ($conflict) {
$skipped++;
continue;
}
$bookingCount++;
$bookingNumber = 'BK-' . str_pad((string) $bookingCount, 6, '0', STR_PAD_LEFT);
$data = [
'booking_number' => $bookingNumber,
'facility_unit_id' => $unitId,
'booking_type' => $action,
'booking_date' => $date,
'start_time' => $startTime,
'end_time' => $endTime,
'group_id' => $groupId,
'coach_id' => $coachId,
'booker_type' => $action === 'training' ? 'academy' : 'member',
'booker_name' => $action === 'training' ? ($group['name_ar'] ?? 'تدريب') : 'إدارة',
'participant_count'=> $action === 'training' ? (int) ($group['current_count'] ?? 0) : 0,
'spots_reserved' => $action === 'training' ? (int) ($group['max_capacity'] ?? 8) : 8,
'status' => 'confirmed',
'payment_status' => 'paid',
'notes' => $notes,
'branch_id' => 1,
'created_at' => $ts,
'updated_at' => $ts,
'created_by' => $employeeId,
];
$db->insert('sa_bookings', $data);
$assigned++;
}
return ['success' => true, 'assigned' => $assigned, 'skipped' => $skipped];
}
public static function bulkClear(int $facilityId, string $date, array $cells): array
{
$db = App::getInstance()->db();
$ts = date('Y-m-d H:i:s');
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$cleared = 0;
foreach ($cells as $cell) {
$unitId = (int) ($cell['unit_id'] ?? 0);
$startTime = $cell['start_time'] ?? '';
$endTime = $cell['end_time'] ?? '';
if (!$unitId || !$startTime || !$endTime) continue;
$existing = $db->select(
"SELECT id FROM sa_bookings
WHERE facility_unit_id = ? AND booking_date = ?
AND start_time < ? AND end_time > ?
AND status NOT IN ('cancelled')",
[$unitId, $date, $endTime, $startTime]
);
foreach ($existing as $row) {
$db->update('sa_bookings', [
'status' => 'cancelled',
'cancellation_reason'=> 'مسح من شبكة الحمام',
'cancelled_by' => $employeeId,
'cancelled_at' => $ts,
'updated_at' => $ts,
], 'id = ?', [(int) $row['id']]);
$cleared++;
}
}
return ['success' => true, 'cleared' => $cleared];
}
private static function buildTimeSlots(string $start, string $end, int $slotMinutes): array
{
$slots = [];
$current = strtotime($start);
$endTs = strtotime($end);
while ($current < $endTs) {
$next = $current + ($slotMinutes * 60);
$slots[] = [
'start' => date('H:i', $current),
'end' => date('H:i', $next),
];
$current = $next;
}
return $slots;
}
}
<?php
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>إدارة شبكة حمام السباحة<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(300px, 1fr));gap:20px;">
<?php foreach ($facilities as $f): ?>
<a href="/sa/pool-grid/<?= (int) $f['id'] ?>" class="card" style="text-decoration:none;color:inherit;padding:24px;transition:box-shadow .2s;">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
<div style="width:48px;height:48px;border-radius:12px;background:#DBEAFE;display:flex;align-items:center;justify-content:center;">
<i data-lucide="waves" style="width:24px;height:24px;color:#2563EB;"></i>
</div>
<div>
<h3 style="margin:0;font-size:18px;font-weight:600;"><?= e($f['name_ar']) ?></h3>
<span style="color:#6B7280;font-size:13px;"><?= e($f['discipline_name'] ?? '') ?></span>
</div>
</div>
<p style="margin:0;color:#6B7280;font-size:14px;"><?= e($f['location_description'] ?? 'بدون وصف') ?></p>
</a>
<?php endforeach; ?>
</div>
<script>if (typeof lucide !== 'undefined') lucide.createIcons();</script>
<?php $__template->endSection(); ?>
<?php
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>شبكة <?= e($facility['name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/pool-grid" class="btn btn-outline"><i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> رجوع</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<style>
.pg-toolbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:12px 16px;background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:16px;}
.pg-toolbar .date-nav{display:flex;align-items:center;gap:8px;}
.pg-toolbar .date-nav button{background:none;border:1px solid #D1D5DB;border-radius:6px;padding:6px 10px;cursor:pointer;font-size:14px;}
.pg-toolbar .date-nav button:hover{background:#F3F4F6;}
.pg-toolbar .date-nav input[type=date]{border:1px solid #D1D5DB;border-radius:6px;padding:6px 10px;font-size:14px;font-family:inherit;}
.pg-actions{display:flex;gap:8px;margin-right:auto;}
.pg-actions button{border:none;border-radius:6px;padding:8px 14px;font-size:13px;font-weight:500;cursor:pointer;transition:all .15s;font-family:inherit;}
.pg-actions button:disabled{opacity:.4;cursor:not-allowed;}
.pg-actions .btn-training{background:#DBEAFE;color:#1D4ED8;}
.pg-actions .btn-training:hover:not(:disabled){background:#BFDBFE;}
.pg-actions .btn-blocked{background:#F3F4F6;color:#374151;}
.pg-actions .btn-blocked:hover:not(:disabled){background:#E5E7EB;}
.pg-actions .btn-maintenance{background:#FEF3C7;color:#92400E;}
.pg-actions .btn-maintenance:hover:not(:disabled){background:#FDE68A;}
.pg-actions .btn-clear{background:#FEE2E2;color:#991B1B;}
.pg-actions .btn-clear:hover:not(:disabled){background:#FECACA;}
.pg-selection-info{font-size:13px;color:#6B7280;min-width:100px;text-align:center;}
.pg-wrapper{overflow-x:auto;border:1px solid #E5E7EB;border-radius:8px;background:#fff;}
.pg-table{width:100%;border-collapse:collapse;user-select:none;min-width:600px;}
.pg-table th,.pg-table td{border:1px solid #E5E7EB;padding:0;text-align:center;font-size:12px;}
.pg-table thead th{background:#F9FAFB;padding:10px 6px;font-weight:600;position:sticky;top:0;z-index:2;}
.pg-table thead th.pg-time-header{width:70px;min-width:70px;background:#F3F4F6;position:sticky;right:0;z-index:3;}
.pg-table tbody th{background:#F3F4F6;padding:8px 6px;font-weight:500;width:70px;min-width:70px;position:sticky;right:0;z-index:1;cursor:pointer;}
.pg-table tbody th:hover{background:#E5E7EB;}
.pg-cell{min-height:38px;cursor:crosshair;transition:background .1s;position:relative;padding:4px;}
.pg-cell:hover{outline:2px solid #93C5FD;outline-offset:-2px;z-index:1;}
.pg-cell.selected{outline:2px solid #2563EB;outline-offset:-2px;z-index:1;background:#EFF6FF !important;}
.pg-cell.status-free{background:#fff;}
.pg-cell.status-training{background:#DBEAFE;}
.pg-cell.status-hourly{background:#D1FAE5;}
.pg-cell.status-blocked{background:#F3F4F6;}
.pg-cell.status-maintenance{background:#FEF3C7;}
.pg-cell .cell-label{font-size:11px;font-weight:500;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.pg-cell .cell-cap{font-size:10px;color:#6B7280;margin-top:1px;}
.pg-col-header{cursor:pointer;transition:background .15s;}
.pg-col-header:hover{background:#E5E7EB !important;}
.pg-legend{display:flex;gap:16px;margin-top:12px;font-size:12px;color:#374151;flex-wrap:wrap;}
.pg-legend span{display:flex;align-items:center;gap:4px;}
.pg-legend .swatch{width:14px;height:14px;border-radius:3px;border:1px solid #D1D5DB;}
.pg-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:1000;display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .2s;}
.pg-modal-overlay.active{opacity:1;pointer-events:all;}
.pg-modal{background:#fff;border-radius:12px;padding:24px;width:400px;max-width:90vw;box-shadow:0 20px 60px rgba(0,0,0,.2);}
.pg-modal h3{margin:0 0 16px;font-size:18px;}
.pg-modal label{display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:#374151;}
.pg-modal select,.pg-modal textarea{width:100%;border:1px solid #D1D5DB;border-radius:6px;padding:8px 10px;font-size:14px;font-family:inherit;margin-bottom:12px;}
.pg-modal .modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px;}
.pg-modal .modal-actions button{padding:8px 16px;border-radius:6px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;}
.pg-modal .modal-actions .btn-confirm{background:#2563EB;color:#fff;border:none;}
.pg-modal .modal-actions .btn-cancel{background:#fff;color:#374151;border:1px solid #D1D5DB;}
.pg-loading{position:absolute;inset:0;background:rgba(255,255,255,.7);display:flex;align-items:center;justify-content:center;z-index:5;border-radius:8px;}
.pg-loading.hidden{display:none;}
.pg-spinner{width:32px;height:32px;border:3px solid #E5E7EB;border-top-color:#2563EB;border-radius:50%;animation:spin .6s linear infinite;}
@keyframes spin{to{transform:rotate(360deg);}}
</style>
<!-- Toolbar -->
<div class="pg-toolbar">
<div class="date-nav">
<button id="pgPrevDay" title="اليوم السابق"><i data-lucide="chevron-right" style="width:14px;height:14px;"></i></button>
<input type="date" id="pgDate" value="<?= e($date) ?>">
<button id="pgNextDay" title="اليوم التالي"><i data-lucide="chevron-left" style="width:14px;height:14px;"></i></button>
<button id="pgToday" style="font-size:12px;">اليوم</button>
</div>
<div class="pg-selection-info" id="pgSelInfo">لا يوجد تحديد</div>
<div class="pg-actions">
<button class="btn-training" id="pgBtnTraining" disabled title="تعيين تدريب"><i data-lucide="users" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> تدريب</button>
<button class="btn-blocked" id="pgBtnBlocked" disabled title="حجب"><i data-lucide="lock" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> حجب</button>
<button class="btn-maintenance" id="pgBtnMaint" disabled title="صيانة"><i data-lucide="wrench" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> صيانة</button>
<button class="btn-clear" id="pgBtnClear" disabled title="مسح الحجوزات"><i data-lucide="x" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> مسح</button>
</div>
</div>
<!-- Grid Container -->
<div style="position:relative;">
<div class="pg-loading hidden" id="pgLoading"><div class="pg-spinner"></div></div>
<div class="pg-wrapper">
<table class="pg-table" id="pgTable">
<thead><tr id="pgHead"></tr></thead>
<tbody id="pgBody"></tbody>
</table>
</div>
</div>
<!-- Legend -->
<div class="pg-legend">
<span><span class="swatch" style="background:#fff;"></span> فارغ</span>
<span><span class="swatch" style="background:#DBEAFE;"></span> تدريب</span>
<span><span class="swatch" style="background:#D1FAE5;"></span> حجز ساعة</span>
<span><span class="swatch" style="background:#F3F4F6;"></span> محجوب</span>
<span><span class="swatch" style="background:#FEF3C7;"></span> صيانة</span>
</div>
<!-- Training Assignment Modal -->
<div class="pg-modal-overlay" id="pgModal">
<div class="pg-modal">
<h3 id="pgModalTitle">تعيين تدريب</h3>
<div id="pgModalBody">
<label for="pgGroupSelect">المجموعة</label>
<select id="pgGroupSelect">
<option value="">اختر مجموعة...</option>
<?php foreach ($groups as $g): ?>
<option value="<?= (int) $g['id'] ?>" data-coach="<?= e($g['coach_name'] ?? '') ?>" data-count="<?= (int) $g['current_count'] ?>/<?= (int) $g['max_capacity'] ?>">
<?= e($g['name_ar']) ?><?= e($g['coach_name'] ?? 'بدون مدرب') ?> (<?= (int) $g['current_count'] ?>/<?= (int) $g['max_capacity'] ?>)
</option>
<?php endforeach; ?>
</select>
<label for="pgNotes">ملاحظات (اختياري)</label>
<textarea id="pgNotes" rows="2" placeholder="ملاحظات..."></textarea>
</div>
<div class="modal-actions">
<button class="btn-cancel" id="pgModalCancel">إلغاء</button>
<button class="btn-confirm" id="pgModalConfirm">تأكيد</button>
</div>
</div>
</div>
<script>
(function(){
const FACILITY_ID = <?= (int) $facility['id'] ?>;
const CSRF_TOKEN = '<?= e($_SESSION['_csrf_token'] ?? '') ?>';
let gridState = null;
let selectedCells = new Set();
let isDragging = false;
let dragStart = null;
let dragMode = null; // 'select' or 'deselect'
let pendingAction = null;
const dateInput = document.getElementById('pgDate');
const table = document.getElementById('pgTable');
const head = document.getElementById('pgHead');
const body = document.getElementById('pgBody');
const loading = document.getElementById('pgLoading');
const selInfo = document.getElementById('pgSelInfo');
const btnTraining = document.getElementById('pgBtnTraining');
const btnBlocked = document.getElementById('pgBtnBlocked');
const btnMaint = document.getElementById('pgBtnMaint');
const btnClear = document.getElementById('pgBtnClear');
const modal = document.getElementById('pgModal');
const modalTitle = document.getElementById('pgModalTitle');
const modalBody = document.getElementById('pgModalBody');
const modalConfirm = document.getElementById('pgModalConfirm');
const modalCancel = document.getElementById('pgModalCancel');
const groupSelect = document.getElementById('pgGroupSelect');
const notesInput = document.getElementById('pgNotes');
function currentDate() { return dateInput.value; }
function setDate(d) {
dateInput.value = d;
loadGrid();
}
document.getElementById('pgPrevDay').addEventListener('click', function(){
let d = new Date(currentDate());
d.setDate(d.getDate() - 1);
setDate(d.toISOString().slice(0,10));
});
document.getElementById('pgNextDay').addEventListener('click', function(){
let d = new Date(currentDate());
d.setDate(d.getDate() + 1);
setDate(d.toISOString().slice(0,10));
});
document.getElementById('pgToday').addEventListener('click', function(){
setDate(new Date().toISOString().slice(0,10));
});
dateInput.addEventListener('change', function(){ loadGrid(); });
function showLoading(){ loading.classList.remove('hidden'); }
function hideLoading(){ loading.classList.add('hidden'); }
function cellKey(unitId, slotIdx){ return unitId + ':' + slotIdx; }
function updateSelectionUI(){
let count = selectedCells.size;
selInfo.textContent = count > 0 ? count + ' خلية محددة' : 'لا يوجد تحديد';
let hasSelection = count > 0;
btnTraining.disabled = !hasSelection;
btnBlocked.disabled = !hasSelection;
btnMaint.disabled = !hasSelection;
btnClear.disabled = !hasSelection;
document.querySelectorAll('.pg-cell').forEach(function(el){
let key = el.dataset.key;
el.classList.toggle('selected', selectedCells.has(key));
});
}
function clearSelection(){
selectedCells.clear();
updateSelectionUI();
}
function loadGrid(){
showLoading();
clearSelection();
fetch('/api/sa/pool-grid/' + FACILITY_ID + '/state?date=' + encodeURIComponent(currentDate()), {
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r){ return r.json(); })
.then(function(data){
if(data.error){ alert(data.error); hideLoading(); return; }
gridState = data;
renderGrid();
hideLoading();
})
.catch(function(e){ alert('خطأ في تحميل البيانات'); hideLoading(); });
}
function renderGrid(){
if(!gridState) return;
let units = gridState.units;
let slots = gridState.slots;
let grid = gridState.grid;
// Header: time col + one col per lane
let hhtml = '<th class="pg-time-header">الوقت</th>';
for(let i = 0; i < units.length; i++){
hhtml += '<th class="pg-col-header" data-unit-idx="' + i + '" data-unit-id="' + units[i].id + '">' + escHtml(units[i].name_ar) + '<br><span style="font-weight:400;font-size:10px;color:#6B7280;">سعة: ' + units[i].max_capacity + '</span></th>';
}
head.innerHTML = hhtml;
// Body rows = time slots
let bhtml = '';
for(let s = 0; s < slots.length; s++){
bhtml += '<tr>';
bhtml += '<th data-slot-idx="' + s + '">' + slots[s].start + '<br><span style="font-size:10px;color:#9CA3AF;">' + slots[s].end + '</span></th>';
for(let u = 0; u < units.length; u++){
let unitId = units[u].id;
let cellData = grid[unitId] ? grid[unitId][s] : null;
let status = cellData ? cellData.status : 'free';
let label = cellData ? cellData.label : '';
let spots = cellData ? cellData.spots_used : 0;
let max = cellData ? cellData.max_capacity : units[u].max_capacity;
let key = cellKey(unitId, s);
bhtml += '<td><div class="pg-cell status-' + status + '" data-key="' + key + '" data-unit-id="' + unitId + '" data-slot-idx="' + s + '">';
if(label){
bhtml += '<div class="cell-label">' + escHtml(label) + '</div>';
}
if(status !== 'free' && status !== 'blocked' && status !== 'maintenance'){
bhtml += '<div class="cell-cap">' + spots + '/' + max + '</div>';
}
bhtml += '</div></td>';
}
bhtml += '</tr>';
}
body.innerHTML = bhtml;
bindCellEvents();
bindHeaderEvents();
}
function bindCellEvents(){
let cells = document.querySelectorAll('.pg-cell');
cells.forEach(function(el){
el.addEventListener('mousedown', function(e){
if(e.button !== 0) return;
e.preventDefault();
isDragging = true;
let key = el.dataset.key;
if(e.ctrlKey || e.metaKey){
if(selectedCells.has(key)){ selectedCells.delete(key); dragMode = 'deselect'; }
else { selectedCells.add(key); dragMode = 'select'; }
} else if(e.shiftKey && selectedCells.size > 0) {
rectangleSelect(key);
dragMode = 'select';
} else {
selectedCells.clear();
selectedCells.add(key);
dragMode = 'select';
}
dragStart = key;
updateSelectionUI();
});
el.addEventListener('mouseenter', function(e){
if(!isDragging) return;
let key = el.dataset.key;
if(dragMode === 'select') selectedCells.add(key);
else selectedCells.delete(key);
updateSelectionUI();
});
});
}
function bindHeaderEvents(){
// Column headers — select whole lane
document.querySelectorAll('.pg-col-header').forEach(function(th){
th.addEventListener('click', function(e){
let unitId = th.dataset.unitId;
let slots = gridState.slots;
let allSelected = true;
for(let s = 0; s < slots.length; s++){
if(!selectedCells.has(cellKey(unitId, s))){ allSelected = false; break; }
}
for(let s = 0; s < slots.length; s++){
let k = cellKey(unitId, s);
if(allSelected) selectedCells.delete(k);
else selectedCells.add(k);
}
updateSelectionUI();
});
});
// Row headers — select whole time slot
document.querySelectorAll('tbody th[data-slot-idx]').forEach(function(th){
th.addEventListener('click', function(e){
let slotIdx = parseInt(th.dataset.slotIdx);
let units = gridState.units;
let allSelected = true;
for(let u = 0; u < units.length; u++){
if(!selectedCells.has(cellKey(units[u].id, slotIdx))){ allSelected = false; break; }
}
for(let u = 0; u < units.length; u++){
let k = cellKey(units[u].id, slotIdx);
if(allSelected) selectedCells.delete(k);
else selectedCells.add(k);
}
updateSelectionUI();
});
});
}
function rectangleSelect(endKey){
let startParts = dragStart ? dragStart.split(':') : endKey.split(':');
let endParts = endKey.split(':');
let units = gridState.units;
let unitIds = units.map(function(u){ return String(u.id); });
let startUnitIdx = unitIds.indexOf(startParts[0]);
let endUnitIdx = unitIds.indexOf(endParts[0]);
let startSlot = parseInt(startParts[1]);
let endSlot = parseInt(endParts[1]);
let minU = Math.min(startUnitIdx, endUnitIdx);
let maxU = Math.max(startUnitIdx, endUnitIdx);
let minS = Math.min(startSlot, endSlot);
let maxS = Math.max(startSlot, endSlot);
for(let u = minU; u <= maxU; u++){
for(let s = minS; s <= maxS; s++){
selectedCells.add(cellKey(unitIds[u], s));
}
}
}
document.addEventListener('mouseup', function(){
isDragging = false;
dragMode = null;
});
// Keyboard: Escape clears, Ctrl+A selects all
document.addEventListener('keydown', function(e){
if(e.key === 'Escape') clearSelection();
if((e.ctrlKey || e.metaKey) && e.key === 'a' && document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA'){
e.preventDefault();
if(!gridState) return;
gridState.units.forEach(function(u){
gridState.slots.forEach(function(s, si){
selectedCells.add(cellKey(u.id, si));
});
});
updateSelectionUI();
}
});
// Action buttons
btnTraining.addEventListener('click', function(){ showModal('training'); });
btnBlocked.addEventListener('click', function(){ submitAction('blocked'); });
btnMaint.addEventListener('click', function(){ submitAction('maintenance'); });
btnClear.addEventListener('click', function(){
if(!confirm('هل تريد مسح الحجوزات من الخلايا المحددة؟')) return;
submitClear();
});
function showModal(action){
pendingAction = action;
if(action === 'training'){
modalTitle.textContent = 'تعيين تدريب';
document.getElementById('pgGroupSelect').parentElement.style.display = '';
groupSelect.value = '';
notesInput.value = '';
}
modal.classList.add('active');
}
function hideModal(){
modal.classList.remove('active');
pendingAction = null;
}
modalCancel.addEventListener('click', hideModal);
modal.addEventListener('click', function(e){ if(e.target === modal) hideModal(); });
modalConfirm.addEventListener('click', function(){
if(pendingAction === 'training'){
let gid = groupSelect.value;
if(!gid){ alert('اختر مجموعة'); return; }
submitAction('training', parseInt(gid), notesInput.value.trim());
}
hideModal();
});
function buildCellsPayload(){
let cells = [];
let units = gridState.units;
let slots = gridState.slots;
selectedCells.forEach(function(key){
let parts = key.split(':');
let unitId = parseInt(parts[0]);
let slotIdx = parseInt(parts[1]);
let slot = slots[slotIdx];
if(slot){
cells.push({
unit_id: unitId,
start_time: slot.start + ':00',
end_time: slot.end + ':00'
});
}
});
return cells;
}
function submitAction(action, groupId, notes){
let cells = buildCellsPayload();
if(!cells.length) return;
showLoading();
let payload = {
date: currentDate(),
action: action,
cells: cells,
group_id: groupId || null,
notes: notes || ''
};
fetch('/api/sa/pool-grid/' + FACILITY_ID + '/assign', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(payload)
})
.then(function(r){ return r.json(); })
.then(function(data){
if(data.error){ alert(data.error); hideLoading(); return; }
let msg = 'تم التعيين: ' + (data.assigned || 0) + ' خلية';
if(data.skipped) msg += ' | تم التخطي: ' + data.skipped;
showToast(msg, 'success');
loadGrid();
})
.catch(function(){ alert('خطأ في العملية'); hideLoading(); });
}
function submitClear(){
let cells = buildCellsPayload();
if(!cells.length) return;
showLoading();
fetch('/api/sa/pool-grid/' + FACILITY_ID + '/clear', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ date: currentDate(), cells: cells })
})
.then(function(r){ return r.json(); })
.then(function(data){
if(data.error){ alert(data.error); hideLoading(); return; }
showToast('تم المسح: ' + (data.cleared || 0) + ' حجز', 'success');
loadGrid();
})
.catch(function(){ alert('خطأ في العملية'); hideLoading(); });
}
function showToast(msg, type){
let el = document.createElement('div');
el.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);padding:12px 20px;border-radius:8px;font-size:14px;font-weight:500;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,.15);transition:opacity .3s;';
if(type === 'success') el.style.background = '#D1FAE5', el.style.color = '#065F46';
else el.style.background = '#FEE2E2', el.style.color = '#991B1B';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function(){ el.style.opacity = '0'; setTimeout(function(){ el.remove(); }, 300); }, 3000);
}
function escHtml(s){
if(!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Initial load
loadGrid();
if(typeof lucide !== 'undefined') lucide.createIcons();
})();
</script>
<?php $__template->endSection(); ?>
......@@ -15,6 +15,7 @@ MenuRegistry::register('sports_activity', [
'children' => [
['label_ar' => 'لوحة التحكم', 'label_en' => 'Dashboard', 'route' => '/sa', 'permission' => 'sa.dashboard', 'order' => 1],
['label_ar' => 'المراية', 'label_en' => 'Mirror', 'route' => '/sa/mirror', 'permission' => 'sa.mirror.view', 'order' => 2],
['label_ar' => 'شبكة حمام السباحة', 'label_en' => 'Pool Grid', 'route' => '/sa/pool-grid', 'permission' => 'sa.pool-grid.manage', 'order' => 3],
['label_ar' => 'الألعاب', 'label_en' => 'Disciplines', 'route' => '/sa/disciplines', 'permission' => 'sa.discipline.view', 'order' => 3],
['label_ar' => 'المرافق', 'label_en' => 'Facilities', 'route' => '/sa/facilities', 'permission' => 'sa.facility.view', 'order' => 4],
['label_ar' => 'المدربين', 'label_en' => 'Coaches', 'route' => '/sa/coaches', 'permission' => 'sa.coach.view', 'order' => 5],
......@@ -68,4 +69,5 @@ PermissionRegistry::register('sports_activity', [
'sa.mirror.view' => ['ar' => 'عرض المراية', 'en' => 'View Mirror Display'],
'sa.waitlist.view' => ['ar' => 'عرض قائمة الانتظار', 'en' => 'View Waitlist'],
'sa.waitlist.manage' => ['ar' => 'إدارة قائمة الانتظار', 'en' => 'Manage Waitlist'],
'sa.pool-grid.manage' => ['ar' => 'إدارة شبكة حمام السباحة', 'en' => 'Manage Pool Grid'],
]);
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