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 [ ...@@ -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+}/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'], ['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) // JSON APIs (AJAX)
['GET', '/api/sa/schedule/availability', 'SportsActivity\Controllers\Api\ScheduleApiController@availability', ['auth'], 'sa.schedule.view'], ['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/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/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/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/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(); ?>
This diff is collapsed.
...@@ -15,6 +15,7 @@ MenuRegistry::register('sports_activity', [ ...@@ -15,6 +15,7 @@ MenuRegistry::register('sports_activity', [
'children' => [ 'children' => [
['label_ar' => 'لوحة التحكم', 'label_en' => 'Dashboard', 'route' => '/sa', 'permission' => 'sa.dashboard', 'order' => 1], ['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' => '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' => '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' => 'Facilities', 'route' => '/sa/facilities', 'permission' => 'sa.facility.view', 'order' => 4],
['label_ar' => 'المدربين', 'label_en' => 'Coaches', 'route' => '/sa/coaches', 'permission' => 'sa.coach.view', 'order' => 5], ['label_ar' => 'المدربين', 'label_en' => 'Coaches', 'route' => '/sa/coaches', 'permission' => 'sa.coach.view', 'order' => 5],
...@@ -68,4 +69,5 @@ PermissionRegistry::register('sports_activity', [ ...@@ -68,4 +69,5 @@ PermissionRegistry::register('sports_activity', [
'sa.mirror.view' => ['ar' => 'عرض المراية', 'en' => 'View Mirror Display'], 'sa.mirror.view' => ['ar' => 'عرض المراية', 'en' => 'View Mirror Display'],
'sa.waitlist.view' => ['ar' => 'عرض قائمة الانتظار', 'en' => 'View Waitlist'], 'sa.waitlist.view' => ['ar' => 'عرض قائمة الانتظار', 'en' => 'View Waitlist'],
'sa.waitlist.manage' => ['ar' => 'إدارة قائمة الانتظار', 'en' => 'Manage 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