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
public function state(Request $request, string $id): Response
{
$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)) {
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'])) {
return $this->json(['error' => $state['error']], 404);
return $this->json(['error' => $state['error']], 422);
}
return $this->json($state);
......@@ -31,16 +36,18 @@ class PoolGridApiController extends Controller
{
$body = $request->jsonBody();
$date = trim((string) ($body['date'] ?? ''));
$startTime = trim((string) ($body['start_time'] ?? ''));
$endTime = trim((string) ($body['end_time'] ?? ''));
$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)) {
if (!$date || !$startTime || !$endTime || !$action || empty($cells)) {
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);
}
......@@ -49,14 +56,47 @@ class PoolGridApiController extends Controller
{
$body = $request->jsonBody();
$date = trim((string) ($body['date'] ?? ''));
$startTime = trim((string) ($body['start_time'] ?? ''));
$cells = $body['cells'] ?? [];
if (!$date || empty($cells)) {
if (!$date || !$startTime || empty($cells)) {
return $this->json(['error' => 'بيانات ناقصة'], 400);
}
$result = PoolGridService::bulkClear((int) $id, $date, $cells);
$result = PoolGridService::bulkClear((int) $id, $date, $startTime, $cells);
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
$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]
);
$facility = PoolGridService::getFacility((int) $id);
if (!$facility) {
return $this->redirect('/sa/pool-grid')->withError('المرفق غير موجود');
}
$slots = PoolGridService::getTimeSlots($facility);
$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
......@@ -61,6 +59,7 @@ class PoolGridController extends Controller
return $this->view('SportsActivity.Views.pool-grid.manage', [
'facility' => $facility,
'slots' => $slots,
'groups' => $groups,
'date' => $date,
]);
......
......@@ -153,4 +153,6 @@ return [
['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'],
['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'],
];
......@@ -7,113 +7,110 @@ use App\Core\App;
final class PoolGridService
{
public static function getGridState(int $facilityId, string $date): array
public static function getFacility(int $facilityId): ?array
{
$db = App::getInstance()->db();
$facility = $db->selectOne(
"SELECT * FROM sa_facilities WHERE id = ? AND is_active = 1 AND is_archived = 0",
return $db->selectOne(
"SELECT * FROM sa_facilities WHERE id = ? AND facility_type = 'pool' AND is_active = 1 AND is_archived = 0",
[$facilityId]
);
}
public static function getTimeSlots(array $facility): array
{
$hours = json_decode($facility['operating_hours_json'], true)
?: ['start' => '06:00', 'end' => '22:00', 'slot_minutes' => 60];
$slots = [];
$current = strtotime($hours['start']);
$endTs = strtotime($hours['end']);
$minutes = (int) $hours['slot_minutes'];
while ($current < $endTs) {
$next = $current + ($minutes * 60);
$slots[] = [
'start' => date('H:i', $current),
'end' => date('H:i', $next),
];
$current = $next;
}
return $slots;
}
public static function getGridState(int $facilityId, string $date, string $startTime, string $endTime): array
{
$db = App::getInstance()->db();
$facility = self::getFacility($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]
);
$rows = (int) ($facility['pool_grid_rows'] ?? 0);
$cols = (int) ($facility['pool_grid_cols'] ?? 0);
if ($rows < 1 || $cols < 1) {
return ['error' => 'لم يتم تهيئة شبكة الحمام بعد. يرجى ضبط عدد الصفوف والأعمدة.'];
}
$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]
"SELECT pzb.*, g.name_ar as group_name, g.code as group_code,
c.full_name_ar as coach_name
FROM sa_pool_zone_bookings pzb
LEFT JOIN sa_groups g ON g.id = pzb.group_id
LEFT JOIN sa_coaches c ON c.id = pzb.coach_id
WHERE pzb.facility_id = ? AND pzb.booking_date = ?
AND pzb.start_time = ? AND pzb.status = 'active'",
[$facilityId, $date, $startTime . ':00']
);
$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),
for ($r = 0; $r < $rows; $r++) {
for ($c = 0; $c < $cols; $c++) {
$grid[$r . ':' . $c] = [
'row' => $r,
'col' => $c,
'status' => 'free',
'label' => '',
'type' => null,
'group_id' => null,
'booking_id' => null,
];
}
}
foreach ($bookings as $b) {
$key = $b['zone_row'] . ':' . $b['zone_col'];
if (isset($grid[$key])) {
$grid[$key]['status'] = $b['assignment_type'];
$grid[$key]['label'] = $b['label'] ?: ($b['group_name'] ?? '');
$grid[$key]['type'] = $b['assignment_type'];
$grid[$key]['group_id'] = $b['group_id'] ? (int) $b['group_id'] : null;
$grid[$key]['booking_id'] = (int) $b['id'];
$grid[$key]['coach_name'] = $b['coach_name'] ?? '';
$grid[$key]['notes'] = $b['notes'] ?? '';
}
}
return [
'facility' => $facility,
'units' => $units,
'slots' => $slots,
'grid' => $grid,
'date' => $date,
'facility' => $facility,
'rows' => $rows,
'cols' => $cols,
'grid' => $grid,
'date' => $date,
'start_time' => $startTime,
'end_time' => $endTime,
];
}
public static function bulkAssign(int $facilityId, string $date, string $action, array $cells, ?int $groupId, ?string $notes): array
public static function bulkAssign(int $facilityId, string $date, string $startTime, string $endTime, 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'];
$validActions = ['training', 'blocked', 'maintenance', 'hourly'];
if (!in_array($action, $validActions, true)) {
return ['success' => false, 'error' => 'إجراء غير صالح'];
}
......@@ -122,37 +119,50 @@ final class PoolGridService
return ['success' => false, 'error' => 'المجموعة مطلوبة لتعيين تدريب'];
}
$facility = self::getFacility($facilityId);
if (!$facility) {
return ['success' => false, 'error' => 'المرفق غير موجود'];
}
$maxRows = (int) ($facility['pool_grid_rows'] ?? 0);
$maxCols = (int) ($facility['pool_grid_cols'] ?? 0);
$group = null;
$coachId = null;
$label = '';
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;
$label = $group['name_ar'] ?? 'تدريب';
} elseif ($action === 'blocked') {
$label = 'محجوب';
} elseif ($action === 'maintenance') {
$label = 'صيانة';
} elseif ($action === 'hourly') {
$label = $notes ?: 'حجز ساعة';
}
$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'] ?? '';
$row = (int) ($cell['row'] ?? -1);
$col = (int) ($cell['col'] ?? -1);
if (!$unitId || !$startTime || !$endTime) {
if ($row < 0 || $row >= $maxRows || $col < 0 || $col >= $maxCols) {
$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]
"SELECT id FROM sa_pool_zone_bookings
WHERE facility_id = ? AND booking_date = ? AND start_time = ?
AND zone_row = ? AND zone_col = ? AND status = 'active'",
[$facilityId, $date, $startTime . ':00', $row, $col]
);
if ($conflict) {
......@@ -160,39 +170,30 @@ final class PoolGridService
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);
$db->insert('sa_pool_zone_bookings', [
'facility_id' => $facilityId,
'booking_date' => $date,
'start_time' => $startTime . ':00',
'end_time' => $endTime . ':00',
'zone_row' => $row,
'zone_col' => $col,
'assignment_type' => $action,
'group_id' => $groupId,
'coach_id' => $coachId,
'label' => $label,
'notes' => $notes,
'status' => 'active',
'created_by' => $employeeId,
'created_at' => $ts,
'updated_at' => $ts,
]);
$assigned++;
}
return ['success' => true, 'assigned' => $assigned, 'skipped' => $skipped];
}
public static function bulkClear(int $facilityId, string $date, array $cells): array
public static function bulkClear(int $facilityId, string $date, string $startTime, array $cells): array
{
$db = App::getInstance()->db();
$ts = date('Y-m-d H:i:s');
......@@ -200,28 +201,25 @@ final class PoolGridService
$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]
$row = (int) ($cell['row'] ?? -1);
$col = (int) ($cell['col'] ?? -1);
if ($row < 0 || $col < 0) continue;
$existing = $db->selectOne(
"SELECT id FROM sa_pool_zone_bookings
WHERE facility_id = ? AND booking_date = ? AND start_time = ?
AND zone_row = ? AND zone_col = ? AND status = 'active'",
[$facilityId, $date, $startTime . ':00', $row, $col]
);
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']]);
if ($existing) {
$db->update('sa_pool_zone_bookings', [
'status' => 'cancelled',
'cancelled_by' => $employeeId,
'cancelled_at' => $ts,
'updated_at' => $ts,
], 'id = ?', [(int) $existing['id']]);
$cleared++;
}
}
......@@ -229,21 +227,75 @@ final class PoolGridService
return ['success' => true, 'cleared' => $cleared];
}
private static function buildTimeSlots(string $start, string $end, int $slotMinutes): array
public static function updateGridSize(int $facilityId, int $rows, int $cols): array
{
$slots = [];
$current = strtotime($start);
$endTs = strtotime($end);
$db = App::getInstance()->db();
while ($current < $endTs) {
$next = $current + ($slotMinutes * 60);
$slots[] = [
'start' => date('H:i', $current),
'end' => date('H:i', $next),
];
$current = $next;
if ($rows < 1 || $rows > 20 || $cols < 1 || $cols > 20) {
return ['success' => false, 'error' => 'الحد المسموح من 1 إلى 20 صفوف/أعمدة'];
}
return $slots;
$db->update('sa_facilities', [
'pool_grid_rows' => $rows,
'pool_grid_cols' => $cols,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$facilityId]);
return ['success' => true];
}
public static function copySlot(int $facilityId, string $date, string $fromTime, string $toTime, string $toEndTime): array
{
$db = App::getInstance()->db();
$ts = date('Y-m-d H:i:s');
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$source = $db->select(
"SELECT * FROM sa_pool_zone_bookings
WHERE facility_id = ? AND booking_date = ? AND start_time = ? AND status = 'active'",
[$facilityId, $date, $fromTime . ':00']
);
if (empty($source)) {
return ['success' => false, 'error' => 'لا توجد بيانات في الفترة المصدر'];
}
$copied = 0;
$skipped = 0;
foreach ($source as $s) {
$conflict = $db->selectOne(
"SELECT id FROM sa_pool_zone_bookings
WHERE facility_id = ? AND booking_date = ? AND start_time = ?
AND zone_row = ? AND zone_col = ? AND status = 'active'",
[$facilityId, $date, $toTime . ':00', $s['zone_row'], $s['zone_col']]
);
if ($conflict) {
$skipped++;
continue;
}
$db->insert('sa_pool_zone_bookings', [
'facility_id' => $facilityId,
'booking_date' => $date,
'start_time' => $toTime . ':00',
'end_time' => $toEndTime . ':00',
'zone_row' => $s['zone_row'],
'zone_col' => $s['zone_col'],
'assignment_type' => $s['assignment_type'],
'group_id' => $s['group_id'],
'coach_id' => $s['coach_id'],
'label' => $s['label'],
'notes' => $s['notes'],
'status' => 'active',
'created_by' => $employeeId,
'created_at' => $ts,
'updated_at' => $ts,
]);
$copied++;
}
return ['success' => true, 'copied' => $copied, 'skipped' => $skipped];
}
}
<?php
$__template->layout('Layout.main');
$gridRows = (int) ($facility['pool_grid_rows'] ?? 0);
$gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
?>
<?php $__template->section('title'); ?>شبكة <?= e($facility['name_ar']) ?><?php $__template->endSection(); ?>
......@@ -10,124 +12,175 @@ $__template->layout('Layout.main');
<?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-top{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:16px;}
.pg-top .date-nav{display:flex;align-items:center;gap:6px;}
.pg-top .date-nav button{background:none;border:1px solid #D1D5DB;border-radius:6px;padding:6px 10px;cursor:pointer;font-size:14px;line-height:1;}
.pg-top .date-nav button:hover{background:#F3F4F6;}
.pg-top .date-nav input[type=date]{border:1px solid #D1D5DB;border-radius:6px;padding:6px 10px;font-size:14px;font-family:inherit;}
/* Timeline strip */
.pg-timeline{display:flex;gap:0;border:1px solid #E5E7EB;border-radius:8px;overflow:hidden;margin-bottom:16px;background:#F9FAFB;}
.pg-slot{flex:1;min-width:0;padding:10px 4px;text-align:center;cursor:pointer;border-left:1px solid #E5E7EB;transition:all .15s;font-size:12px;font-weight:500;user-select:none;}
.pg-slot:first-child{border-left:none;}
.pg-slot:hover{background:#EFF6FF;}
.pg-slot.active{background:#2563EB;color:#fff;font-weight:700;}
.pg-slot .slot-time{font-size:11px;display:block;margin-top:2px;opacity:.7;}
/* Grid setup card */
.pg-setup{background:#FEF3C7;border:1px solid #F59E0B;border-radius:8px;padding:20px;margin-bottom:16px;display:flex;align-items:center;gap:16px;flex-wrap:wrap;}
.pg-setup label{font-size:14px;font-weight:600;color:#92400E;}
.pg-setup input[type=number]{width:70px;border:1px solid #D1D5DB;border-radius:6px;padding:6px 10px;font-size:14px;text-align:center;}
.pg-setup button{background:#F59E0B;color:#fff;border:none;border-radius:6px;padding:8px 16px;font-size:13px;font-weight:600;cursor:pointer;}
.pg-setup button:hover{background:#D97706;}
/* Pool grid */
.pg-pool-wrap{position:relative;margin-bottom:16px;}
.pg-pool{display:inline-grid;gap:3px;padding:16px;background:#E0F2FE;border:2px solid #7DD3FC;border-radius:12px;min-width:100%;}
.pg-cell{width:100%;aspect-ratio:1;border-radius:6px;cursor:crosshair;transition:all .12s;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:11px;font-weight:500;border:2px solid transparent;user-select:none;position:relative;overflow:hidden;min-height:50px;}
.pg-cell:hover{transform:scale(1.04);z-index:1;box-shadow:0 2px 8px rgba(0,0,0,.15);}
.pg-cell.selected{border-color:#2563EB !important;box-shadow:0 0 0 2px rgba(37,99,235,.3);}
.pg-cell.status-free{background:#fff;border-color:#E5E7EB;}
.pg-cell.status-training{background:#DBEAFE;border-color:#93C5FD;}
.pg-cell.status-hourly{background:#D1FAE5;border-color:#6EE7B7;}
.pg-cell.status-blocked{background:#F3F4F6;border-color:#D1D5DB;}
.pg-cell.status-maintenance{background:#FEF3C7;border-color:#FCD34D;}
.pg-cell .cell-label{text-align:center;line-height:1.2;padding:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%;}
.pg-cell .cell-coord{position:absolute;top:2px;right:4px;font-size:9px;color:#9CA3AF;font-weight:400;}
/* Action bar */
.pg-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:12px 16px;background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:16px;}
.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 button:disabled{opacity:.35;cursor:not-allowed;}
.pg-actions .btn-training{background:#DBEAFE;color:#1D4ED8;}
.pg-actions .btn-training:hover:not(:disabled){background:#BFDBFE;}
.pg-actions .btn-hourly{background:#D1FAE5;color:#065F46;}
.pg-actions .btn-hourly:hover:not(:disabled){background:#A7F3D0;}
.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-actions .btn-copy{background:#E0E7FF;color:#3730A3;}
.pg-actions .btn-copy:hover:not(:disabled){background:#C7D2FE;}
.pg-sel-info{font-size:13px;color:#6B7280;margin-right:auto;}
/* Legend */
.pg-legend{display:flex;gap:14px;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-legend .sw{width:16px;height:16px;border-radius:4px;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;}
/* Modal */
.pg-modal-bg{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-bg.show{opacity:1;pointer-events:all;}
.pg-modal{background:#fff;border-radius:12px;padding:24px;width:420px;max-width:90vw;box-shadow:0 20px 60px rgba(0,0,0,.2);}
.pg-modal h3{margin:0 0 16px;font-size:18px;font-weight:700;}
.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-modal select,.pg-modal input,.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 .m-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px;}
.pg-modal .m-actions button{padding:8px 18px;border-radius:6px;font-size:14px;font-weight:500;cursor:pointer;font-family:inherit;border:none;}
.pg-modal .m-confirm{background:#2563EB;color:#fff;}
.pg-modal .m-confirm:hover{background:#1D4ED8;}
.pg-modal .m-cancel{background:#fff;color:#374151;border:1px solid #D1D5DB !important;}
/* Loading */
.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:12px;}
.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);}}
.pg-spinner{width:32px;height:32px;border:3px solid #E5E7EB;border-top-color:#2563EB;border-radius:50%;animation:pgspin .6s linear infinite;}
@keyframes pgspin{to{transform:rotate(360deg);}}
/* Axis labels */
.pg-axis-row{position:absolute;right:-28px;font-size:10px;color:#6B7280;font-weight:600;display:flex;align-items:center;height:100%;}
.pg-axis-col{text-align:center;font-size:10px;color:#6B7280;font-weight:600;padding:4px 0;}
</style>
<!-- Toolbar -->
<div class="pg-toolbar">
<!-- Date Navigation -->
<div class="pg-top">
<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>
<button id="pgToday" style="font-size:12px;padding:6px 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>
<!-- Timeline Strip -->
<div class="pg-timeline" id="pgTimeline">
<?php foreach ($slots as $i => $slot): ?>
<div class="pg-slot<?= $i === 0 ? ' active' : '' ?>" data-idx="<?= $i ?>" data-start="<?= e($slot['start']) ?>" data-end="<?= e($slot['end']) ?>">
<?= e($slot['start']) ?>
<span class="slot-time"><?= e($slot['end']) ?></span>
</div>
<?php endforeach; ?>
</div>
<!-- Grid Setup (only shown if not configured) -->
<?php if ($gridRows < 1 || $gridCols < 1): ?>
<div class="pg-setup" id="pgSetup">
<i data-lucide="settings" style="width:20px;height:20px;color:#92400E;"></i>
<span style="font-size:14px;color:#92400E;font-weight:600;">تهيئة الشبكة:</span>
<label>صفوف (طول) <input type="number" id="pgSetupRows" min="1" max="20" value="4"></label>
<label>أعمدة (عرض) <input type="number" id="pgSetupCols" min="1" max="20" value="6"></label>
<button id="pgSetupSave">حفظ الأبعاد</button>
</div>
<?php endif; ?>
<!-- Action Bar -->
<div class="pg-actions">
<span class="pg-sel-info" id="pgSelInfo">لا يوجد تحديد</span>
<button class="btn-training" id="pgBtnTraining" disabled><i data-lucide="users" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> تدريب</button>
<button class="btn-hourly" id="pgBtnHourly" disabled><i data-lucide="clock" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> ساعة</button>
<button class="btn-blocked" id="pgBtnBlocked" disabled><i data-lucide="lock" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> حجب</button>
<button class="btn-maintenance" id="pgBtnMaint" disabled><i data-lucide="wrench" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> صيانة</button>
<button class="btn-copy" id="pgBtnCopy" disabled title="نسخ من فترة أخرى"><i data-lucide="copy" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> نسخ</button>
<button class="btn-clear" id="pgBtnClear" disabled><i data-lucide="x" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> مسح</button>
</div>
<!-- Grid Container -->
<div style="position:relative;">
<!-- Pool Grid -->
<div class="pg-pool-wrap" 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 id="pgPool" class="pg-pool" style="grid-template-columns:repeat(<?= $gridCols ?: 6 ?>, 1fr);"></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 class="pg-legend" style="margin-top:12px;">
<span><span class="sw" style="background:#fff;"></span> فارغ</span>
<span><span class="sw" style="background:#DBEAFE;"></span> تدريب</span>
<span><span class="sw" style="background:#D1FAE5;"></span> حجز ساعة</span>
<span><span class="sw" style="background:#F3F4F6;"></span> محجوب</span>
<span><span class="sw" style="background:#FEF3C7;"></span> صيانة</span>
</div>
<!-- Training Assignment Modal -->
<div class="pg-modal-overlay" id="pgModal">
<!-- Modal: Training -->
<div class="pg-modal-bg" 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>
<h3 id="pgModalTitle">تعيين</h3>
<div id="pgModalContent">
<div id="pgModalGroup">
<label>المجموعة</label>
<select id="pgGroupSelect">
<option value="">اختر مجموعة...</option>
<?php foreach ($groups as $g): ?>
<option value="<?= (int) $g['id'] ?>">
<?= e($g['name_ar']) ?><?= e($g['coach_name'] ?? 'بدون مدرب') ?> (<?= (int) $g['current_count'] ?>/<?= (int) $g['max_capacity'] ?>)
</option>
<?php endforeach; ?>
</select>
</div>
<div id="pgModalCopy" style="display:none;">
<label>نسخ من فترة</label>
<select id="pgCopyFrom">
<?php foreach ($slots as $i => $slot): ?>
<option value="<?= e($slot['start']) ?>"><?= e($slot['start']) ?> - <?= e($slot['end']) ?></option>
<?php endforeach; ?>
</select>
</div>
<label>ملاحظات (اختياري)</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 class="m-actions">
<button class="m-cancel" id="pgModalCancel">إلغاء</button>
<button class="m-confirm" id="pgModalConfirm">تأكيد</button>
</div>
</div>
</div>
......@@ -135,143 +188,96 @@ $__template->layout('Layout.main');
<script>
(function(){
const FACILITY_ID = <?= (int) $facility['id'] ?>;
const CSRF_TOKEN = '<?= e($_SESSION['_csrf_token'] ?? '') ?>';
const CSRF = '<?= e($_SESSION['_csrf_token'] ?? '') ?>';
const SLOTS = <?= json_encode($slots, JSON_UNESCAPED_UNICODE) ?>;
let GRID_ROWS = <?= $gridRows ?>;
let GRID_COLS = <?= $gridCols ?>;
let gridState = null;
let activeSlotIdx = 0;
let gridData = {};
let selectedCells = new Set();
let isDragging = false;
let dragStart = null;
let dragMode = null; // 'select' or 'deselect'
let dragMode = null;
let pendingAction = null;
const pool = document.getElementById('pgPool');
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 timeline = document.getElementById('pgTimeline');
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();
}
// --- Date Navigation ---
function currentDate(){ return dateInput.value; }
function activeSlot(){ return SLOTS[activeSlotIdx] || SLOTS[0]; }
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(); });
document.getElementById('pgPrevDay').onclick = function(){ shiftDate(-1); };
document.getElementById('pgNextDay').onclick = function(){ shiftDate(1); };
document.getElementById('pgToday').onclick = function(){ dateInput.value = new Date().toISOString().slice(0,10); loadState(); };
dateInput.onchange = function(){ loadState(); };
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 shiftDate(d){
let dt = new Date(currentDate());
dt.setDate(dt.getDate() + d);
dateInput.value = dt.toISOString().slice(0,10);
loadState();
}
function clearSelection(){
selectedCells.clear();
updateSelectionUI();
}
function loadGrid(){
showLoading();
// --- Timeline ---
timeline.addEventListener('click', function(e){
let slot = e.target.closest('.pg-slot');
if(!slot) return;
document.querySelectorAll('.pg-slot').forEach(s => s.classList.remove('active'));
slot.classList.add('active');
activeSlotIdx = parseInt(slot.dataset.idx);
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(); });
loadState();
});
// --- Grid Setup ---
var setupBtn = document.getElementById('pgSetupSave');
if(setupBtn){
setupBtn.onclick = function(){
let r = parseInt(document.getElementById('pgSetupRows').value) || 0;
let c = parseInt(document.getElementById('pgSetupCols').value) || 0;
if(r < 1 || c < 1 || r > 20 || c > 20){ alert('الأبعاد يجب أن تكون بين 1 و 20'); return; }
showLoading();
apiPost('/api/sa/pool-grid/' + FACILITY_ID + '/update-grid', {rows: r, cols: c})
.then(function(res){
if(res.error){ alert(res.error); hideLoading(); return; }
GRID_ROWS = r; GRID_COLS = c;
pool.style.gridTemplateColumns = 'repeat(' + c + ', 1fr)';
document.getElementById('pgSetup').style.display = 'none';
toast('تم حفظ أبعاد الشبكة: ' + r + ' × ' + c, 'success');
loadState();
});
};
}
// --- Render Grid ---
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>';
let html = '';
for(let r = 0; r < GRID_ROWS; r++){
for(let c = 0; c < GRID_COLS; c++){
let key = r + ':' + c;
let cell = gridData[key] || {status:'free', label:'', type:null};
let cls = 'pg-cell status-' + (cell.status || 'free');
html += '<div class="' + cls + '" data-r="' + r + '" data-c="' + c + '" data-key="' + key + '">';
html += '<span class="cell-coord">' + (r+1) + ',' + (c+1) + '</span>';
if(cell.label) html += '<span class="cell-label">' + esc(cell.label) + '</span>';
html += '</div>';
}
bhtml += '</tr>';
}
body.innerHTML = bhtml;
bindCellEvents();
bindHeaderEvents();
pool.innerHTML = html;
pool.style.gridTemplateColumns = 'repeat(' + GRID_COLS + ', 1fr)';
bindCells();
updateSelectionUI();
}
function bindCellEvents(){
let cells = document.querySelectorAll('.pg-cell');
cells.forEach(function(el){
// --- Cell Interactions ---
function bindCells(){
pool.querySelectorAll('.pg-cell').forEach(function(el){
el.addEventListener('mousedown', function(e){
if(e.button !== 0) return;
e.preventDefault();
......@@ -280,18 +286,17 @@ $__template->layout('Layout.main');
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);
} else if(e.shiftKey && selectedCells.size > 0){
rectSelect(key);
dragMode = 'select';
} else {
selectedCells.clear();
selectedCells.add(key);
dragMode = 'select';
}
dragStart = key;
updateSelectionUI();
});
el.addEventListener('mouseenter', function(e){
el.addEventListener('mouseenter', function(){
if(!isDragging) return;
let key = el.dataset.key;
if(dragMode === 'select') selectedCells.add(key);
......@@ -301,214 +306,207 @@ $__template->layout('Layout.main');
});
}
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));
function rectSelect(endKey){
let last = Array.from(selectedCells).pop();
if(!last) return;
let [r1, c1] = last.split(':').map(Number);
let [r2, c2] = endKey.split(':').map(Number);
let minR = Math.min(r1,r2), maxR = Math.max(r1,r2);
let minC = Math.min(c1,c2), maxC = Math.max(c1,c2);
for(let r = minR; r <= maxR; r++){
for(let c = minC; c <= maxC; c++){
selectedCells.add(r + ':' + c);
}
}
}
document.addEventListener('mouseup', function(){
isDragging = false;
dragMode = null;
});
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'){
if((e.ctrlKey || e.metaKey) && e.key === 'a' && !isInput(e.target)){
e.preventDefault();
if(!gridState) return;
gridState.units.forEach(function(u){
gridState.slots.forEach(function(s, si){
selectedCells.add(cellKey(u.id, si));
});
});
for(let r=0;r<GRID_ROWS;r++) for(let c=0;c<GRID_COLS;c++) selectedCells.add(r+':'+c);
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 isInput(el){ return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT'; }
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 clearSelection(){
selectedCells.clear();
updateSelectionUI();
}
function updateSelectionUI(){
let n = selectedCells.size;
selInfo.textContent = n > 0 ? n + ' مربع محدد' : 'لا يوجد تحديد';
let has = n > 0;
document.getElementById('pgBtnTraining').disabled = !has;
document.getElementById('pgBtnHourly').disabled = !has;
document.getElementById('pgBtnBlocked').disabled = !has;
document.getElementById('pgBtnMaint').disabled = !has;
document.getElementById('pgBtnClear').disabled = !has;
document.getElementById('pgBtnCopy').disabled = SLOTS.length < 2;
pool.querySelectorAll('.pg-cell').forEach(function(el){
el.classList.toggle('selected', selectedCells.has(el.dataset.key));
});
}
function hideModal(){
modal.classList.remove('active');
pendingAction = null;
// --- Actions ---
document.getElementById('pgBtnTraining').onclick = function(){ showModal('training'); };
document.getElementById('pgBtnHourly').onclick = function(){ showModal('hourly'); };
document.getElementById('pgBtnBlocked').onclick = function(){ doAssign('blocked'); };
document.getElementById('pgBtnMaint').onclick = function(){ doAssign('maintenance'); };
document.getElementById('pgBtnCopy').onclick = function(){ showModal('copy'); };
document.getElementById('pgBtnClear').onclick = function(){
if(!confirm('هل تريد مسح التعيينات من المربعات المحددة؟')) return;
doClear();
};
function showModal(action){
pendingAction = action;
document.getElementById('pgModalGroup').style.display = (action === 'training') ? '' : 'none';
document.getElementById('pgModalCopy').style.display = (action === 'copy') ? '' : 'none';
if(action === 'training') modalTitle.textContent = 'تعيين تدريب';
else if(action === 'hourly') modalTitle.textContent = 'حجز ساعة';
else if(action === 'copy') modalTitle.textContent = 'نسخ من فترة أخرى';
document.getElementById('pgNotes').value = '';
document.getElementById('pgGroupSelect').value = '';
modal.classList.add('show');
}
modalCancel.addEventListener('click', hideModal);
function hideModal(){ modal.classList.remove('show'); pendingAction = null; }
document.getElementById('pgModalCancel').onclick = hideModal;
modal.addEventListener('click', function(e){ if(e.target === modal) hideModal(); });
modalConfirm.addEventListener('click', function(){
document.getElementById('pgModalConfirm').onclick = function(){
if(pendingAction === 'training'){
let gid = groupSelect.value;
let gid = document.getElementById('pgGroupSelect').value;
if(!gid){ alert('اختر مجموعة'); return; }
submitAction('training', parseInt(gid), notesInput.value.trim());
doAssign('training', parseInt(gid));
} else if(pendingAction === 'hourly'){
doAssign('hourly', null);
} else if(pendingAction === 'copy'){
doCopy();
}
hideModal();
});
};
function buildCellsPayload(){
function buildCells(){
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'
});
}
let [r, c] = key.split(':').map(Number);
cells.push({row: r, col: c});
});
return cells;
}
function submitAction(action, groupId, notes){
let cells = buildCellsPayload();
if(!cells.length) return;
function doAssign(action, groupId){
let slot = activeSlot();
let notes = document.getElementById('pgNotes').value.trim();
showLoading();
let payload = {
apiPost('/api/sa/pool-grid/' + FACILITY_ID + '/assign', {
date: currentDate(),
start_time: slot.start,
end_time: slot.end,
action: action,
cells: cells,
cells: buildCells(),
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(res){
if(res.error){ alert(res.error); hideLoading(); return; }
let msg = 'تم: ' + (res.assigned || 0) + ' مربع';
if(res.skipped) msg += ' | تخطي: ' + res.skipped;
toast(msg, 'success');
clearSelection();
loadState();
});
}
function doClear(){
let slot = activeSlot();
showLoading();
apiPost('/api/sa/pool-grid/' + FACILITY_ID + '/clear', {
date: currentDate(),
start_time: slot.start,
cells: buildCells()
}).then(function(res){
if(res.error){ alert(res.error); hideLoading(); return; }
toast('تم المسح: ' + (res.cleared || 0), 'success');
clearSelection();
loadState();
});
}
function doCopy(){
let slot = activeSlot();
let fromTime = document.getElementById('pgCopyFrom').value;
if(fromTime === slot.start){ alert('لا يمكن النسخ من نفس الفترة'); return; }
showLoading();
apiPost('/api/sa/pool-grid/' + FACILITY_ID + '/copy-slot', {
date: currentDate(),
from_time: fromTime,
to_time: slot.start,
to_end_time: slot.end
}).then(function(res){
if(res.error){ alert(res.error); hideLoading(); return; }
toast('تم النسخ: ' + (res.copied || 0) + ' مربع', 'success');
loadState();
});
}
// --- API ---
function loadState(){
if(GRID_ROWS < 1 || GRID_COLS < 1){ renderGrid(); hideLoading(); return; }
showLoading();
let slot = activeSlot();
fetch('/api/sa/pool-grid/' + FACILITY_ID + '/state?date=' + encodeURIComponent(currentDate()) + '&start_time=' + encodeURIComponent(slot.start) + '&end_time=' + encodeURIComponent(slot.end), {
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.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();
if(data.error){ toast(data.error, 'error'); hideLoading(); renderGrid(); return; }
GRID_ROWS = data.rows || GRID_ROWS;
GRID_COLS = data.cols || GRID_COLS;
gridData = data.grid || {};
renderGrid();
hideLoading();
})
.catch(function(){ alert('خطأ في العملية'); hideLoading(); });
.catch(function(){ toast('خطأ في تحميل البيانات', 'error'); hideLoading(); });
}
function submitClear(){
let cells = buildCellsPayload();
if(!cells.length) return;
showLoading();
fetch('/api/sa/pool-grid/' + FACILITY_ID + '/clear', {
function apiPost(url, body){
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': CSRF_TOKEN,
'X-CSRF-TOKEN': CSRF,
'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(); });
body: JSON.stringify(body)
}).then(function(r){ return r.json(); }).catch(function(){ return {error:'خطأ في الاتصال'}; });
}
function showToast(msg, type){
function showLoading(){ loading.classList.remove('hidden'); }
function hideLoading(){ loading.classList.add('hidden'); }
function toast(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.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);padding:12px 24px;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;font-family:inherit;';
el.style.background = type === 'success' ? '#D1FAE5' : '#FEE2E2';
el.style.color = type === 'success' ? '#065F46' : '#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;');
}
function esc(s){ return s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') : ''; }
// Initial load
loadGrid();
// --- Init ---
if(GRID_ROWS > 0 && GRID_COLS > 0) loadState();
else renderGrid();
if(typeof lucide !== 'undefined') lucide.createIcons();
})();
</script>
......
<?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