Commit 00f555fb authored by Mahmoud Aglan's avatar Mahmoud Aglan

test

parent a4373d67
......@@ -36,18 +36,25 @@ 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 || !$startTime || !$endTime || !$action || empty($cells)) {
// Support multi-slot (slots array) or legacy single start_time/end_time
if (!empty($body['slots']) && is_array($body['slots'])) {
$slots = $body['slots'];
} else {
$startTime = trim((string) ($body['start_time'] ?? ''));
$endTime = trim((string) ($body['end_time'] ?? ''));
$slots = [['start_time' => $startTime, 'end_time' => $endTime]];
}
if (!$date || !$action || empty($cells) || empty($slots)) {
return $this->json(['error' => 'بيانات ناقصة'], 400);
}
$result = PoolGridService::bulkAssign((int) $id, $date, $startTime, $endTime, $action, $cells, $groupId, $notes ?: null);
$result = PoolGridService::bulkAssign((int) $id, $date, $slots, $action, $cells, $groupId, $notes ?: null);
return $this->json($result, $result['success'] ? 200 : 422);
}
......@@ -56,14 +63,21 @@ class PoolGridApiController extends Controller
{
$body = $request->jsonBody();
$date = trim((string) ($body['date'] ?? ''));
$startTime = trim((string) ($body['start_time'] ?? ''));
$cells = $body['cells'] ?? [];
if (!$date || !$startTime || empty($cells)) {
// Support multi-slot or legacy single start_time
if (!empty($body['slots']) && is_array($body['slots'])) {
$slots = $body['slots'];
} else {
$startTime = trim((string) ($body['start_time'] ?? ''));
$slots = [['start_time' => $startTime]];
}
if (!$date || empty($cells) || empty($slots)) {
return $this->json(['error' => 'بيانات ناقصة'], 400);
}
$result = PoolGridService::bulkClear((int) $id, $date, $startTime, $cells);
$result = PoolGridService::bulkClear((int) $id, $date, $slots, $cells);
return $this->json($result);
}
......@@ -88,14 +102,21 @@ class PoolGridApiController extends Controller
$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) {
// Support multi-target (to_slots array) or legacy single to_time/to_end_time
if (!empty($body['to_slots']) && is_array($body['to_slots'])) {
$toSlots = $body['to_slots'];
} else {
$toTime = trim((string) ($body['to_time'] ?? ''));
$toEndTime = trim((string) ($body['to_end_time'] ?? ''));
$toSlots = [['start' => $toTime, 'end' => $toEndTime]];
}
if (!$date || !$fromTime || empty($toSlots)) {
return $this->json(['error' => 'بيانات ناقصة'], 400);
}
$result = PoolGridService::copySlot((int) $id, $date, $fromTime, $toTime, $toEndTime);
$result = PoolGridService::copySlot((int) $id, $date, $fromTime, $toSlots);
return $this->json($result, $result['success'] ? 200 : 422);
}
......
<?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\PoolGridTemplateService;
class PoolGridTemplateApiController extends Controller
{
public function list(Request $request, string $id): Response
{
$templates = PoolGridTemplateService::getTemplates((int) $id);
return $this->json(['templates' => $templates]);
}
public function store(Request $request, string $id): Response
{
$body = $request->jsonBody();
$name = trim((string) ($body['name'] ?? ''));
$description = trim((string) ($body['description'] ?? ''));
$color = trim((string) ($body['color'] ?? '#3B82F6'));
$entries = $body['entries'] ?? [];
if (!$name) {
return $this->json(['error' => 'اسم القالب مطلوب'], 400);
}
$result = PoolGridTemplateService::createTemplate((int) $id, $name, $description ?: null, $color, $entries);
return $this->json($result, $result['success'] ? 200 : 422);
}
public function update(Request $request, string $id, string $tid): Response
{
$body = $request->jsonBody();
$name = trim((string) ($body['name'] ?? ''));
$description = trim((string) ($body['description'] ?? ''));
$color = trim((string) ($body['color'] ?? '#3B82F6'));
$entries = $body['entries'] ?? [];
if (!$name) {
return $this->json(['error' => 'اسم القالب مطلوب'], 400);
}
$db = \App\Core\App::getInstance()->db();
$ts = date('Y-m-d H:i:s');
$db->update('sa_pool_zone_templates', [
'name' => $name,
'description' => $description ?: null,
'color' => $color,
'updated_at' => $ts,
], 'id = ?', [(int) $tid]);
// Replace entries
$db->delete('sa_pool_zone_template_entries', 'template_id = ?', [(int) $tid]);
foreach ($entries as $entry) {
$zoneJson = $entry['zone_selection_json'] ?? '{}';
if (is_array($zoneJson)) {
$zoneJson = json_encode($zoneJson, JSON_UNESCAPED_UNICODE);
}
$db->insert('sa_pool_zone_template_entries', [
'template_id' => (int) $tid,
'day_of_week' => (int) ($entry['day_of_week'] ?? 0),
'start_time' => $entry['start_time'] ?? '09:00:00',
'end_time' => $entry['end_time'] ?? '10:00:00',
'zone_selection_type' => $entry['zone_selection_type'] ?? 'all',
'zone_selection_json' => $zoneJson,
'assignment_type' => $entry['assignment_type'] ?? 'training',
'group_id' => !empty($entry['group_id']) ? (int) $entry['group_id'] : null,
'coach_id' => !empty($entry['coach_id']) ? (int) $entry['coach_id'] : null,
'label' => $entry['label'] ?? $name,
'notes' => $entry['notes'] ?? null,
'created_at' => $ts,
'updated_at' => $ts,
]);
}
return $this->json(['success' => true]);
}
public function delete(Request $request, string $id, string $tid): Response
{
$result = PoolGridTemplateService::deleteTemplate((int) $tid);
return $this->json($result, $result['success'] ? 200 : 422);
}
public function expand(Request $request, string $id, string $tid): Response
{
$body = $request->jsonBody();
$fromDate = trim((string) ($body['from_date'] ?? ''));
$toDate = trim((string) ($body['to_date'] ?? ''));
if (!$fromDate || !$toDate) {
return $this->json(['error' => 'التواريخ مطلوبة'], 400);
}
if ($fromDate > $toDate) {
return $this->json(['error' => 'تاريخ البداية يجب أن يكون قبل النهاية'], 400);
}
// Max 60 days to prevent accidental mass generation
$daysDiff = (strtotime($toDate) - strtotime($fromDate)) / 86400;
if ($daysDiff > 60) {
return $this->json(['error' => 'الحد الأقصى 60 يوم للتوسيع'], 400);
}
$result = PoolGridTemplateService::expandTemplate((int) $tid, $fromDate, $toDate);
return $this->json($result, $result['success'] ? 200 : 422);
}
public function rollback(Request $request, string $id, string $tid, string $rid): Response
{
$result = PoolGridTemplateService::rollbackRun((int) $rid);
return $this->json($result, $result['success'] ? 200 : 422);
}
}
......@@ -155,4 +155,12 @@ return [
['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'],
// Pool Grid Templates
['GET', '/api/sa/pool-grid/{id:\d+}/templates', 'SportsActivity\Controllers\Api\PoolGridTemplateApiController@list', ['auth'], 'sa.pool-grid.manage'],
['POST', '/api/sa/pool-grid/{id:\d+}/templates', 'SportsActivity\Controllers\Api\PoolGridTemplateApiController@store', ['auth', 'csrf'], 'sa.pool-grid.manage'],
['POST', '/api/sa/pool-grid/{id:\d+}/templates/{tid:\d+}', 'SportsActivity\Controllers\Api\PoolGridTemplateApiController@update', ['auth', 'csrf'], 'sa.pool-grid.manage'],
['POST', '/api/sa/pool-grid/{id:\d+}/templates/{tid:\d+}/delete', 'SportsActivity\Controllers\Api\PoolGridTemplateApiController@delete', ['auth', 'csrf'], 'sa.pool-grid.manage'],
['POST', '/api/sa/pool-grid/{id:\d+}/templates/{tid:\d+}/expand', 'SportsActivity\Controllers\Api\PoolGridTemplateApiController@expand', ['auth', 'csrf'], 'sa.pool-grid.manage'],
['POST', '/api/sa/pool-grid/{id:\d+}/templates/{tid:\d+}/runs/{rid:\d+}/rollback', 'SportsActivity\Controllers\Api\PoolGridTemplateApiController@rollback', ['auth', 'csrf'], 'sa.pool-grid.manage'],
];
......@@ -104,7 +104,10 @@ final class PoolGridService
];
}
public static function bulkAssign(int $facilityId, string $date, string $startTime, string $endTime, string $action, array $cells, ?int $groupId, ?string $notes): array
/**
* Bulk assign cells across one or more time slots.
*/
public static function bulkAssign(int $facilityId, string $date, array $slots, string $action, array $cells, ?int $groupId, ?string $notes, ?int $templateRunId = null): array
{
$db = App::getInstance()->db();
$ts = date('Y-m-d H:i:s');
......@@ -127,7 +130,6 @@ final class PoolGridService
$maxRows = (int) ($facility['pool_grid_rows'] ?? 0);
$maxCols = (int) ($facility['pool_grid_cols'] ?? 0);
$group = null;
$coachId = null;
$label = '';
......@@ -149,78 +151,102 @@ final class PoolGridService
$assigned = 0;
$skipped = 0;
foreach ($cells as $cell) {
$row = (int) ($cell['row'] ?? -1);
$col = (int) ($cell['col'] ?? -1);
if ($row < 0 || $row >= $maxRows || $col < 0 || $col >= $maxCols) {
$skipped++;
continue;
}
foreach ($slots as $slot) {
$startTime = $slot['start_time'] ?? '';
$endTime = $slot['end_time'] ?? '';
if (!$startTime || !$endTime) continue;
$startTimeFull = (strlen($startTime) === 5) ? $startTime . ':00' : $startTime;
$endTimeFull = (strlen($endTime) === 5) ? $endTime . ':00' : $endTime;
foreach ($cells as $cell) {
$row = (int) ($cell['row'] ?? -1);
$col = (int) ($cell['col'] ?? -1);
if ($row < 0 || $row >= $maxRows || $col < 0 || $col >= $maxCols) {
$skipped++;
continue;
}
$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, $startTimeFull, $row, $col]
);
if ($conflict) {
$skipped++;
continue;
}
$insertData = [
'facility_id' => $facilityId,
'booking_date' => $date,
'start_time' => $startTimeFull,
'end_time' => $endTimeFull,
'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,
];
$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, $startTime . ':00', $row, $col]
);
if ($templateRunId !== null) {
$insertData['template_run_id'] = $templateRunId;
}
if ($conflict) {
$skipped++;
continue;
$db->insert('sa_pool_zone_bookings', $insertData);
$assigned++;
}
$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, string $startTime, array $cells): array
/**
* Bulk clear cells across one or more time slots.
*/
public static function bulkClear(int $facilityId, string $date, array $slots, 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) {
$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]
);
if ($existing) {
$db->update('sa_pool_zone_bookings', [
'status' => 'cancelled',
'cancelled_by' => $employeeId,
'cancelled_at' => $ts,
'updated_at' => $ts,
], 'id = ?', [(int) $existing['id']]);
$cleared++;
foreach ($slots as $slot) {
$startTime = $slot['start_time'] ?? '';
if (!$startTime) continue;
$startTimeFull = (strlen($startTime) === 5) ? $startTime . ':00' : $startTime;
foreach ($cells as $cell) {
$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, $startTimeFull, $row, $col]
);
if ($existing) {
$db->update('sa_pool_zone_bookings', [
'status' => 'cancelled',
'cancelled_by' => $employeeId,
'cancelled_at' => $ts,
'updated_at' => $ts,
], 'id = ?', [(int) $existing['id']]);
$cleared++;
}
}
}
......@@ -244,16 +270,21 @@ final class PoolGridService
return ['success' => true];
}
public static function copySlot(int $facilityId, string $date, string $fromTime, string $toTime, string $toEndTime): array
/**
* Copy from one source slot to one or more target slots.
*/
public static function copySlot(int $facilityId, string $date, string $fromTime, array $toSlots): array
{
$db = App::getInstance()->db();
$ts = date('Y-m-d H:i:s');
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$fromTimeFull = (strlen($fromTime) === 5) ? $fromTime . ':00' : $fromTime;
$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']
[$facilityId, $date, $fromTimeFull]
);
if (empty($source)) {
......@@ -263,37 +294,46 @@ final class PoolGridService
$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;
foreach ($toSlots as $target) {
$toTime = $target['start'] ?? '';
$toEndTime = $target['end'] ?? '';
if (!$toTime || !$toEndTime) continue;
$toTimeFull = (strlen($toTime) === 5) ? $toTime . ':00' : $toTime;
$toEndTimeFull = (strlen($toEndTime) === 5) ? $toEndTime . ':00' : $toEndTime;
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, $toTimeFull, $s['zone_row'], $s['zone_col']]
);
if ($conflict) {
$skipped++;
continue;
}
$db->insert('sa_pool_zone_bookings', [
'facility_id' => $facilityId,
'booking_date' => $date,
'start_time' => $toTimeFull,
'end_time' => $toEndTimeFull,
'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++;
}
$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
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
final class PoolGridTemplateService
{
public static function getTemplates(int $facilityId): array
{
$db = App::getInstance()->db();
$templates = $db->select(
"SELECT t.*,
(SELECT COUNT(*) FROM sa_pool_zone_template_entries WHERE template_id = t.id) as entries_count
FROM sa_pool_zone_templates t
WHERE t.facility_id = ? AND t.is_active = 1
ORDER BY t.created_at DESC",
[$facilityId]
);
return $templates;
}
public static function getTemplate(int $templateId): ?array
{
$db = App::getInstance()->db();
$template = $db->selectOne("SELECT * FROM sa_pool_zone_templates WHERE id = ?", [$templateId]);
if (!$template) return null;
$template['entries'] = $db->select(
"SELECT e.*, g.name_ar as group_name
FROM sa_pool_zone_template_entries e
LEFT JOIN sa_groups g ON g.id = e.group_id
WHERE e.template_id = ?
ORDER BY e.day_of_week, e.start_time",
[$templateId]
);
return $template;
}
public static function createTemplate(int $facilityId, string $name, ?string $description, ?string $color, array $entries): array
{
$db = App::getInstance()->db();
$ts = date('Y-m-d H:i:s');
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
if (!trim($name)) {
return ['success' => false, 'error' => 'اسم القالب مطلوب'];
}
if (empty($entries)) {
return ['success' => false, 'error' => 'يجب إضافة إدخال واحد على الأقل'];
}
$db->insert('sa_pool_zone_templates', [
'facility_id' => $facilityId,
'name' => trim($name),
'description' => $description ? trim($description) : null,
'color' => $color ?: '#3B82F6',
'is_active' => 1,
'created_by' => $employeeId,
'created_at' => $ts,
'updated_at' => $ts,
]);
$templateId = (int) $db->selectOne("SELECT LAST_INSERT_ID() as id", [])['id'];
foreach ($entries as $entry) {
$zoneJson = $entry['zone_selection_json'] ?? '{}';
if (is_array($zoneJson)) {
$zoneJson = json_encode($zoneJson, JSON_UNESCAPED_UNICODE);
}
$coachId = null;
$groupId = !empty($entry['group_id']) ? (int) $entry['group_id'] : null;
if ($groupId) {
$group = $db->selectOne("SELECT coach_id FROM sa_groups WHERE id = ?", [$groupId]);
$coachId = $group ? ((int) $group['coach_id'] ?: null) : null;
}
$db->insert('sa_pool_zone_template_entries', [
'template_id' => $templateId,
'day_of_week' => (int) ($entry['day_of_week'] ?? 0),
'start_time' => $entry['start_time'] ?? '09:00:00',
'end_time' => $entry['end_time'] ?? '10:00:00',
'zone_selection_type' => $entry['zone_selection_type'] ?? 'all',
'zone_selection_json' => $zoneJson,
'assignment_type' => $entry['assignment_type'] ?? 'training',
'group_id' => $groupId,
'coach_id' => $coachId,
'label' => $entry['label'] ?? $name,
'notes' => $entry['notes'] ?? null,
'created_at' => $ts,
'updated_at' => $ts,
]);
}
return ['success' => true, 'template_id' => $templateId];
}
public static function deleteTemplate(int $templateId): array
{
$db = App::getInstance()->db();
$template = $db->selectOne("SELECT id FROM sa_pool_zone_templates WHERE id = ?", [$templateId]);
if (!$template) {
return ['success' => false, 'error' => 'القالب غير موجود'];
}
$db->update('sa_pool_zone_templates', [
'is_active' => 0,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$templateId]);
return ['success' => true];
}
public static function expandTemplate(int $templateId, string $fromDate, string $toDate): array
{
$db = App::getInstance()->db();
$ts = date('Y-m-d H:i:s');
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$template = self::getTemplate($templateId);
if (!$template) {
return ['success' => false, 'error' => 'القالب غير موجود'];
}
$facility = PoolGridService::getFacility((int) $template['facility_id']);
if (!$facility) {
return ['success' => false, 'error' => 'المرفق غير موجود'];
}
$maxRows = (int) ($facility['pool_grid_rows'] ?? 0);
$maxCols = (int) ($facility['pool_grid_cols'] ?? 0);
if ($maxRows < 1 || $maxCols < 1) {
return ['success' => false, 'error' => 'الشبكة غير مهيأة'];
}
// Create run record
$db->insert('sa_pool_zone_template_runs', [
'template_id' => $templateId,
'date_from' => $fromDate,
'date_to' => $toDate,
'generated_count' => 0,
'skipped_count' => 0,
'status' => 'completed',
'created_by' => $employeeId,
'created_at' => $ts,
]);
$runId = (int) $db->selectOne("SELECT LAST_INSERT_ID() as id", [])['id'];
$entries = $template['entries'] ?? [];
$generated = 0;
$skipped = 0;
// Iterate each day in range
$current = strtotime($fromDate);
$end = strtotime($toDate);
while ($current <= $end) {
$dayOfWeek = (int) date('w', $current); // 0=Sunday
$dateStr = date('Y-m-d', $current);
// Process matching entries for this day
foreach ($entries as $entry) {
if ((int) $entry['day_of_week'] !== $dayOfWeek) continue;
$cells = self::resolveZoneSelection(
$entry['zone_selection_type'],
$entry['zone_selection_json'],
$maxRows,
$maxCols
);
$startTime = $entry['start_time'];
$endTime = $entry['end_time'];
foreach ($cells as $cell) {
$row = (int) $cell['row'];
$col = (int) $cell['col'];
if ($row < 0 || $row >= $maxRows || $col < 0 || $col >= $maxCols) {
$skipped++;
continue;
}
$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'",
[(int) $template['facility_id'], $dateStr, $startTime, $row, $col]
);
if ($conflict) {
$skipped++;
continue;
}
$db->insert('sa_pool_zone_bookings', [
'facility_id' => (int) $template['facility_id'],
'booking_date' => $dateStr,
'start_time' => $startTime,
'end_time' => $endTime,
'zone_row' => $row,
'zone_col' => $col,
'assignment_type' => $entry['assignment_type'],
'group_id' => $entry['group_id'] ? (int) $entry['group_id'] : null,
'coach_id' => $entry['coach_id'] ? (int) $entry['coach_id'] : null,
'label' => $entry['label'] ?: $template['name'],
'notes' => $entry['notes'],
'template_run_id' => $runId,
'status' => 'active',
'created_by' => $employeeId,
'created_at' => $ts,
'updated_at' => $ts,
]);
$generated++;
}
}
$current = strtotime('+1 day', $current);
}
// Update run with counts
$db->update('sa_pool_zone_template_runs', [
'generated_count' => $generated,
'skipped_count' => $skipped,
], 'id = ?', [$runId]);
return ['success' => true, 'generated' => $generated, 'skipped' => $skipped, 'run_id' => $runId];
}
public static function rollbackRun(int $runId): array
{
$db = App::getInstance()->db();
$ts = date('Y-m-d H:i:s');
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$run = $db->selectOne("SELECT * FROM sa_pool_zone_template_runs WHERE id = ?", [$runId]);
if (!$run) {
return ['success' => false, 'error' => 'السجل غير موجود'];
}
if ($run['status'] === 'rolled_back') {
return ['success' => false, 'error' => 'تم التراجع عن هذا التوسيع مسبقاً'];
}
// Cancel all bookings from this run
$db->query(
"UPDATE sa_pool_zone_bookings
SET status = 'cancelled', cancelled_by = ?, cancelled_at = ?, updated_at = ?
WHERE template_run_id = ? AND status = 'active'",
[$employeeId, $ts, $ts, $runId]
);
$db->update('sa_pool_zone_template_runs', [
'status' => 'rolled_back',
'rolled_back_at' => $ts,
'rolled_back_by' => $employeeId,
], 'id = ?', [$runId]);
return ['success' => true];
}
public static function getRunHistory(int $templateId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM sa_pool_zone_template_runs WHERE template_id = ? ORDER BY created_at DESC LIMIT 20",
[$templateId]
);
}
private static function resolveZoneSelection(string $type, $json, int $maxRows, int $maxCols): array
{
$data = is_string($json) ? json_decode($json, true) : $json;
if (!is_array($data)) $data = [];
$cells = [];
switch ($type) {
case 'all':
for ($r = 0; $r < $maxRows; $r++) {
for ($c = 0; $c < $maxCols; $c++) {
$cells[] = ['row' => $r, 'col' => $c];
}
}
break;
case 'row':
$rows = $data['rows'] ?? [];
foreach ($rows as $r) {
$r = (int) $r;
if ($r >= 0 && $r < $maxRows) {
for ($c = 0; $c < $maxCols; $c++) {
$cells[] = ['row' => $r, 'col' => $c];
}
}
}
break;
case 'column':
$cols = $data['cols'] ?? [];
foreach ($cols as $c) {
$c = (int) $c;
if ($c >= 0 && $c < $maxCols) {
for ($r = 0; $r < $maxRows; $r++) {
$cells[] = ['row' => $r, 'col' => $c];
}
}
}
break;
case 'cells':
default:
if (isset($data[0]) && is_array($data[0])) {
foreach ($data as $cell) {
if (isset($cell['row']) && isset($cell['col'])) {
$cells[] = ['row' => (int) $cell['row'], 'col' => (int) $cell['col']];
}
}
}
break;
}
return $cells;
}
}
......@@ -12,31 +12,54 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
<?php $__template->section('content'); ?>
<style>
/* Top bar */
.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;}
.pg-top-actions{display:flex;gap:6px;margin-right:auto;}
.pg-top-actions button{background:none;border:1px solid #D1D5DB;border-radius:6px;padding:6px 10px;cursor:pointer;font-size:13px;display:flex;align-items:center;gap:4px;}
.pg-top-actions button:hover{background:#F3F4F6;}
.pg-top-actions button.active{background:#EFF6FF;border-color:#93C5FD;color:#1D4ED8;}
/* 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-timeline-wrap{margin-bottom:16px;}
.pg-timeline{display:flex;gap:0;border:1px solid #E5E7EB;border-radius:8px;overflow-x:auto;background:#F9FAFB;user-select:none;}
.pg-slot{flex:0 0 auto;min-width:60px;padding:10px 8px;text-align:center;cursor:pointer;border-left:1px solid #E5E7EB;transition:all .15s;font-size:12px;font-weight:500;}
.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;}
.pg-slot .slot-time{font-size:10px;display:block;margin-top:2px;opacity:.7;}
.pg-timeline-info{font-size:12px;color:#6B7280;margin-top:4px;}
/* 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;}
/* Grid setup panel */
.pg-setup{background:#FEF3C7;border:1px solid #F59E0B;border-radius:8px;padding:16px;margin-bottom:16px;display:flex;align-items:center;gap:16px;flex-wrap:wrap;transition:all .2s;}
.pg-setup.neutral{background:#F3F4F6;border-color:#D1D5DB;}
.pg-setup.hidden{display:none;}
.pg-setup label{font-size:13px;font-weight:600;color:#92400E;}
.pg-setup.neutral label{color:#374151;}
.pg-setup input[type=number]{width:60px;border:1px solid #D1D5DB;border-radius:6px;padding:6px 8px;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;}
.pg-setup.neutral button{background:#2563EB;}
.pg-setup button:hover{opacity:.9;}
.pg-setup .pg-size-current{font-size:13px;color:#6B7280;font-weight:500;}
/* 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;}
/* Pool grid wrapper with headers */
.pg-grid-outer{display:inline-grid;gap:0;margin-bottom:16px;min-width:100%;}
.pg-col-header{text-align:center;font-size:11px;color:#6B7280;font-weight:600;padding:4px 0;cursor:pointer;border-radius:4px;transition:background .12s;user-select:none;}
.pg-col-header:hover{background:#DBEAFE;color:#1D4ED8;}
.pg-col-header.hl{background:#BFDBFE;color:#1D4ED8;}
.pg-row-header{display:flex;align-items:center;justify-content:center;font-size:11px;color:#6B7280;font-weight:600;padding:0 6px;cursor:pointer;border-radius:4px;transition:background .12s;user-select:none;}
.pg-row-header:hover{background:#DBEAFE;color:#1D4ED8;}
.pg-row-header.hl{background:#BFDBFE;color:#1D4ED8;}
.pg-corner{display:flex;align-items:center;justify-content:center;cursor:pointer;border-radius:4px;font-size:10px;color:#9CA3AF;transition:background .12s;user-select:none;}
.pg-corner:hover{background:#DBEAFE;color:#1D4ED8;}
/* Pool grid cells */
.pg-pool-wrap{position:relative;}
.pg-pool{display:inline-grid;gap:3px;padding:12px;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:48px;}
.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;}
......@@ -65,15 +88,38 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
.pg-actions .btn-copy:hover:not(:disabled){background:#C7D2FE;}
.pg-sel-info{font-size:13px;color:#6B7280;margin-right:auto;}
/* Shortcuts hint */
.pg-shortcuts{font-size:11px;color:#9CA3AF;margin-bottom:12px;display:flex;gap:12px;flex-wrap:wrap;}
.pg-shortcuts kbd{background:#F3F4F6;border:1px solid #D1D5DB;border-radius:3px;padding:1px 4px;font-size:10px;font-family:monospace;}
/* Legend */
.pg-legend{display:flex;gap:14px;font-size:12px;color:#374151;flex-wrap:wrap;}
.pg-legend{display:flex;gap:14px;font-size:12px;color:#374151;flex-wrap:wrap;margin-top:12px;}
.pg-legend span{display:flex;align-items:center;gap:4px;}
.pg-legend .sw{width:16px;height:16px;border-radius:4px;border:1px solid #D1D5DB;}
/* Template side panel */
.pg-tpl-btn{position:relative;}
.pg-tpl-panel{position:fixed;top:0;left:0;width:380px;max-width:90vw;height:100vh;background:#fff;box-shadow:4px 0 20px rgba(0,0,0,.15);z-index:900;transform:translateX(-100%);transition:transform .25s ease;overflow-y:auto;display:flex;flex-direction:column;}
.pg-tpl-panel.open{transform:translateX(0);}
.pg-tpl-overlay{position:fixed;inset:0;background:rgba(0,0,0,.3);z-index:899;opacity:0;pointer-events:none;transition:opacity .2s;}
.pg-tpl-overlay.show{opacity:1;pointer-events:all;}
.pg-tpl-header{padding:16px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;background:#fff;z-index:1;}
.pg-tpl-header h3{margin:0;font-size:16px;font-weight:700;}
.pg-tpl-body{padding:16px 20px;flex:1;overflow-y:auto;}
.pg-tpl-item{border:1px solid #E5E7EB;border-radius:8px;padding:12px;margin-bottom:10px;position:relative;}
.pg-tpl-item .tpl-name{font-size:14px;font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:6px;}
.pg-tpl-item .tpl-name .dot{width:10px;height:10px;border-radius:50%;}
.pg-tpl-item .tpl-meta{font-size:11px;color:#6B7280;}
.pg-tpl-item .tpl-actions{display:flex;gap:6px;margin-top:8px;}
.pg-tpl-item .tpl-actions button{font-size:11px;padding:4px 8px;border-radius:4px;border:1px solid #D1D5DB;background:#fff;cursor:pointer;}
.pg-tpl-item .tpl-actions button:hover{background:#F3F4F6;}
.pg-tpl-item .tpl-actions .btn-expand{border-color:#10B981;color:#065F46;background:#D1FAE5;}
.pg-tpl-item .tpl-actions .btn-expand:hover{background:#A7F3D0;}
/* 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{background:#fff;border-radius:12px;padding:24px;width:500px;max-width:90vw;max-height:85vh;overflow-y:auto;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 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;}
......@@ -83,18 +129,23 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
.pg-modal .m-confirm:hover{background:#1D4ED8;}
.pg-modal .m-cancel{background:#fff;color:#374151;border:1px solid #D1D5DB !important;}
/* Template wizard steps */
.pg-wizard-steps{display:flex;gap:8px;margin-bottom:16px;}
.pg-wizard-steps .step{flex:1;height:4px;border-radius:2px;background:#E5E7EB;transition:background .2s;}
.pg-wizard-steps .step.done{background:#2563EB;}
.pg-wizard-steps .step.current{background:#93C5FD;}
.pg-wiz-days{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px;}
.pg-wiz-days button{width:40px;height:36px;border-radius:6px;border:1px solid #D1D5DB;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}
.pg-wiz-days button.active{background:#2563EB;color:#fff;border-color:#2563EB;}
/* 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: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>
<!-- Date Navigation -->
<!-- Date Navigation + Actions -->
<div class="pg-top">
<div class="date-nav">
<button id="pgPrevDay" title="اليوم السابق"><i data-lucide="chevron-right" style="width:14px;height:14px;"></i></button>
......@@ -102,28 +153,33 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
<button id="pgNextDay" title="اليوم التالي"><i data-lucide="chevron-left" style="width:14px;height:14px;"></i></button>
<button id="pgToday" style="font-size:12px;padding:6px 12px;">اليوم</button>
</div>
<div class="pg-top-actions">
<button id="pgSettingsToggle" title="إعدادات الشبكة (<?= $gridRows ?>×<?= $gridCols ?>)"><i data-lucide="settings" style="width:14px;height:14px;"></i> <?= $gridRows ?>×<?= $gridCols ?></button>
<button id="pgTplToggle" class="pg-tpl-btn" title="القوالب الأسبوعية"><i data-lucide="calendar-range" style="width:14px;height:14px;"></i> القوالب</button>
</div>
</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; ?>
<!-- Grid Setup Panel (always in DOM, toggled) -->
<div class="pg-setup <?= ($gridRows > 0 && $gridCols > 0) ? 'hidden neutral' : '' ?>" id="pgSetup">
<i data-lucide="grid-3x3" style="width:18px;height:18px;"></i>
<span class="pg-size-current"><?= ($gridRows > 0) ? "الحالي: {$gridRows}×{$gridCols}" : 'لم يتم التهيئة' ?></span>
<label>صفوف <input type="number" id="pgSetupRows" min="1" max="20" value="<?= $gridRows ?: 4 ?>"></label>
<label>أعمدة <input type="number" id="pgSetupCols" min="1" max="20" value="<?= $gridCols ?: 6 ?>"></label>
<button id="pgSetupSave">حفظ</button>
</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>
<!-- Timeline Strip (multi-select) -->
<div class="pg-timeline-wrap">
<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>
<div class="pg-timeline-info" id="pgTimelineInfo">فترة واحدة محددة</div>
</div>
<?php endif; ?>
<!-- Action Bar -->
<div class="pg-actions">
......@@ -132,18 +188,26 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
<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-copy" id="pgBtnCopy" 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>
<!-- Pool Grid -->
<!-- Keyboard Shortcuts -->
<div class="pg-shortcuts">
<span><kbd>Ctrl+A</kbd> تحديد الكل</span>
<span><kbd>Esc</kbd> إلغاء التحديد</span>
<span><kbd>Shift+Click</kbd> تحديد مستطيل</span>
<span><kbd>Ctrl+Click</kbd> إضافة/إزالة</span>
</div>
<!-- Pool Grid with Headers -->
<div class="pg-pool-wrap" style="position:relative;">
<div class="pg-loading hidden" id="pgLoading"><div class="pg-spinner"></div></div>
<div id="pgPool" class="pg-pool" style="grid-template-columns:repeat(<?= $gridCols ?: 6 ?>, 1fr);"></div>
<div id="pgGridOuter" class="pg-grid-outer"></div>
</div>
<!-- Legend -->
<div class="pg-legend" style="margin-top:12px;">
<div class="pg-legend">
<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>
......@@ -151,7 +215,20 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
<span><span class="sw" style="background:#FEF3C7;"></span> صيانة</span>
</div>
<!-- Modal: Training -->
<!-- Template Side Panel -->
<div class="pg-tpl-overlay" id="pgTplOverlay"></div>
<div class="pg-tpl-panel" id="pgTplPanel">
<div class="pg-tpl-header">
<h3>القوالب الأسبوعية</h3>
<button id="pgTplClose" style="background:none;border:none;cursor:pointer;font-size:18px;color:#6B7280;">&times;</button>
</div>
<div class="pg-tpl-body" id="pgTplBody">
<button id="pgTplCreate" style="width:100%;padding:10px;border:2px dashed #D1D5DB;border-radius:8px;background:none;cursor:pointer;font-size:13px;font-weight:600;color:#6B7280;margin-bottom:16px;">+ قالب جديد</button>
<div id="pgTplList"></div>
</div>
</div>
<!-- Modal -->
<div class="pg-modal-bg" id="pgModal">
<div class="pg-modal">
<h3 id="pgModalTitle">تعيين</h3>
......@@ -185,32 +262,72 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
</div>
</div>
<!-- Template Wizard Modal -->
<div class="pg-modal-bg" id="pgTplWizModal">
<div class="pg-modal" style="width:550px;">
<h3 id="pgTplWizTitle">قالب جديد</h3>
<div class="pg-wizard-steps" id="pgWizSteps">
<div class="step current"></div>
<div class="step"></div>
<div class="step"></div>
<div class="step"></div>
</div>
<div id="pgWizContent"></div>
<div class="m-actions">
<button class="m-cancel" id="pgWizBack">السابق</button>
<button class="m-confirm" id="pgWizNext">التالي</button>
</div>
</div>
</div>
<!-- Expand Modal -->
<div class="pg-modal-bg" id="pgExpandModal">
<div class="pg-modal" style="width:380px;">
<h3>توسيع القالب</h3>
<label>من تاريخ</label>
<input type="date" id="pgExpandFrom" value="<?= e($date) ?>">
<label>إلى تاريخ</label>
<input type="date" id="pgExpandTo" value="">
<div class="m-actions">
<button class="m-cancel" id="pgExpandCancel">إلغاء</button>
<button class="m-confirm" id="pgExpandConfirm">توسيع</button>
</div>
</div>
</div>
<script>
(function(){
const FACILITY_ID = <?= (int) $facility['id'] ?>;
const CSRF = '<?= e($_SESSION['_csrf_token'] ?? '') ?>';
const SLOTS = <?= json_encode($slots, JSON_UNESCAPED_UNICODE) ?>;
const GROUPS = <?= json_encode($groups, JSON_UNESCAPED_UNICODE) ?>;
let GRID_ROWS = <?= $gridRows ?>;
let GRID_COLS = <?= $gridCols ?>;
let activeSlotIdx = 0;
let activeSlotIndices = new Set([0]);
let lastClickedSlot = 0;
let gridData = {};
let selectedCells = new Set();
let isDragging = false;
let isDraggingTimeline = false;
let dragMode = null;
let pendingAction = null;
let templates = [];
let expandingTemplateId = null;
const pool = document.getElementById('pgPool');
const gridOuter = document.getElementById('pgGridOuter');
const dateInput = document.getElementById('pgDate');
const loading = document.getElementById('pgLoading');
const selInfo = document.getElementById('pgSelInfo');
const timeline = document.getElementById('pgTimeline');
const timelineInfo = document.getElementById('pgTimelineInfo');
const modal = document.getElementById('pgModal');
const modalTitle = document.getElementById('pgModalTitle');
// --- Date Navigation ---
function currentDate(){ return dateInput.value; }
function activeSlot(){ return SLOTS[activeSlotIdx] || SLOTS[0]; }
function activeSlots(){ return Array.from(activeSlotIndices).sort((a,b)=>a-b).map(i => SLOTS[i]).filter(Boolean); }
function firstActiveSlot(){ let sorted = Array.from(activeSlotIndices).sort((a,b)=>a-b); return SLOTS[sorted[0]] || SLOTS[0]; }
document.getElementById('pgPrevDay').onclick = function(){ shiftDate(-1); };
document.getElementById('pgNextDay').onclick = function(){ shiftDate(1); };
......@@ -224,60 +341,185 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
loadState();
}
// --- Timeline ---
timeline.addEventListener('click', function(e){
// --- Timeline (Multi-select with drag + shift) ---
timeline.addEventListener('mousedown', 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();
loadState();
e.preventDefault();
isDraggingTimeline = true;
let idx = parseInt(slot.dataset.idx);
if(e.shiftKey && activeSlotIndices.size > 0){
let min = Math.min(lastClickedSlot, idx);
let max = Math.max(lastClickedSlot, idx);
activeSlotIndices.clear();
for(let i = min; i <= max; i++) activeSlotIndices.add(i);
} else if(e.ctrlKey || e.metaKey){
if(activeSlotIndices.has(idx)) activeSlotIndices.delete(idx);
else activeSlotIndices.add(idx);
} else {
activeSlotIndices.clear();
activeSlotIndices.add(idx);
}
lastClickedSlot = idx;
updateTimelineUI();
});
// --- 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();
});
};
timeline.addEventListener('mousemove', function(e){
if(!isDraggingTimeline) return;
let slot = e.target.closest('.pg-slot');
if(!slot) return;
let idx = parseInt(slot.dataset.idx);
let min = Math.min(lastClickedSlot, idx);
let max = Math.max(lastClickedSlot, idx);
activeSlotIndices.clear();
for(let i = min; i <= max; i++) activeSlotIndices.add(i);
updateTimelineUI();
});
document.addEventListener('mouseup', function(){
if(isDraggingTimeline){
isDraggingTimeline = false;
clearSelection();
loadState();
}
isDragging = false;
dragMode = null;
});
function updateTimelineUI(){
document.querySelectorAll('.pg-slot').forEach(function(s){
s.classList.toggle('active', activeSlotIndices.has(parseInt(s.dataset.idx)));
});
let n = activeSlotIndices.size;
timelineInfo.textContent = n === 1 ? 'فترة واحدة محددة' : n + ' فترات محددة';
}
// --- Render Grid ---
// --- Grid Setup Toggle ---
document.getElementById('pgSettingsToggle').onclick = function(){
let setup = document.getElementById('pgSetup');
setup.classList.toggle('hidden');
this.classList.toggle('active', !setup.classList.contains('hidden'));
};
document.getElementById('pgSetupSave').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;
document.getElementById('pgSetup').classList.add('hidden', 'neutral');
document.getElementById('pgSettingsToggle').innerHTML = '<i data-lucide="settings" style="width:14px;height:14px;"></i> ' + r + '×' + c;
if(typeof lucide !== 'undefined') lucide.createIcons();
toast('تم حفظ أبعاد الشبكة: ' + r + '×' + c, 'success');
loadState();
});
};
// --- Render Grid with Row/Col Headers ---
function renderGrid(){
if(GRID_ROWS < 1 || GRID_COLS < 1){ gridOuter.innerHTML = '<div style="padding:20px;color:#92400E;font-size:14px;">يرجى تهيئة أبعاد الشبكة أولاً</div>'; return; }
let cols = GRID_COLS + 1; // extra for row headers
gridOuter.style.gridTemplateColumns = 'repeat(' + GRID_COLS + ', 1fr) 32px';
gridOuter.style.gridTemplateRows = '28px repeat(' + GRID_ROWS + ', 1fr)';
gridOuter.style.gap = '3px';
let html = '';
// Column headers row
for(let c = 0; c < GRID_COLS; c++){
html += '<div class="pg-col-header" data-col="' + c + '">' + (c+1) + '</div>';
}
// Corner (select all)
html += '<div class="pg-corner" id="pgSelectAll" title="تحديد الكل">⊞</div>';
// Data rows
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');
if(selectedCells.has(key)) cls += ' selected';
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>';
}
// Row header
html += '<div class="pg-row-header" data-row="' + r + '">' + (r+1) + '</div>';
}
pool.innerHTML = html;
pool.style.gridTemplateColumns = 'repeat(' + GRID_COLS + ', 1fr)';
gridOuter.innerHTML = html;
bindCells();
bindHeaders();
updateSelectionUI();
}
// --- Header Click Handlers ---
function bindHeaders(){
gridOuter.querySelectorAll('.pg-col-header').forEach(function(el){
el.addEventListener('click', function(e){
let c = parseInt(el.dataset.col);
if(e.shiftKey){
for(let r = 0; r < GRID_ROWS; r++) selectedCells.add(r + ':' + c);
} else {
let allSelected = true;
for(let r = 0; r < GRID_ROWS; r++){
if(!selectedCells.has(r + ':' + c)){ allSelected = false; break; }
}
if(allSelected){
for(let r = 0; r < GRID_ROWS; r++) selectedCells.delete(r + ':' + c);
} else {
if(!e.ctrlKey && !e.metaKey) selectedCells.clear();
for(let r = 0; r < GRID_ROWS; r++) selectedCells.add(r + ':' + c);
}
}
updateSelectionUI();
});
});
gridOuter.querySelectorAll('.pg-row-header').forEach(function(el){
el.addEventListener('click', function(e){
let r = parseInt(el.dataset.row);
if(e.shiftKey){
for(let c = 0; c < GRID_COLS; c++) selectedCells.add(r + ':' + c);
} else {
let allSelected = true;
for(let c = 0; c < GRID_COLS; c++){
if(!selectedCells.has(r + ':' + c)){ allSelected = false; break; }
}
if(allSelected){
for(let c = 0; c < GRID_COLS; c++) selectedCells.delete(r + ':' + c);
} else {
if(!e.ctrlKey && !e.metaKey) selectedCells.clear();
for(let c = 0; c < GRID_COLS; c++) selectedCells.add(r + ':' + c);
}
}
updateSelectionUI();
});
});
let corner = document.getElementById('pgSelectAll');
if(corner){
corner.onclick = function(){
let total = GRID_ROWS * GRID_COLS;
if(selectedCells.size === total){
selectedCells.clear();
} else {
for(let r=0;r<GRID_ROWS;r++) for(let c=0;c<GRID_COLS;c++) selectedCells.add(r+':'+c);
}
updateSelectionUI();
};
}
}
// --- Cell Interactions ---
function bindCells(){
pool.querySelectorAll('.pg-cell').forEach(function(el){
gridOuter.querySelectorAll('.pg-cell').forEach(function(el){
el.addEventListener('mousedown', function(e){
if(e.button !== 0) return;
e.preventDefault();
......@@ -320,8 +562,6 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
}
}
document.addEventListener('mouseup', function(){ isDragging = false; dragMode = null; });
document.addEventListener('keydown', function(e){
if(e.key === 'Escape') clearSelection();
if((e.ctrlKey || e.metaKey) && e.key === 'a' && !isInput(e.target)){
......@@ -340,21 +580,41 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
function updateSelectionUI(){
let n = selectedCells.size;
selInfo.textContent = n > 0 ? n + ' مربع محدد' : 'لا يوجد تحديد';
let slotsN = activeSlotIndices.size;
if(n === 0){
selInfo.textContent = 'لا يوجد تحديد';
} else if(slotsN > 1){
selInfo.textContent = n + ' مربع × ' + slotsN + ' فترات';
} else {
selInfo.textContent = 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){
gridOuter.querySelectorAll('.pg-cell').forEach(function(el){
el.classList.toggle('selected', selectedCells.has(el.dataset.key));
});
// Highlight row/col headers
gridOuter.querySelectorAll('.pg-col-header').forEach(function(el){
let c = parseInt(el.dataset.col);
let any = false;
for(let r = 0; r < GRID_ROWS; r++){ if(selectedCells.has(r+':'+c)){ any = true; break; } }
el.classList.toggle('hl', any);
});
gridOuter.querySelectorAll('.pg-row-header').forEach(function(el){
let r = parseInt(el.dataset.row);
let any = false;
for(let c = 0; c < GRID_COLS; c++){ if(selectedCells.has(r+':'+c)){ any = true; break; } }
el.classList.toggle('hl', any);
});
}
// --- Actions ---
// --- Actions (multi-slot aware) ---
document.getElementById('pgBtnTraining').onclick = function(){ showModal('training'); };
document.getElementById('pgBtnHourly').onclick = function(){ showModal('hourly'); };
document.getElementById('pgBtnBlocked').onclick = function(){ doAssign('blocked'); };
......@@ -403,14 +663,17 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
return cells;
}
function buildSlots(){
return activeSlots().map(function(s){ return {start_time: s.start, end_time: s.end}; });
}
function doAssign(action, groupId){
let slot = activeSlot();
let slots = buildSlots();
let notes = document.getElementById('pgNotes').value.trim();
showLoading();
apiPost('/api/sa/pool-grid/' + FACILITY_ID + '/assign', {
date: currentDate(),
start_time: slot.start,
end_time: slot.end,
slots: slots,
action: action,
cells: buildCells(),
group_id: groupId || null,
......@@ -426,11 +689,11 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
}
function doClear(){
let slot = activeSlot();
let slots = buildSlots();
showLoading();
apiPost('/api/sa/pool-grid/' + FACILITY_ID + '/clear', {
date: currentDate(),
start_time: slot.start,
slots: slots,
cells: buildCells()
}).then(function(res){
if(res.error){ alert(res.error); hideLoading(); return; }
......@@ -441,15 +704,14 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
}
function doCopy(){
let slot = activeSlot();
let slot = firstActiveSlot();
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
to_slots: buildSlots().map(function(s){ return {start: s.start_time, end: s.end_time}; })
}).then(function(res){
if(res.error){ alert(res.error); hideLoading(); return; }
toast('تم النسخ: ' + (res.copied || 0) + ' مربع', 'success');
......@@ -457,11 +719,294 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
});
}
// --- Template Panel ---
document.getElementById('pgTplToggle').onclick = function(){
document.getElementById('pgTplPanel').classList.add('open');
document.getElementById('pgTplOverlay').classList.add('show');
loadTemplates();
};
document.getElementById('pgTplClose').onclick = closeTplPanel;
document.getElementById('pgTplOverlay').onclick = closeTplPanel;
function closeTplPanel(){
document.getElementById('pgTplPanel').classList.remove('open');
document.getElementById('pgTplOverlay').classList.remove('show');
}
function loadTemplates(){
fetch('/api/sa/pool-grid/' + FACILITY_ID + '/templates', {
headers: {'X-Requested-With': 'XMLHttpRequest'}
})
.then(function(r){ return r.json(); })
.then(function(data){
templates = data.templates || [];
renderTemplateList();
})
.catch(function(){ document.getElementById('pgTplList').innerHTML = '<p style="color:#991B1B;font-size:13px;">خطأ في التحميل</p>'; });
}
function renderTemplateList(){
let html = '';
if(templates.length === 0){
html = '<p style="color:#6B7280;font-size:13px;text-align:center;">لا توجد قوالب بعد</p>';
}
templates.forEach(function(t){
html += '<div class="pg-tpl-item">';
html += '<div class="tpl-name"><span class="dot" style="background:' + esc(t.color || '#3B82F6') + ';"></span>' + esc(t.name) + '</div>';
html += '<div class="tpl-meta">' + (t.entries_count || 0) + ' إدخال' + (t.is_active ? '' : ' — <b style="color:#DC2626;">معطّل</b>') + '</div>';
html += '<div class="tpl-actions">';
html += '<button class="btn-expand" onclick="pgExpandTemplate(' + t.id + ')">توسيع</button>';
html += '<button onclick="pgEditTemplate(' + t.id + ')">تعديل</button>';
html += '<button onclick="pgDeleteTemplate(' + t.id + ')">حذف</button>';
html += '</div></div>';
});
document.getElementById('pgTplList').innerHTML = html;
}
// Template wizard state
let wizStep = 0;
let wizData = {name:'', color:'#3B82F6', days:[], slots:[], zoneType:'all', zones:{}, action:'training', group_id:null, label:'', notes:''};
document.getElementById('pgTplCreate').onclick = function(){ openWizard(); };
function openWizard(editData){
wizStep = 0;
wizData = editData || {name:'', color:'#3B82F6', days:[], slots:[], zoneType:'all', zones:{}, action:'training', group_id:null, label:'', notes:''};
renderWizStep();
document.getElementById('pgTplWizModal').classList.add('show');
}
function closeWizard(){
document.getElementById('pgTplWizModal').classList.remove('show');
}
document.getElementById('pgWizBack').onclick = function(){
if(wizStep === 0) closeWizard();
else { wizStep--; renderWizStep(); }
};
document.getElementById('pgWizNext').onclick = function(){
if(!validateWizStep()) return;
if(wizStep === 3){
saveTemplate();
return;
}
wizStep++;
renderWizStep();
};
function validateWizStep(){
if(wizStep === 0 && !wizData.name.trim()){ alert('ادخل اسم القالب'); return false; }
if(wizStep === 1 && wizData.days.length === 0){ alert('اختر يوم واحد على الأقل'); return false; }
if(wizStep === 2 && wizData.slots.length === 0){ alert('اختر فترة واحدة على الأقل'); return false; }
return true;
}
const DAY_NAMES = ['أحد','إثنين','ثلاثاء','أربعاء','خميس','جمعة','سبت'];
function renderWizStep(){
let steps = document.querySelectorAll('#pgWizSteps .step');
steps.forEach(function(s, i){
s.className = 'step' + (i < wizStep ? ' done' : '') + (i === wizStep ? ' current' : '');
});
let nextBtn = document.getElementById('pgWizNext');
nextBtn.textContent = wizStep === 3 ? 'حفظ' : 'التالي';
document.getElementById('pgWizBack').textContent = wizStep === 0 ? 'إلغاء' : 'السابق';
let content = document.getElementById('pgWizContent');
let html = '';
if(wizStep === 0){
html += '<label>اسم القالب</label>';
html += '<input type="text" id="pgWizName" value="' + esc(wizData.name) + '" placeholder="مثال: تدريب صباحي أسبوعي">';
html += '<label>اللون</label>';
html += '<input type="color" id="pgWizColor" value="' + (wizData.color || '#3B82F6') + '" style="width:60px;height:36px;padding:2px;cursor:pointer;">';
} else if(wizStep === 1){
html += '<label>أيام الأسبوع</label>';
html += '<div class="pg-wiz-days">';
for(let d = 0; d < 7; d++){
let active = wizData.days.includes(d) ? ' active' : '';
html += '<button type="button" class="wiz-day' + active + '" data-d="' + d + '">' + DAY_NAMES[d] + '</button>';
}
html += '</div>';
} else if(wizStep === 2){
html += '<label>الفترات الزمنية (اختر واحدة أو أكثر)</label>';
html += '<div style="display:flex;flex-wrap:wrap;gap:4px;max-height:200px;overflow-y:auto;">';
SLOTS.forEach(function(s, i){
let active = wizData.slots.some(function(ws){ return ws.start === s.start; }) ? ' active' : '';
html += '<button type="button" class="wiz-slot' + active + '" data-idx="' + i + '" style="padding:6px 10px;border-radius:4px;border:1px solid #D1D5DB;background:' + (active ? '#2563EB;color:#fff' : '#fff') + ';font-size:12px;cursor:pointer;">' + s.start + '-' + s.end + '</button>';
});
html += '</div>';
} else if(wizStep === 3){
html += '<label>نوع التعيين</label>';
html += '<select id="pgWizAction"><option value="training"' + (wizData.action==='training'?' selected':'') + '>تدريب</option><option value="hourly"' + (wizData.action==='hourly'?' selected':'') + '>حجز ساعة</option><option value="blocked"' + (wizData.action==='blocked'?' selected':'') + '>حجب</option><option value="maintenance"' + (wizData.action==='maintenance'?' selected':'') + '>صيانة</option></select>';
html += '<div id="pgWizGroupWrap"><label>المجموعة</label><select id="pgWizGroup"><option value="">اختر...</option>';
GROUPS.forEach(function(g){ html += '<option value="' + g.id + '"' + (wizData.group_id==g.id?' selected':'') + '>' + esc(g.name_ar) + '</option>'; });
html += '</select></div>';
html += '<label>نطاق التحديد</label>';
html += '<select id="pgWizZoneType"><option value="all"' + (wizData.zoneType==='all'?' selected':'') + '>كامل الشبكة</option><option value="row"' + (wizData.zoneType==='row'?' selected':'') + '>صفوف محددة</option><option value="column"' + (wizData.zoneType==='column'?' selected':'') + '>أعمدة محددة</option><option value="cells"' + (wizData.zoneType==='cells'?' selected':'') + '>مربعات محددة (التحديد الحالي)</option></select>';
html += '<p style="font-size:11px;color:#6B7280;margin-top:4px;">لـ "مربعات محددة": سيستخدم التحديد الحالي على الشبكة</p>';
if(wizData.zoneType === 'row'){
html += '<label>أرقام الصفوف (مفصولة بفاصلة)</label>';
html += '<input type="text" id="pgWizRows" value="' + (wizData.zones.rows||[]).join(',') + '" placeholder="1,2,3">';
} else if(wizData.zoneType === 'column'){
html += '<label>أرقام الأعمدة (مفصولة بفاصلة)</label>';
html += '<input type="text" id="pgWizCols" value="' + (wizData.zones.cols||[]).join(',') + '" placeholder="1,2,3">';
}
}
content.innerHTML = html;
// Bind step-specific events
if(wizStep === 0){
document.getElementById('pgWizName').oninput = function(){ wizData.name = this.value; };
document.getElementById('pgWizColor').oninput = function(){ wizData.color = this.value; };
} else if(wizStep === 1){
content.querySelectorAll('.wiz-day').forEach(function(btn){
btn.onclick = function(){
let d = parseInt(btn.dataset.d);
let idx = wizData.days.indexOf(d);
if(idx >= 0) wizData.days.splice(idx, 1);
else wizData.days.push(d);
btn.classList.toggle('active');
};
});
} else if(wizStep === 2){
content.querySelectorAll('.wiz-slot').forEach(function(btn){
btn.onclick = function(){
let i = parseInt(btn.dataset.idx);
let s = SLOTS[i];
let existing = wizData.slots.findIndex(function(ws){ return ws.start === s.start; });
if(existing >= 0) wizData.slots.splice(existing, 1);
else wizData.slots.push(s);
btn.classList.toggle('active');
btn.style.background = btn.classList.contains('active') ? '#2563EB' : '#fff';
btn.style.color = btn.classList.contains('active') ? '#fff' : '';
};
});
} else if(wizStep === 3){
let actionSel = document.getElementById('pgWizAction');
let groupWrap = document.getElementById('pgWizGroupWrap');
actionSel.onchange = function(){
wizData.action = this.value;
groupWrap.style.display = this.value === 'training' ? '' : 'none';
};
groupWrap.style.display = wizData.action === 'training' ? '' : 'none';
let zoneSel = document.getElementById('pgWizZoneType');
zoneSel.onchange = function(){
wizData.zoneType = this.value;
renderWizStep();
};
}
}
function saveTemplate(){
// Collect final step data
if(wizStep === 3){
wizData.action = document.getElementById('pgWizAction').value;
if(wizData.action === 'training'){
wizData.group_id = parseInt(document.getElementById('pgWizGroup').value) || null;
if(!wizData.group_id){ alert('اختر مجموعة'); return; }
}
wizData.zoneType = document.getElementById('pgWizZoneType').value;
if(wizData.zoneType === 'row'){
let val = (document.getElementById('pgWizRows')?.value || '').split(',').map(function(x){ return parseInt(x.trim()) - 1; }).filter(function(x){ return !isNaN(x) && x >= 0; });
wizData.zones = {rows: val};
} else if(wizData.zoneType === 'column'){
let val = (document.getElementById('pgWizCols')?.value || '').split(',').map(function(x){ return parseInt(x.trim()) - 1; }).filter(function(x){ return !isNaN(x) && x >= 0; });
wizData.zones = {cols: val};
} else if(wizData.zoneType === 'cells'){
wizData.zones = buildCells();
} else {
wizData.zones = {};
}
}
// Build entries array (one entry per day × slot combo)
let entries = [];
wizData.days.forEach(function(day){
wizData.slots.forEach(function(slot){
entries.push({
day_of_week: day,
start_time: slot.start + ':00',
end_time: slot.end + ':00',
zone_selection_type: wizData.zoneType,
zone_selection_json: wizData.zoneType === 'cells' ? wizData.zones : JSON.stringify(wizData.zones),
assignment_type: wizData.action,
group_id: wizData.group_id,
label: wizData.name,
notes: ''
});
});
});
apiPost('/api/sa/pool-grid/' + FACILITY_ID + '/templates', {
name: wizData.name,
description: '',
color: wizData.color,
entries: entries
}).then(function(res){
if(res.error){ alert(res.error); return; }
toast('تم إنشاء القالب: ' + wizData.name, 'success');
closeWizard();
loadTemplates();
});
}
// Global functions for template actions
window.pgExpandTemplate = function(id){
expandingTemplateId = id;
let today = new Date().toISOString().slice(0,10);
document.getElementById('pgExpandFrom').value = today;
let twoWeeks = new Date(); twoWeeks.setDate(twoWeeks.getDate() + 14);
document.getElementById('pgExpandTo').value = twoWeeks.toISOString().slice(0,10);
document.getElementById('pgExpandModal').classList.add('show');
};
document.getElementById('pgExpandCancel').onclick = function(){
document.getElementById('pgExpandModal').classList.remove('show');
};
document.getElementById('pgExpandConfirm').onclick = function(){
let from = document.getElementById('pgExpandFrom').value;
let to = document.getElementById('pgExpandTo').value;
if(!from || !to){ alert('اختر التواريخ'); return; }
if(from > to){ alert('تاريخ البداية يجب أن يكون قبل النهاية'); return; }
document.getElementById('pgExpandModal').classList.remove('show');
showLoading();
apiPost('/api/sa/pool-grid/' + FACILITY_ID + '/templates/' + expandingTemplateId + '/expand', {
from_date: from,
to_date: to
}).then(function(res){
hideLoading();
if(res.error){ alert(res.error); return; }
toast('تم التوسيع: ' + (res.generated || 0) + ' مربع | تخطي: ' + (res.skipped || 0), 'success');
loadState();
loadTemplates();
});
};
window.pgEditTemplate = function(id){
toast('التعديل — قريباً', 'success');
};
window.pgDeleteTemplate = function(id){
if(!confirm('هل تريد حذف هذا القالب؟')) return;
apiPost('/api/sa/pool-grid/' + FACILITY_ID + '/templates/' + id + '/delete', {})
.then(function(res){
if(res.error){ alert(res.error); return; }
toast('تم الحذف', 'success');
loadTemplates();
});
};
// --- API ---
function loadState(){
if(GRID_ROWS < 1 || GRID_COLS < 1){ renderGrid(); hideLoading(); return; }
showLoading();
let slot = activeSlot();
let slot = firstActiveSlot();
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'}
})
......@@ -502,7 +1047,7 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
setTimeout(function(){ el.style.opacity = '0'; setTimeout(function(){ el.remove(); }, 300); }, 3000);
}
function esc(s){ return s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') : ''; }
function esc(s){ return s ? String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : ''; }
// --- Init ---
if(GRID_ROWS > 0 && GRID_COLS > 0) loadState();
......
<?php
declare(strict_types=1);
return [
'up' => "CREATE TABLE `sa_pool_zone_templates` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`facility_id` BIGINT UNSIGNED NOT NULL,
`name` VARCHAR(200) NOT NULL,
`description` TEXT NULL,
`color` VARCHAR(20) NULL DEFAULT '#3B82F6',
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_by` BIGINT UNSIGNED NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_sa_pzt_facility` (`facility_id`, `is_active`),
CONSTRAINT `fk_sa_pzt_facility` FOREIGN KEY (`facility_id`) REFERENCES `sa_facilities`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;",
'down' => "DROP TABLE IF EXISTS `sa_pool_zone_templates`;",
];
<?php
declare(strict_types=1);
return [
'up' => "CREATE TABLE `sa_pool_zone_template_entries` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`template_id` BIGINT UNSIGNED NOT NULL,
`day_of_week` TINYINT UNSIGNED NOT NULL COMMENT '0=Sunday...6=Saturday',
`start_time` TIME NOT NULL,
`end_time` TIME NOT NULL,
`zone_selection_type` VARCHAR(20) NOT NULL COMMENT 'cells, row, column, all',
`zone_selection_json` JSON NOT NULL,
`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,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_sa_pzte_template` (`template_id`),
INDEX `idx_sa_pzte_day` (`day_of_week`, `start_time`),
CONSTRAINT `fk_sa_pzte_template` FOREIGN KEY (`template_id`) REFERENCES `sa_pool_zone_templates`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_sa_pzte_group` FOREIGN KEY (`group_id`) REFERENCES `sa_groups`(`id`) ON DELETE SET NULL,
CONSTRAINT `fk_sa_pzte_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_template_entries`;",
];
<?php
declare(strict_types=1);
return [
'up' => "CREATE TABLE `sa_pool_zone_template_runs` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`template_id` BIGINT UNSIGNED NOT NULL,
`date_from` DATE NOT NULL,
`date_to` DATE NOT NULL,
`generated_count` INT UNSIGNED NOT NULL DEFAULT 0,
`skipped_count` INT UNSIGNED NOT NULL DEFAULT 0,
`status` VARCHAR(20) NOT NULL DEFAULT 'completed' COMMENT 'completed, rolled_back',
`rolled_back_at` TIMESTAMP NULL,
`rolled_back_by` BIGINT UNSIGNED NULL,
`created_by` BIGINT UNSIGNED NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_sa_pztr_template` (`template_id`),
CONSTRAINT `fk_sa_pztr_template` FOREIGN KEY (`template_id`) REFERENCES `sa_pool_zone_templates`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;",
'down' => "DROP TABLE IF EXISTS `sa_pool_zone_template_runs`;",
];
<?php
declare(strict_types=1);
return [
'up' => "ALTER TABLE `sa_pool_zone_bookings`
ADD COLUMN `template_run_id` BIGINT UNSIGNED NULL AFTER `notes`,
ADD INDEX `idx_sa_pzb_run` (`template_run_id`);",
'down' => "ALTER TABLE `sa_pool_zone_bookings`
DROP INDEX `idx_sa_pzb_run`,
DROP COLUMN `template_run_id`;",
];
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