Commit 0b3ff07b authored by Mahmoud Aglan's avatar Mahmoud Aglan

Rebuild pool grid as physical 2D zone system with timeline

Pool is now a real 2D grid (rows × cols = squares). Admin sets dimensions,
selects time slot from timeline strip above, then assigns squares to
academies/groups/hourly/blocked/maintenance via drag selection.

- New table: sa_pool_zone_bookings (zone_row × zone_col per time slot)
- ALTER sa_facilities: add pool_grid_rows, pool_grid_cols
- Rewritten PoolGridService with zone-based logic + copy-slot feature
- Interactive view: timeline strip, 2D grid with drag/click/shift-rect select
- API: state, assign, clear, update-grid, copy-slot
- Seed: 4×6 grid with sample zone bookings
- Fix mirror route mismatch (/sa/mirror/{id} now works)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent c6592185
...@@ -13,15 +13,20 @@ class PoolGridApiController extends Controller ...@@ -13,15 +13,20 @@ class PoolGridApiController extends Controller
public function state(Request $request, string $id): Response public function state(Request $request, string $id): Response
{ {
$date = $request->get('date', date('Y-m-d')); $date = $request->get('date', date('Y-m-d'));
$startTime = $request->get('start_time', '');
$endTime = $request->get('end_time', '');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $this->json(['error' => 'تاريخ غير صالح'], 400); return $this->json(['error' => 'تاريخ غير صالح'], 400);
} }
if (!preg_match('/^\d{2}:\d{2}$/', $startTime) || !preg_match('/^\d{2}:\d{2}$/', $endTime)) {
return $this->json(['error' => 'وقت غير صالح'], 400);
}
$state = PoolGridService::getGridState((int) $id, $date); $state = PoolGridService::getGridState((int) $id, $date, $startTime, $endTime);
if (isset($state['error'])) { if (isset($state['error'])) {
return $this->json(['error' => $state['error']], 404); return $this->json(['error' => $state['error']], 422);
} }
return $this->json($state); return $this->json($state);
...@@ -31,16 +36,18 @@ class PoolGridApiController extends Controller ...@@ -31,16 +36,18 @@ class PoolGridApiController extends Controller
{ {
$body = $request->jsonBody(); $body = $request->jsonBody();
$date = trim((string) ($body['date'] ?? '')); $date = trim((string) ($body['date'] ?? ''));
$startTime = trim((string) ($body['start_time'] ?? ''));
$endTime = trim((string) ($body['end_time'] ?? ''));
$action = trim((string) ($body['action'] ?? '')); $action = trim((string) ($body['action'] ?? ''));
$cells = $body['cells'] ?? []; $cells = $body['cells'] ?? [];
$groupId = !empty($body['group_id']) ? (int) $body['group_id'] : null; $groupId = !empty($body['group_id']) ? (int) $body['group_id'] : null;
$notes = trim((string) ($body['notes'] ?? '')); $notes = trim((string) ($body['notes'] ?? ''));
if (!$date || !$action || empty($cells)) { if (!$date || !$startTime || !$endTime || !$action || empty($cells)) {
return $this->json(['error' => 'بيانات ناقصة'], 400); return $this->json(['error' => 'بيانات ناقصة'], 400);
} }
$result = PoolGridService::bulkAssign((int) $id, $date, $action, $cells, $groupId, $notes ?: null); $result = PoolGridService::bulkAssign((int) $id, $date, $startTime, $endTime, $action, $cells, $groupId, $notes ?: null);
return $this->json($result, $result['success'] ? 200 : 422); return $this->json($result, $result['success'] ? 200 : 422);
} }
...@@ -49,14 +56,47 @@ class PoolGridApiController extends Controller ...@@ -49,14 +56,47 @@ class PoolGridApiController extends Controller
{ {
$body = $request->jsonBody(); $body = $request->jsonBody();
$date = trim((string) ($body['date'] ?? '')); $date = trim((string) ($body['date'] ?? ''));
$startTime = trim((string) ($body['start_time'] ?? ''));
$cells = $body['cells'] ?? []; $cells = $body['cells'] ?? [];
if (!$date || empty($cells)) { if (!$date || !$startTime || empty($cells)) {
return $this->json(['error' => 'بيانات ناقصة'], 400); return $this->json(['error' => 'بيانات ناقصة'], 400);
} }
$result = PoolGridService::bulkClear((int) $id, $date, $cells); $result = PoolGridService::bulkClear((int) $id, $date, $startTime, $cells);
return $this->json($result); return $this->json($result);
} }
public function updateGrid(Request $request, string $id): Response
{
$body = $request->jsonBody();
$rows = (int) ($body['rows'] ?? 0);
$cols = (int) ($body['cols'] ?? 0);
if ($rows < 1 || $cols < 1) {
return $this->json(['error' => 'عدد الصفوف والأعمدة مطلوب'], 400);
}
$result = PoolGridService::updateGridSize((int) $id, $rows, $cols);
return $this->json($result, $result['success'] ? 200 : 422);
}
public function copySlot(Request $request, string $id): Response
{
$body = $request->jsonBody();
$date = trim((string) ($body['date'] ?? ''));
$fromTime = trim((string) ($body['from_time'] ?? ''));
$toTime = trim((string) ($body['to_time'] ?? ''));
$toEndTime = trim((string) ($body['to_end_time'] ?? ''));
if (!$date || !$fromTime || !$toTime || !$toEndTime) {
return $this->json(['error' => 'بيانات ناقصة'], 400);
}
$result = PoolGridService::copySlot((int) $id, $date, $fromTime, $toTime, $toEndTime);
return $this->json($result, $result['success'] ? 200 : 422);
}
} }
...@@ -39,15 +39,13 @@ class PoolGridController extends Controller ...@@ -39,15 +39,13 @@ class PoolGridController extends Controller
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$date = $request->get('date', date('Y-m-d')); $date = $request->get('date', date('Y-m-d'));
$facility = $db->selectOne( $facility = PoolGridService::getFacility((int) $id);
"SELECT * FROM sa_facilities WHERE id = ? AND is_active = 1 AND is_archived = 0",
[(int) $id]
);
if (!$facility) { if (!$facility) {
return $this->redirect('/sa/pool-grid')->withError('المرفق غير موجود'); return $this->redirect('/sa/pool-grid')->withError('المرفق غير موجود');
} }
$slots = PoolGridService::getTimeSlots($facility);
$groups = $db->select( $groups = $db->select(
"SELECT g.id, g.code, g.name_ar, g.current_count, g.max_capacity, "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 c.full_name_ar as coach_name, p.name_ar as program_name
...@@ -61,6 +59,7 @@ class PoolGridController extends Controller ...@@ -61,6 +59,7 @@ class PoolGridController extends Controller
return $this->view('SportsActivity.Views.pool-grid.manage', [ return $this->view('SportsActivity.Views.pool-grid.manage', [
'facility' => $facility, 'facility' => $facility,
'slots' => $slots,
'groups' => $groups, 'groups' => $groups,
'date' => $date, 'date' => $date,
]); ]);
......
...@@ -153,4 +153,6 @@ return [ ...@@ -153,4 +153,6 @@ return [
['GET', '/api/sa/pool-grid/{id:\d+}/state', 'SportsActivity\Controllers\Api\PoolGridApiController@state', ['auth'], 'sa.pool-grid.manage'], ['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+}/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'], ['POST', '/api/sa/pool-grid/{id:\d+}/clear', 'SportsActivity\Controllers\Api\PoolGridApiController@clear', ['auth', 'csrf'], 'sa.pool-grid.manage'],
['POST', '/api/sa/pool-grid/{id:\d+}/update-grid', 'SportsActivity\Controllers\Api\PoolGridApiController@updateGrid', ['auth', 'csrf'], 'sa.pool-grid.manage'],
['POST', '/api/sa/pool-grid/{id:\d+}/copy-slot', 'SportsActivity\Controllers\Api\PoolGridApiController@copySlot', ['auth', 'csrf'], 'sa.pool-grid.manage'],
]; ];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE `sa_facilities`
ADD COLUMN `pool_grid_rows` TINYINT UNSIGNED NULL AFTER `operating_hours_json`,
ADD COLUMN `pool_grid_cols` TINYINT UNSIGNED NULL AFTER `pool_grid_rows`;
CREATE TABLE `sa_pool_zone_bookings` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`facility_id` BIGINT UNSIGNED NOT NULL,
`booking_date` DATE NOT NULL,
`start_time` TIME NOT NULL,
`end_time` TIME NOT NULL,
`zone_row` TINYINT UNSIGNED NOT NULL COMMENT '0-based row index',
`zone_col` TINYINT UNSIGNED NOT NULL COMMENT '0-based col index',
`assignment_type` VARCHAR(20) NOT NULL COMMENT 'training, blocked, maintenance, hourly',
`group_id` BIGINT UNSIGNED NULL,
`coach_id` BIGINT UNSIGNED NULL,
`label` VARCHAR(200) NULL,
`notes` TEXT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT 'active, cancelled',
`created_by` BIGINT UNSIGNED NULL,
`cancelled_by` BIGINT UNSIGNED NULL,
`cancelled_at` TIMESTAMP NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_sa_pzb_lookup` (`facility_id`, `booking_date`, `start_time`, `end_time`),
INDEX `idx_sa_pzb_zone` (`facility_id`, `zone_row`, `zone_col`),
INDEX `idx_sa_pzb_group` (`group_id`),
UNIQUE KEY `uq_sa_pzb_slot` (`facility_id`, `booking_date`, `start_time`, `zone_row`, `zone_col`),
CONSTRAINT `fk_sa_pzb_facility` FOREIGN KEY (`facility_id`) REFERENCES `sa_facilities`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_sa_pzb_group` FOREIGN KEY (`group_id`) REFERENCES `sa_groups`(`id`) ON DELETE SET NULL,
CONSTRAINT `fk_sa_pzb_coach` FOREIGN KEY (`coach_id`) REFERENCES `sa_coaches`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "
DROP TABLE IF EXISTS `sa_pool_zone_bookings`;
ALTER TABLE `sa_facilities` DROP COLUMN `pool_grid_rows`, DROP COLUMN `pool_grid_cols`
",
];
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$ts = date('Y-m-d H:i:s');
$today = date('Y-m-d');
// Get pool facility
$pool = $db->selectOne("SELECT id FROM sa_facilities WHERE code = 'POOL-MAIN'", []);
if (!$pool) {
return; // Phase_70_001 must run first
}
$poolId = (int) $pool['id'];
// Set grid dimensions: 4 rows × 6 cols = 24 squares
$db->update('sa_facilities', [
'pool_grid_rows' => 4,
'pool_grid_cols' => 6,
'updated_at' => $ts,
], 'id = ?', [$poolId]);
// Get some groups for sample assignments
$groups = $db->select("SELECT id, name_ar, coach_id FROM sa_groups WHERE status = 'active' LIMIT 3", []);
// Seed zone bookings for today — morning slots
$sampleSlots = [
['start' => '07:00:00', 'end' => '08:00:00'],
['start' => '08:00:00', 'end' => '09:00:00'],
['start' => '09:00:00', 'end' => '10:00:00'],
];
// Slot 07:00 — Group 1 gets top-left 2x3 block
if (!empty($groups[0])) {
$g = $groups[0];
for ($r = 0; $r < 2; $r++) {
for ($c = 0; $c < 3; $c++) {
$db->insert('sa_pool_zone_bookings', [
'facility_id' => $poolId,
'booking_date' => $today,
'start_time' => '07:00:00',
'end_time' => '08:00:00',
'zone_row' => $r,
'zone_col' => $c,
'assignment_type' => 'training',
'group_id' => (int) $g['id'],
'coach_id' => $g['coach_id'] ? (int) $g['coach_id'] : null,
'label' => $g['name_ar'],
'notes' => null,
'status' => 'active',
'created_by' => 1,
'created_at' => $ts,
'updated_at' => $ts,
]);
}
}
}
// Slot 07:00 — Maintenance on bottom-right corner
$db->insert('sa_pool_zone_bookings', [
'facility_id' => $poolId,
'booking_date' => $today,
'start_time' => '07:00:00',
'end_time' => '08:00:00',
'zone_row' => 3,
'zone_col' => 5,
'assignment_type' => 'maintenance',
'group_id' => null,
'coach_id' => null,
'label' => 'صيانة',
'notes' => 'تنظيف فلتر',
'status' => 'active',
'created_by' => 1,
'created_at' => $ts,
'updated_at' => $ts,
]);
// Slot 08:00 — Group 2 gets middle strip
if (!empty($groups[1])) {
$g = $groups[1];
for ($c = 0; $c < 6; $c++) {
$db->insert('sa_pool_zone_bookings', [
'facility_id' => $poolId,
'booking_date' => $today,
'start_time' => '08:00:00',
'end_time' => '09:00:00',
'zone_row' => 1,
'zone_col' => $c,
'assignment_type' => 'training',
'group_id' => (int) $g['id'],
'coach_id' => $g['coach_id'] ? (int) $g['coach_id'] : null,
'label' => $g['name_ar'],
'notes' => null,
'status' => 'active',
'created_by' => 1,
'created_at' => $ts,
'updated_at' => $ts,
]);
}
}
// Slot 09:00 — Blocked column (col 0)
for ($r = 0; $r < 4; $r++) {
$db->insert('sa_pool_zone_bookings', [
'facility_id' => $poolId,
'booking_date' => $today,
'start_time' => '09:00:00',
'end_time' => '10:00:00',
'zone_row' => $r,
'zone_col' => 0,
'assignment_type' => 'blocked',
'group_id' => null,
'coach_id' => null,
'label' => 'محجوب',
'notes' => 'حدث خاص',
'status' => 'active',
'created_by' => 1,
'created_at' => $ts,
'updated_at' => $ts,
]);
}
// Slot 09:00 — Hourly booking in a couple cells
$db->insert('sa_pool_zone_bookings', [
'facility_id' => $poolId,
'booking_date' => $today,
'start_time' => '09:00:00',
'end_time' => '10:00:00',
'zone_row' => 2,
'zone_col' => 3,
'assignment_type' => 'hourly',
'group_id' => null,
'coach_id' => null,
'label' => 'حجز خاص',
'notes' => null,
'status' => 'active',
'created_by' => 1,
'created_at' => $ts,
'updated_at' => $ts,
]);
};
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