Commit 2e5488e5 authored by Fares's avatar Fares

feat(sa): implement medical level, grace period, booking postponement, and mirror recurrence

- Medical Level (1-3): added to approval flow, saved to players/sa_players/medical_records,
  displayed on player detail page with color-coded badge
- Medical Grace Period: configurable days (system_config), set on enrollment when
  medical cert is missing, shown in attendance view, cron auto-suspends expired grace
- Booking Postponement: new postpone method with max 3 attempts, change history table,
  availability check for new slot, UI form on booking detail page
- Mirror Recurrence: templates now support daily/weekly/biweekly/monthly/custom patterns,
  expandTemplate respects recurrence_type, wizard UI updated with pattern selector
- Attendance view: grace period deadline shown instead of generic medical warning
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 05172c6e
...@@ -46,9 +46,13 @@ class MedicalApprovalController extends Controller ...@@ -46,9 +46,13 @@ class MedicalApprovalController extends Controller
$employee = App::getInstance()->currentEmployee(); $employee = App::getInstance()->currentEmployee();
$approvedBy = $employee ? (int) $employee->id : 0; $approvedBy = $employee ? (int) $employee->id : 0;
$medicalLevel = (int) $request->post('medical_level', 1);
if ($medicalLevel < 1 || $medicalLevel > 3) {
$medicalLevel = 1;
}
try { try {
MedicalRecordService::approveRecord((int) $id, $approvedBy); MedicalRecordService::approveRecord((int) $id, $approvedBy, $medicalLevel);
} catch (\RuntimeException $e) { } catch (\RuntimeException $e) {
return $this->redirect('/medical-approvals')->withError($e->getMessage()); return $this->redirect('/medical-approvals')->withError($e->getMessage());
} }
......
...@@ -36,6 +36,7 @@ class Player extends Model ...@@ -36,6 +36,7 @@ class Player extends Model
'photo_path', 'photo_path',
'medical_status', 'medical_status',
'medical_expiry_date', 'medical_expiry_date',
'medical_level',
'school_name', 'school_name',
'school_grade', 'school_grade',
'registration_fee_paid', 'registration_fee_paid',
......
...@@ -63,7 +63,7 @@ final class MedicalRecordService ...@@ -63,7 +63,7 @@ final class MedicalRecordService
return $record; return $record;
} }
public static function approveRecord(int $recordId, int $approvedBy): void public static function approveRecord(int $recordId, int $approvedBy, int $medicalLevel = 1): void
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
...@@ -77,6 +77,7 @@ final class MedicalRecordService ...@@ -77,6 +77,7 @@ final class MedicalRecordService
'approved_by' => $approvedBy, 'approved_by' => $approvedBy,
'approved_at' => date('Y-m-d H:i:s'), 'approved_at' => date('Y-m-d H:i:s'),
'result' => 'fit', 'result' => 'fit',
'medical_level' => $medicalLevel,
], 'id = ?', [$recordId]); ], 'id = ?', [$recordId]);
$player = Player::find((int) $record['player_id']); $player = Player::find((int) $record['player_id']);
...@@ -84,12 +85,22 @@ final class MedicalRecordService ...@@ -84,12 +85,22 @@ final class MedicalRecordService
$player->update([ $player->update([
'medical_status' => 'fit', 'medical_status' => 'fit',
'medical_expiry_date' => $record['expiry_date'], 'medical_expiry_date' => $record['expiry_date'],
'medical_level' => $medicalLevel,
]); ]);
} }
// Also update sa_players if the player exists there
$saPlayer = $db->selectOne("SELECT id FROM sa_players WHERE player_id = ?", [(int) $record['player_id']]);
if ($saPlayer) {
$db->update('sa_players', [
'medical_level' => $medicalLevel,
], 'player_id = ?', [(int) $record['player_id']]);
}
EventBus::dispatch('player.medical_approved', [ EventBus::dispatch('player.medical_approved', [
'player_id' => (int) $record['player_id'], 'player_id' => (int) $record['player_id'],
'record_id' => $recordId, 'record_id' => $recordId,
'medical_level' => $medicalLevel,
]); ]);
} }
......
...@@ -94,9 +94,17 @@ $recordTypes = PlayerMedicalRecord::getRecordTypes(); ...@@ -94,9 +94,17 @@ $recordTypes = PlayerMedicalRecord::getRecordTypes();
<!-- Actions --> <!-- Actions -->
<?php if ($filter === 'pending' && can('sa.medical.approve')): ?> <?php if ($filter === 'pending' && can('sa.medical.approve')): ?>
<div style="display:flex;flex-direction:column;gap:8px;flex-shrink:0;"> <div style="display:flex;flex-direction:column;gap:8px;flex-shrink:0;min-width:160px;">
<form method="POST" action="/medical-approvals/<?= (int) $rec['id'] ?>/approve" style="margin:0;"> <form method="POST" action="/medical-approvals/<?= (int) $rec['id'] ?>/approve" style="margin:0;">
<?= csrf_field() ?> <?= csrf_field() ?>
<div style="margin-bottom:6px;">
<label style="font-size:11px;color:#6B7280;display:block;margin-bottom:3px;">المستوى الطبي</label>
<select name="medical_level" class="form-input" style="font-size:12px;padding:4px 8px;">
<option value="1">1 - لائق بالكامل</option>
<option value="2">2 - لائق بشروط</option>
<option value="3">3 - لائق جزئياً</option>
</select>
</div>
<button type="submit" class="btn btn-primary" style="font-size:12px;padding:8px 16px;width:100%;" onclick="return confirm('تأكيد اعتماد هذا السجل الطبي؟')"> <button type="submit" class="btn btn-primary" style="font-size:12px;padding:8px 16px;width:100%;" onclick="return confirm('تأكيد اعتماد هذا السجل الطبي؟')">
<i data-lucide="check" style="width:13px;height:13px;vertical-align:middle;margin-left:4px;"></i> اعتماد <i data-lucide="check" style="width:13px;height:13px;vertical-align:middle;margin-left:4px;"></i> اعتماد
</button> </button>
......
...@@ -71,6 +71,16 @@ $enrollmentStatuses = AcademyEnrollment::getStatuses(); ...@@ -71,6 +71,16 @@ $enrollmentStatuses = AcademyEnrollment::getStatuses();
</div> </div>
<div style="text-align:left;"> <div style="text-align:left;">
<span style="display:inline-block;padding:4px 12px;border-radius:10px;font-size:12px;font-weight:600;background:<?= $medicalColor ?>15;color:<?= $medicalColor ?>;"><?= e($medicalLabel) ?></span> <span style="display:inline-block;padding:4px 12px;border-radius:10px;font-size:12px;font-weight:600;background:<?= $medicalColor ?>15;color:<?= $medicalColor ?>;"><?= e($medicalLabel) ?></span>
<?php if ($player->medical_level): ?>
<?php
$levelLabels = [1 => 'مستوى 1 - لائق بالكامل', 2 => 'مستوى 2 - لائق بشروط', 3 => 'مستوى 3 - لائق جزئياً'];
$levelColors = [1 => '#059669', 2 => '#D97706', 3 => '#DC2626'];
$lvl = (int) $player->medical_level;
$lvlLabel = $levelLabels[$lvl] ?? "مستوى {$lvl}";
$lvlColor = $levelColors[$lvl] ?? '#6B7280';
?>
<span style="display:inline-block;padding:4px 10px;border-radius:10px;font-size:11px;font-weight:600;background:<?= $lvlColor ?>15;color:<?= $lvlColor ?>;margin-right:6px;"><?= e($lvlLabel) ?></span>
<?php endif; ?>
<?php if ($player->medical_expiry_date): ?> <?php if ($player->medical_expiry_date): ?>
<div style="margin-top:6px;font-size:11px;color:#9CA3AF;">انتهاء: <?= e($player->medical_expiry_date) ?></div> <div style="margin-top:6px;font-size:11px;color:#9CA3AF;">انتهاء: <?= e($player->medical_expiry_date) ?></div>
<?php endif; ?> <?php endif; ?>
......
...@@ -23,12 +23,17 @@ class PoolGridTemplateApiController extends Controller ...@@ -23,12 +23,17 @@ class PoolGridTemplateApiController extends Controller
$description = trim((string) ($body['description'] ?? '')); $description = trim((string) ($body['description'] ?? ''));
$color = trim((string) ($body['color'] ?? '#3B82F6')); $color = trim((string) ($body['color'] ?? '#3B82F6'));
$entries = $body['entries'] ?? []; $entries = $body['entries'] ?? [];
$recurrenceType = trim((string) ($body['recurrence_type'] ?? 'weekly'));
$recurrenceConfig = $body['recurrence_config'] ?? null;
if (!$name) { if (!$name) {
return $this->json(['error' => 'اسم القالب مطلوب'], 400); return $this->json(['error' => 'اسم القالب مطلوب'], 400);
} }
$result = PoolGridTemplateService::createTemplate((int) $id, $name, $description ?: null, $color, $entries); $result = PoolGridTemplateService::createTemplate(
(int) $id, $name, $description ?: null, $color, $entries,
$recurrenceType, is_array($recurrenceConfig) ? $recurrenceConfig : null
);
return $this->json($result, $result['success'] ? 200 : 422); return $this->json($result, $result['success'] ? 200 : 422);
} }
...@@ -40,11 +45,18 @@ class PoolGridTemplateApiController extends Controller ...@@ -40,11 +45,18 @@ class PoolGridTemplateApiController extends Controller
$description = trim((string) ($body['description'] ?? '')); $description = trim((string) ($body['description'] ?? ''));
$color = trim((string) ($body['color'] ?? '#3B82F6')); $color = trim((string) ($body['color'] ?? '#3B82F6'));
$entries = $body['entries'] ?? []; $entries = $body['entries'] ?? [];
$recurrenceType = trim((string) ($body['recurrence_type'] ?? 'weekly'));
$recurrenceConfig = $body['recurrence_config'] ?? null;
if (!$name) { if (!$name) {
return $this->json(['error' => 'اسم القالب مطلوب'], 400); return $this->json(['error' => 'اسم القالب مطلوب'], 400);
} }
$validTypes = ['daily', 'weekly', 'biweekly', 'monthly', 'custom'];
if (!in_array($recurrenceType, $validTypes, true)) {
$recurrenceType = 'weekly';
}
$db = \App\Core\App::getInstance()->db(); $db = \App\Core\App::getInstance()->db();
$ts = date('Y-m-d H:i:s'); $ts = date('Y-m-d H:i:s');
...@@ -52,6 +64,8 @@ class PoolGridTemplateApiController extends Controller ...@@ -52,6 +64,8 @@ class PoolGridTemplateApiController extends Controller
'name' => $name, 'name' => $name,
'description' => $description ?: null, 'description' => $description ?: null,
'color' => $color, 'color' => $color,
'recurrence_type' => $recurrenceType,
'recurrence_config' => is_array($recurrenceConfig) ? json_encode($recurrenceConfig, JSON_UNESCAPED_UNICODE) : null,
'updated_at' => $ts, 'updated_at' => $ts,
], 'id = ?', [(int) $tid]); ], 'id = ?', [(int) $tid]);
......
...@@ -88,10 +88,19 @@ class AttendanceController extends Controller ...@@ -88,10 +88,19 @@ class AttendanceController extends Controller
$players = []; $players = [];
if ($booking['group_id']) { if ($booking['group_id']) {
$players = $db->select( $players = $db->select(
"SELECT gp.player_id, p.full_name_ar as player_name, p.registration_serial as player_code, p.medical_status "SELECT gp.player_id, gp.status as enrollment_status,
gp.medical_grace_deadline,
p.full_name_ar as player_name, p.registration_serial as player_code,
p.medical_status, p.medical_expiry_date, p.player_type,
(SELECT COUNT(*) FROM sa_subscriptions s
WHERE s.player_id = gp.player_id AND s.group_id = gp.group_id
AND s.payment_status IN ('unpaid','overdue','partial')) as unpaid_count,
(SELECT COUNT(*) FROM sa_player_documents doc
WHERE doc.player_id = gp.player_id AND doc.document_type = 'medical_cert'
AND doc.approval_status = 'approved') as approved_medical_count
FROM sa_group_players gp FROM sa_group_players gp
JOIN sa_players p ON p.id = gp.player_id JOIN sa_players p ON p.id = gp.player_id
WHERE gp.group_id = ? AND gp.status = 'active' WHERE gp.group_id = ? AND gp.status IN ('active', 'pending_payment')
ORDER BY p.full_name_ar ASC", ORDER BY p.full_name_ar ASC",
[(int) $booking['group_id']] [(int) $booking['group_id']]
); );
......
...@@ -236,6 +236,29 @@ class BookingController extends Controller ...@@ -236,6 +236,29 @@ class BookingController extends Controller
return $this->redirect('/sa/bookings/' . $id)->withError($result['error']); return $this->redirect('/sa/bookings/' . $id)->withError($result['error']);
} }
/**
* Postpone a booking to a new date/time.
*/
public function postpone(Request $request, string $id): Response
{
$newDate = trim((string) $request->post('new_date', ''));
$newStartTime = trim((string) $request->post('new_start_time', ''));
$newEndTime = trim((string) $request->post('new_end_time', ''));
$reason = trim((string) $request->post('reason', ''));
if ($newDate === '' || $newStartTime === '' || $newEndTime === '') {
return $this->redirect('/sa/bookings/' . $id)->withError('يجب تحديد التاريخ والوقت الجديد');
}
$result = BookingService::postpone((int) $id, $newDate, $newStartTime, $newEndTime, $reason);
if ($result['success']) {
return $this->redirect('/sa/bookings/' . $id)->withSuccess('تم تأجيل الحجز بنجاح');
}
return $this->redirect('/sa/bookings/' . $id)->withError($result['error']);
}
/** /**
* Check in a booking. * Check in a booking.
*/ */
......
...@@ -118,6 +118,7 @@ return [ ...@@ -118,6 +118,7 @@ return [
['POST', '/sa/bookings', 'SportsActivity\Controllers\BookingController@store', ['auth', 'csrf'], 'sa.booking.create'], ['POST', '/sa/bookings', 'SportsActivity\Controllers\BookingController@store', ['auth', 'csrf'], 'sa.booking.create'],
['GET', '/sa/bookings/{id:\d+}', 'SportsActivity\Controllers\BookingController@show', ['auth'], 'sa.booking.view'], ['GET', '/sa/bookings/{id:\d+}', 'SportsActivity\Controllers\BookingController@show', ['auth'], 'sa.booking.view'],
['POST', '/sa/bookings/{id:\d+}/cancel', 'SportsActivity\Controllers\BookingController@cancel', ['auth', 'csrf'], 'sa.booking.manage'], ['POST', '/sa/bookings/{id:\d+}/cancel', 'SportsActivity\Controllers\BookingController@cancel', ['auth', 'csrf'], 'sa.booking.manage'],
['POST', '/sa/bookings/{id:\d+}/postpone', 'SportsActivity\Controllers\BookingController@postpone', ['auth', 'csrf'], 'sa.booking.manage'],
['POST', '/sa/bookings/{id:\d+}/checkin', 'SportsActivity\Controllers\BookingController@checkin', ['auth', 'csrf'], 'sa.booking.manage'], ['POST', '/sa/bookings/{id:\d+}/checkin', 'SportsActivity\Controllers\BookingController@checkin', ['auth', 'csrf'], 'sa.booking.manage'],
['POST', '/sa/bookings/{id:\d+}/checkout', 'SportsActivity\Controllers\BookingController@checkout', ['auth', 'csrf'], 'sa.booking.manage'], ['POST', '/sa/bookings/{id:\d+}/checkout', 'SportsActivity\Controllers\BookingController@checkout', ['auth', 'csrf'], 'sa.booking.manage'],
......
...@@ -180,6 +180,75 @@ final class BookingService ...@@ -180,6 +180,75 @@ final class BookingService
]; ];
} }
public static function postpone(int $bookingId, string $newDate, string $newStartTime, string $newEndTime, string $reason = ''): array
{
$db = App::getInstance()->db();
$booking = $db->selectOne("SELECT * FROM sa_bookings WHERE id = ?", [$bookingId]);
if (!$booking) {
return ['success' => false, 'error' => 'الحجز غير موجود'];
}
if (in_array($booking['status'], [SaConstants::BOOKING_CANCELLED, SaConstants::BOOKING_NO_SHOW])) {
return ['success' => false, 'error' => 'لا يمكن تأجيل حجز ملغي'];
}
$maxPostpone = 3;
if ((int) ($booking['postponed_count'] ?? 0) >= $maxPostpone) {
return ['success' => false, 'error' => "تم الوصول للحد الأقصى للتأجيل ({$maxPostpone} مرات)"];
}
$unitId = (int) $booking['facility_unit_id'];
$spotsReserved = (int) $booking['spots_reserved'];
$availability = SlotAvailabilityService::check($unitId, $newDate, $newStartTime, $newEndTime, $spotsReserved, $bookingId);
if (!$availability['available']) {
return ['success' => false, 'error' => 'الموعد الجديد غير متاح: ' . $availability['reason']];
}
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$db->beginTransaction();
try {
$db->insert('sa_booking_changes', [
'booking_id' => $bookingId,
'change_type' => 'postponed',
'old_date' => $booking['booking_date'],
'new_date' => $newDate,
'old_start_time' => $booking['start_time'],
'new_start_time' => $newStartTime,
'old_end_time' => $booking['end_time'],
'new_end_time' => $newEndTime,
'reason' => $reason ?: null,
'changed_by' => $employeeId,
'created_at' => date('Y-m-d H:i:s'),
]);
$updateData = [
'booking_date' => $newDate,
'start_time' => $newStartTime,
'end_time' => $newEndTime,
'postponed_count' => (int) ($booking['postponed_count'] ?? 0) + 1,
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => $employeeId,
];
if (empty($booking['original_date'])) {
$updateData['original_date'] = $booking['booking_date'];
$updateData['original_start_time'] = $booking['start_time'];
}
$db->update('sa_bookings', $updateData, 'id = ?', [$bookingId]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل تأجيل الحجز: ' . $e->getMessage()];
}
return ['success' => true, 'postponed_count' => (int) ($booking['postponed_count'] ?? 0) + 1];
}
public static function cancel(int $bookingId, string $reason = ''): array public static function cancel(int $bookingId, string $reason = ''): array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
......
...@@ -22,10 +22,13 @@ final class EnrollmentService ...@@ -22,10 +22,13 @@ final class EnrollmentService
} }
$medicalWarning = null; $medicalWarning = null;
$needsGracePeriod = false;
if (!in_array($player['medical_status'], [SaConstants::MEDICAL_FIT, SaConstants::MEDICAL_CONDITIONAL])) { if (!in_array($player['medical_status'], [SaConstants::MEDICAL_FIT, SaConstants::MEDICAL_CONDITIONAL])) {
$medicalWarning = 'اللاعب بدون شهادة طبية سارية'; $medicalWarning = 'اللاعب بدون شهادة طبية سارية — سيتم تعيين مهلة لتقديم الشهادة';
$needsGracePeriod = true;
} elseif ($player['medical_status'] === SaConstants::MEDICAL_FIT && !empty($player['medical_expiry_date']) && $player['medical_expiry_date'] < date('Y-m-d')) { } elseif ($player['medical_status'] === SaConstants::MEDICAL_FIT && !empty($player['medical_expiry_date']) && $player['medical_expiry_date'] < date('Y-m-d')) {
$medicalWarning = 'الشهادة الطبية منتهية الصلاحية'; $medicalWarning = 'الشهادة الطبية منتهية الصلاحية — سيتم تعيين مهلة لتجديدها';
$needsGracePeriod = true;
} }
$group = $db->selectOne("SELECT * FROM sa_groups WHERE id = ? AND is_archived = 0", [$groupId]); $group = $db->selectOne("SELECT * FROM sa_groups WHERE id = ? AND is_archived = 0", [$groupId]);
...@@ -82,9 +85,19 @@ final class EnrollmentService ...@@ -82,9 +85,19 @@ final class EnrollmentService
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0); $employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$graceDeadline = null;
if ($needsGracePeriod) {
$graceDaysRow = $db->selectOne(
"SELECT config_value FROM system_config WHERE config_key = 'sa.medical_grace_days'",
[]
);
$graceDays = (int) ($graceDaysRow['config_value'] ?? 14);
$graceDeadline = date('Y-m-d', strtotime("+{$graceDays} days"));
}
$db->beginTransaction(); $db->beginTransaction();
try { try {
$enrollmentId = $db->insert('sa_group_players', [ $enrollData = [
'group_id' => $groupId, 'group_id' => $groupId,
'player_id' => $playerId, 'player_id' => $playerId,
'enrolled_at' => date('Y-m-d'), 'enrolled_at' => date('Y-m-d'),
...@@ -92,7 +105,11 @@ final class EnrollmentService ...@@ -92,7 +105,11 @@ final class EnrollmentService
'created_at' => date('Y-m-d H:i:s'), 'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employeeId, 'created_by' => $employeeId,
]); ];
if ($graceDeadline) {
$enrollData['medical_grace_deadline'] = $graceDeadline;
}
$enrollmentId = $db->insert('sa_group_players', $enrollData);
$memberId = !empty($player['member_id']) ? (int) $player['member_id'] : 0; $memberId = !empty($player['member_id']) ? (int) $player['member_id'] : 0;
$description = 'تسجيل رياضي — ' . $group['name_ar'] . ' — ' . $player['full_name_ar']; $description = 'تسجيل رياضي — ' . $group['name_ar'] . ' — ' . $player['full_name_ar'];
...@@ -126,6 +143,7 @@ final class EnrollmentService ...@@ -126,6 +143,7 @@ final class EnrollmentService
'request_number' => $requestResult['request_number'] ?? null, 'request_number' => $requestResult['request_number'] ?? null,
'fee' => $fee, 'fee' => $fee,
'medical_warning' => $medicalWarning, 'medical_warning' => $medicalWarning,
'medical_grace_deadline' => $graceDeadline,
]; ];
} }
......
...@@ -39,7 +39,7 @@ final class PoolGridTemplateService ...@@ -39,7 +39,7 @@ final class PoolGridTemplateService
return $template; return $template;
} }
public static function createTemplate(int $facilityId, string $name, ?string $description, ?string $color, array $entries): array public static function createTemplate(int $facilityId, string $name, ?string $description, ?string $color, array $entries, string $recurrenceType = 'weekly', ?array $recurrenceConfig = null): array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$ts = date('Y-m-d H:i:s'); $ts = date('Y-m-d H:i:s');
...@@ -53,11 +53,18 @@ final class PoolGridTemplateService ...@@ -53,11 +53,18 @@ final class PoolGridTemplateService
return ['success' => false, 'error' => 'يجب إضافة إدخال واحد على الأقل']; return ['success' => false, 'error' => 'يجب إضافة إدخال واحد على الأقل'];
} }
$validTypes = ['daily', 'weekly', 'biweekly', 'monthly', 'custom'];
if (!in_array($recurrenceType, $validTypes, true)) {
$recurrenceType = 'weekly';
}
$db->insert('sa_pool_zone_templates', [ $db->insert('sa_pool_zone_templates', [
'facility_id' => $facilityId, 'facility_id' => $facilityId,
'name' => trim($name), 'name' => trim($name),
'description' => $description ? trim($description) : null, 'description' => $description ? trim($description) : null,
'color' => $color ?: '#3B82F6', 'color' => $color ?: '#3B82F6',
'recurrence_type' => $recurrenceType,
'recurrence_config' => $recurrenceConfig ? json_encode($recurrenceConfig, JSON_UNESCAPED_UNICODE) : null,
'is_active' => 1, 'is_active' => 1,
'created_by' => $employeeId, 'created_by' => $employeeId,
'created_at' => $ts, 'created_at' => $ts,
...@@ -153,20 +160,46 @@ final class PoolGridTemplateService ...@@ -153,20 +160,46 @@ final class PoolGridTemplateService
$runId = (int) $db->selectOne("SELECT LAST_INSERT_ID() as id", [])['id']; $runId = (int) $db->selectOne("SELECT LAST_INSERT_ID() as id", [])['id'];
$entries = $template['entries'] ?? []; $entries = $template['entries'] ?? [];
$recurrenceType = $template['recurrence_type'] ?? 'weekly';
$generated = 0; $generated = 0;
$skipped = 0; $skipped = 0;
// Iterate each day in range // Iterate each day in range
$current = strtotime($fromDate); $current = strtotime($fromDate);
$end = strtotime($toDate); $end = strtotime($toDate);
$startTimestamp = strtotime($fromDate);
while ($current <= $end) { while ($current <= $end) {
$dayOfWeek = (int) date('w', $current); // 0=Sunday $dayOfWeek = (int) date('w', $current); // 0=Sunday
$dateStr = date('Y-m-d', $current); $dateStr = date('Y-m-d', $current);
$dayOfMonth = (int) date('j', $current);
$daysSinceStart = (int) (($current - $startTimestamp) / 86400);
// Process matching entries for this day // Process matching entries for this day
foreach ($entries as $entry) { foreach ($entries as $entry) {
if ((int) $entry['day_of_week'] !== $dayOfWeek) continue; $matches = false;
switch ($recurrenceType) {
case 'daily':
$matches = true;
break;
case 'weekly':
$matches = ((int) $entry['day_of_week'] === $dayOfWeek);
break;
case 'biweekly':
$matches = ((int) $entry['day_of_week'] === $dayOfWeek) && (intdiv($daysSinceStart, 7) % 2 === 0);
break;
case 'monthly':
$monthDay = (int) ($entry['month_day'] ?? $entry['day_of_week']);
$matches = ($dayOfMonth === $monthDay);
break;
case 'custom':
$interval = (int) ($entry['day_interval'] ?? 1);
$matches = ($interval > 0 && $daysSinceStart % $interval === 0);
break;
default:
$matches = ((int) $entry['day_of_week'] === $dayOfWeek);
}
if (!$matches) continue;
$cells = self::resolveZoneSelection( $cells = self::resolveZoneSelection(
$entry['zone_selection_type'], $entry['zone_selection_type'],
......
...@@ -43,12 +43,47 @@ ...@@ -43,12 +43,47 @@
$playerId = (int) $player['player_id']; $playerId = (int) $player['player_id'];
$currentStatus = $attendanceMap[$playerId] ?? 'present'; $currentStatus = $attendanceMap[$playerId] ?? 'present';
?> ?>
<?php
$issues = [];
$medStatus = $player['medical_status'] ?? 'pending';
$graceDeadline = $player['medical_grace_deadline'] ?? null;
if ($medStatus === 'expired' || (!empty($player['medical_expiry_date']) && $player['medical_expiry_date'] < date('Y-m-d'))) {
if ($graceDeadline && $graceDeadline >= date('Y-m-d')) {
$issues[] = ['label' => 'مهلة طبية حتى ' . $graceDeadline, 'color' => '#D97706', 'bg' => '#FEF3C7'];
} elseif ($graceDeadline && $graceDeadline < date('Y-m-d')) {
$issues[] = ['label' => 'انتهت المهلة الطبية!', 'color' => '#DC2626', 'bg' => '#FEE2E2'];
} else {
$issues[] = ['label' => 'شهادة طبية منتهية', 'color' => '#DC2626', 'bg' => '#FEE2E2'];
}
} elseif ($medStatus === 'pending') {
if ($graceDeadline && $graceDeadline >= date('Y-m-d')) {
$issues[] = ['label' => 'مهلة طبية حتى ' . $graceDeadline, 'color' => '#D97706', 'bg' => '#FEF3C7'];
} else {
$issues[] = ['label' => 'بانتظار الموافقة الطبية', 'color' => '#D97706', 'bg' => '#FEF3C7'];
}
} elseif (!in_array($medStatus, ['fit', 'conditional'])) {
if ($graceDeadline && $graceDeadline >= date('Y-m-d')) {
$issues[] = ['label' => 'مهلة طبية حتى ' . $graceDeadline, 'color' => '#D97706', 'bg' => '#FEF3C7'];
} else {
$issues[] = ['label' => 'شهادة طبية مفقودة', 'color' => '#DC2626', 'bg' => '#FEE2E2'];
}
}
if (($player['enrollment_status'] ?? '') === 'pending_payment') {
$issues[] = ['label' => 'دفع التسجيل معلق', 'color' => '#D97706', 'bg' => '#FEF3C7'];
} elseif ((int) ($player['unpaid_count'] ?? 0) > 0) {
$issues[] = ['label' => 'اشتراك غير مدفوع', 'color' => '#D97706', 'bg' => '#FEF3C7'];
}
?>
<tr> <tr>
<td><?= $i + 1 ?></td> <td><?= $i + 1 ?></td>
<td style="font-weight:600;"> <td>
<?= e($player['player_name'] ?? '') ?> <div style="font-weight:600;"><?= e($player['player_name'] ?? '') ?></div>
<?php if (!in_array($player['medical_status'] ?? 'pending', ['fit', 'conditional'])): ?> <?php if (!empty($issues)): ?>
<i data-lucide="alert-triangle" style="width:13px;height:13px;color:#D97706;vertical-align:middle;margin-right:3px;" title="بدون شهادة طبية سارية"></i> <div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:3px;">
<?php foreach ($issues as $issue): ?>
<span style="font-size:10px;font-weight:600;padding:1px 6px;border-radius:8px;background:<?= $issue['bg'] ?>;color:<?= $issue['color'] ?>;"><?= $issue['label'] ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td><code style="font-size:11px;background:#F3F4F6;padding:2px 6px;border-radius:4px;"><?= e($player['player_code'] ?? '') ?></code></td> <td><code style="font-size:11px;background:#F3F4F6;padding:2px 6px;border-radius:4px;"><?= e($player['player_code'] ?? '') ?></code></td>
......
...@@ -40,6 +40,12 @@ $bs = $booking['status'] ?? ''; ...@@ -40,6 +40,12 @@ $bs = $booking['status'] ?? '';
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php if (!in_array($bs, ['cancelled', 'completed', 'no_show']) && can('sa.booking.manage')): ?> <?php if (!in_array($bs, ['cancelled', 'completed', 'no_show']) && can('sa.booking.manage')): ?>
<button type="button" class="btn btn-sm btn-outline" style="color:#D97706;border-color:#D97706;" onclick="document.getElementById('postponeSection').style.display = document.getElementById('postponeSection').style.display === 'none' ? 'block' : 'none';">
<i data-lucide="calendar-clock" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> تأجيل
<?php if ((int) ($booking['postponed_count'] ?? 0) > 0): ?>
<span style="font-size:10px;background:#FEF3C7;color:#D97706;padding:1px 5px;border-radius:8px;margin-right:4px;"><?= (int) $booking['postponed_count'] ?>/3</span>
<?php endif; ?>
</button>
<form method="POST" action="/sa/bookings/<?= (int) $booking['id'] ?>/cancel" style="display:inline;" onsubmit="return confirm('هل تريد إلغاء هذا الحجز؟');"> <form method="POST" action="/sa/bookings/<?= (int) $booking['id'] ?>/cancel" style="display:inline;" onsubmit="return confirm('هل تريد إلغاء هذا الحجز؟');">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="reason" value=""> <input type="hidden" name="reason" value="">
...@@ -51,6 +57,49 @@ $bs = $booking['status'] ?? ''; ...@@ -51,6 +57,49 @@ $bs = $booking['status'] ?? '';
</div> </div>
</div> </div>
<!-- Postpone Form (hidden by default) -->
<?php if (!in_array($bs, ['cancelled', 'completed', 'no_show']) && can('sa.booking.manage')): ?>
<div id="postponeSection" style="display:none;margin-bottom:20px;">
<div class="card" style="border:2px solid #F59E0B;border-radius:8px;">
<div style="padding:15px 20px;border-bottom:1px solid #FEF3C7;background:#FFFBEB;">
<h3 style="margin:0;color:#D97706;font-size:14px;">تأجيل الحجز</h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/sa/bookings/<?= (int) $booking['id'] ?>/postpone">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;margin-bottom:15px;">
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">التاريخ الجديد <span style="color:#DC2626;">*</span></label>
<input type="date" name="new_date" class="form-input" required min="<?= date('Y-m-d') ?>">
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">وقت البداية <span style="color:#DC2626;">*</span></label>
<input type="time" name="new_start_time" class="form-input" required value="<?= e(substr($booking['start_time'], 0, 5)) ?>">
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">وقت النهاية <span style="color:#DC2626;">*</span></label>
<input type="time" name="new_end_time" class="form-input" required value="<?= e(substr($booking['end_time'], 0, 5)) ?>">
</div>
</div>
<div class="form-group" style="margin:0 0 15px 0;">
<label class="form-label" style="font-size:12px;">سبب التأجيل</label>
<input type="text" name="reason" class="form-input" placeholder="سبب اختياري...">
</div>
<?php if ((int) ($booking['postponed_count'] ?? 0) >= 3): ?>
<div style="padding:10px;background:#FEE2E2;border-radius:6px;font-size:12px;color:#DC2626;margin-bottom:15px;">تم الوصول للحد الأقصى للتأجيل (3 مرات)</div>
<?php else: ?>
<div style="display:flex;gap:10px;align-items:center;">
<button type="submit" class="btn btn-primary" style="font-size:13px;">تأكيد التأجيل</button>
<button type="button" class="btn btn-outline" style="font-size:13px;" onclick="document.getElementById('postponeSection').style.display='none';">إلغاء</button>
<span style="font-size:11px;color:#6B7280;">التأجيلات: <?= (int) ($booking['postponed_count'] ?? 0) ?>/3</span>
</div>
<?php endif; ?>
</form>
</div>
</div>
</div>
<?php endif; ?>
<!-- Booking Details --> <!-- Booking Details -->
<div class="card" style="margin-bottom:20px;"> <div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"> <div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
......
...@@ -777,13 +777,13 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0); ...@@ -777,13 +777,13 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
// Template wizard state // Template wizard state
let wizStep = 0; let wizStep = 0;
let wizData = {name:'', color:'#3B82F6', days:[], slots:[], zoneType:'all', zones:{}, action:'training', group_id:null, label:'', notes:''}; let wizData = {name:'', color:'#3B82F6', recurrence_type:'weekly', days:[], slots:[], zoneType:'all', zones:{}, action:'training', group_id:null, label:'', notes:''};
document.getElementById('pgTplCreate').onclick = function(){ openWizard(); }; document.getElementById('pgTplCreate').onclick = function(){ openWizard(); };
function openWizard(editData){ function openWizard(editData){
wizStep = 0; wizStep = 0;
wizData = editData || {name:'', color:'#3B82F6', days:[], slots:[], zoneType:'all', zones:{}, action:'training', group_id:null, label:'', notes:''}; wizData = editData || {name:'', color:'#3B82F6', recurrence_type:'weekly', days:[], slots:[], zoneType:'all', zones:{}, action:'training', group_id:null, label:'', notes:''};
renderWizStep(); renderWizStep();
document.getElementById('pgTplWizModal').classList.add('show'); document.getElementById('pgTplWizModal').classList.add('show');
} }
...@@ -832,6 +832,14 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0); ...@@ -832,6 +832,14 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
if(wizStep === 0){ if(wizStep === 0){
html += '<label>اسم القالب</label>'; html += '<label>اسم القالب</label>';
html += '<input type="text" id="pgWizName" value="' + esc(wizData.name) + '" placeholder="مثال: تدريب صباحي أسبوعي">'; html += '<input type="text" id="pgWizName" value="' + esc(wizData.name) + '" placeholder="مثال: تدريب صباحي أسبوعي">';
html += '<label>نمط التكرار</label>';
html += '<select id="pgWizRecurrence">';
html += '<option value="daily"' + (wizData.recurrence_type==='daily'?' selected':'') + '>يومي</option>';
html += '<option value="weekly"' + (wizData.recurrence_type==='weekly'?' selected':'') + '>أسبوعي</option>';
html += '<option value="biweekly"' + (wizData.recurrence_type==='biweekly'?' selected':'') + '>كل أسبوعين</option>';
html += '<option value="monthly"' + (wizData.recurrence_type==='monthly'?' selected':'') + '>شهري</option>';
html += '<option value="custom"' + (wizData.recurrence_type==='custom'?' selected':'') + '>مخصص (كل N يوم)</option>';
html += '</select>';
html += '<label>اللون</label>'; html += '<label>اللون</label>';
html += '<input type="color" id="pgWizColor" value="' + (wizData.color || '#3B82F6') + '" style="width:60px;height:36px;padding:2px;cursor:pointer;">'; html += '<input type="color" id="pgWizColor" value="' + (wizData.color || '#3B82F6') + '" style="width:60px;height:36px;padding:2px;cursor:pointer;">';
} else if(wizStep === 1){ } else if(wizStep === 1){
...@@ -873,6 +881,7 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0); ...@@ -873,6 +881,7 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
// Bind step-specific events // Bind step-specific events
if(wizStep === 0){ if(wizStep === 0){
document.getElementById('pgWizName').oninput = function(){ wizData.name = this.value; }; document.getElementById('pgWizName').oninput = function(){ wizData.name = this.value; };
document.getElementById('pgWizRecurrence').onchange = function(){ wizData.recurrence_type = this.value; };
document.getElementById('pgWizColor').oninput = function(){ wizData.color = this.value; }; document.getElementById('pgWizColor').oninput = function(){ wizData.color = this.value; };
} else if(wizStep === 1){ } else if(wizStep === 1){
content.querySelectorAll('.wiz-day').forEach(function(btn){ content.querySelectorAll('.wiz-day').forEach(function(btn){
...@@ -958,6 +967,7 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0); ...@@ -958,6 +967,7 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
name: wizData.name, name: wizData.name,
description: '', description: '',
color: wizData.color, color: wizData.color,
recurrence_type: wizData.recurrence_type,
entries: entries entries: entries
}).then(function(res){ }).then(function(res){
if(res.error){ alert(res.error); return; } if(res.error){ alert(res.error); return; }
......
...@@ -70,7 +70,43 @@ class SaMedicalExpiryJob ...@@ -70,7 +70,43 @@ class SaMedicalExpiryJob
", [date('Y-m-d H:i:s'), $today]); ", [date('Y-m-d H:i:s'), $today]);
$docsExpired = $stmt->rowCount(); $docsExpired = $stmt->rowCount();
Logger::info("SA Medical expiry: {$notified} reminders, {$expired} players expired, {$docsExpired} docs expired"); // Suspend enrollments where medical grace period has expired
return ['reminders_sent' => $notified, 'players_expired' => $expired, 'docs_expired' => $docsExpired]; $graceSuspended = 0;
$graceExpired = $this->db->select("
SELECT gp.id, gp.player_id, gp.group_id
FROM sa_group_players gp
JOIN sa_players p ON p.id = gp.player_id
WHERE gp.status = 'active'
AND gp.medical_grace_deadline IS NOT NULL
AND gp.medical_grace_deadline < ?
AND p.medical_status NOT IN ('fit', 'conditional')
", [$today]);
foreach ($graceExpired as $enrollment) {
$this->db->update('sa_group_players', [
'status' => 'suspended',
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $enrollment['id']]);
$group = $this->db->selectOne("SELECT current_count FROM sa_groups WHERE id = ?", [(int) $enrollment['group_id']]);
if ($group) {
$newCount = max(0, (int) $group['current_count'] - 1);
$this->db->update('sa_groups', [
'current_count' => $newCount,
'is_full' => 0,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $enrollment['group_id']]);
}
EventBus::dispatch('sa.player.medical_grace_expired', [
'player_id' => (int) $enrollment['player_id'],
'group_id' => (int) $enrollment['group_id'],
'enrollment_id' => (int) $enrollment['id'],
]);
$graceSuspended++;
}
Logger::info("SA Medical expiry: {$notified} reminders, {$expired} players expired, {$docsExpired} docs expired, {$graceSuspended} grace suspensions");
return ['reminders_sent' => $notified, 'players_expired' => $expired, 'docs_expired' => $docsExpired, 'grace_suspended' => $graceSuspended];
} }
} }
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE sa_players ADD COLUMN medical_level TINYINT UNSIGNED NULL DEFAULT NULL COMMENT '1=full, 2=moderate, 3=limited' AFTER medical_expiry_date;
ALTER TABLE player_medical_records ADD COLUMN medical_level TINYINT UNSIGNED NULL DEFAULT NULL COMMENT '1=full, 2=moderate, 3=limited' AFTER result;
ALTER TABLE players ADD COLUMN medical_level TINYINT UNSIGNED NULL DEFAULT NULL COMMENT '1=full, 2=moderate, 3=limited' AFTER medical_expiry_date;
",
'down' => "
ALTER TABLE sa_players DROP COLUMN medical_level;
ALTER TABLE player_medical_records DROP COLUMN medical_level;
ALTER TABLE players DROP COLUMN medical_level;
",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE sa_group_players ADD COLUMN medical_grace_deadline DATE NULL DEFAULT NULL COMMENT 'Deadline for submitting medical cert' AFTER status;
INSERT INTO system_config (config_key, config_value, config_group, label_ar, field_type, created_at, updated_at)
VALUES ('sa.medical_grace_days', '14', 'sports_activity', 'مهلة تقديم الشهادة الطبية (بالأيام)', 'number', NOW(), NOW())
ON DUPLICATE KEY UPDATE config_key = config_key;
",
'down' => "
ALTER TABLE sa_group_players DROP COLUMN medical_grace_deadline;
DELETE FROM system_config WHERE config_key = 'sa.medical_grace_days';
",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE `sa_booking_changes` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`booking_id` BIGINT UNSIGNED NOT NULL,
`change_type` VARCHAR(30) NOT NULL COMMENT 'postponed, cancelled, time_changed, unit_changed',
`old_date` DATE NULL,
`new_date` DATE NULL,
`old_start_time` TIME NULL,
`new_start_time` TIME NULL,
`old_end_time` TIME NULL,
`new_end_time` TIME NULL,
`old_unit_id` BIGINT UNSIGNED NULL,
`new_unit_id` BIGINT UNSIGNED NULL,
`reason` TEXT NULL,
`changed_by` BIGINT UNSIGNED NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_sa_bc_booking` (`booking_id`),
INDEX `idx_sa_bc_type` (`change_type`),
CONSTRAINT `fk_sa_bc_booking` FOREIGN KEY (`booking_id`) REFERENCES `sa_bookings`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE sa_bookings ADD COLUMN postponed_count TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER cancellation_reason;
ALTER TABLE sa_bookings ADD COLUMN original_date DATE NULL AFTER postponed_count;
ALTER TABLE sa_bookings ADD COLUMN original_start_time TIME NULL AFTER original_date;
",
'down' => "
DROP TABLE IF EXISTS `sa_booking_changes`;
ALTER TABLE sa_bookings DROP COLUMN postponed_count;
ALTER TABLE sa_bookings DROP COLUMN original_date;
ALTER TABLE sa_bookings DROP COLUMN original_start_time;
",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE sa_pool_zone_templates ADD COLUMN recurrence_type VARCHAR(20) NOT NULL DEFAULT 'weekly' COMMENT 'daily, weekly, biweekly, monthly, custom' AFTER color;
ALTER TABLE sa_pool_zone_templates ADD COLUMN recurrence_config JSON NULL COMMENT 'Config for custom patterns' AFTER recurrence_type;
ALTER TABLE sa_pool_zone_template_entries ADD COLUMN day_interval INT UNSIGNED NULL DEFAULT NULL COMMENT 'For biweekly/custom: repeat every N days' AFTER day_of_week;
ALTER TABLE sa_pool_zone_template_entries ADD COLUMN month_day TINYINT UNSIGNED NULL DEFAULT NULL COMMENT 'For monthly: day of month' AFTER day_interval;
",
'down' => "
ALTER TABLE sa_pool_zone_templates DROP COLUMN recurrence_type;
ALTER TABLE sa_pool_zone_templates DROP COLUMN recurrence_config;
ALTER TABLE sa_pool_zone_template_entries DROP COLUMN day_interval;
ALTER TABLE sa_pool_zone_template_entries DROP COLUMN month_day;
",
];
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