Commit b6b61bdb authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(sports): implement full sports module enhancement (Phases 0-10)

- Phase 0: Schema — training attendance, makeup sessions, institutions tables + sport_type on disciplines
- Phase 1: Player lifecycle — pause/resume months, medical grace enforcement with auto-suspend cron
- Phase 2: Medical grace integration — grace deadline on enrollment, extend grace endpoint
- Phase 3: Training attendance — group-session-based system (separate from booking-based sa_attendance)
- Phase 4: Make-up sessions — create from absence, schedule, complete, auto-expire cron
- Phase 5: Institution management — full CRUD with booking linkage and contract tracking
- Phase 6: Subscription renewal — medical verification flag on auto-generated subscriptions
- Phase 7: Treasury auto-sync — already existed via SaEventListenerService (verified)
- Phase 8: Recreational differentiation — sport_type enum on disciplines (training/recreational)
- Phase 9: Dashboard — added medical grace expiry + makeup session alerts
- Phase 10: Schedule copy — copy between groups, shift times, program-wide schedule propagation
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent e83886e0
...@@ -69,6 +69,26 @@ class DashboardController extends Controller ...@@ -69,6 +69,26 @@ class DashboardController extends Controller
$alerts[] = ['type' => 'warning', 'icon' => 'credit-card', 'text' => $expiringCards . ' كارت ينتهي خلال أسبوع', 'link' => '/sa/cards', 'count' => $expiringCards]; $alerts[] = ['type' => 'warning', 'icon' => 'credit-card', 'text' => $expiringCards . ' كارت ينتهي خلال أسبوع', 'link' => '/sa/cards', 'count' => $expiringCards];
} }
$medicalGraceExpiring = (int) $db->selectOne(
"SELECT COUNT(*) as c FROM sa_group_players gp
JOIN sa_players sp ON sp.id = gp.player_id
WHERE gp.status = 'active' AND gp.medical_grace_deadline IS NOT NULL
AND gp.medical_grace_deadline BETWEEN ? AND DATE_ADD(?, INTERVAL 3 DAY)
AND sp.medical_status NOT IN ('fit','conditional')"
, [$today, $today]
)['c'];
if ($medicalGraceExpiring > 0) {
$alerts[] = ['type' => 'danger', 'icon' => 'heart-pulse', 'text' => $medicalGraceExpiring . ' لاعب تنتهي مهلتهم الطبية خلال 3 أيام', 'link' => '/sa/players', 'count' => $medicalGraceExpiring];
}
$eligibleMakeups = (int) $db->selectOne(
"SELECT COUNT(*) as c FROM sa_makeup_sessions WHERE status = 'eligible' AND expires_at >= ?",
[$today]
)['c'];
if ($eligibleMakeups > 0) {
$alerts[] = ['type' => 'info', 'icon' => 'refresh-ccw', 'text' => $eligibleMakeups . ' حصة تعويضية بانتظار الجدولة', 'link' => '/sa/makeup-sessions?status=eligible', 'count' => $eligibleMakeups];
}
// ─── Live Now: What's happening right now ─────────────────────────── // ─── Live Now: What's happening right now ───────────────────────────
$liveNow = $db->select( $liveNow = $db->select(
"SELECT b.start_time, b.end_time, b.booking_type, b.booker_name, b.booker_type, "SELECT b.start_time, b.end_time, b.booking_type, b.booker_name, b.booker_type,
......
...@@ -134,11 +134,17 @@ class DisciplineController extends Controller ...@@ -134,11 +134,17 @@ class DisciplineController extends Controller
return $this->redirect('/sa/disciplines/create'); return $this->redirect('/sa/disciplines/create');
} }
$sportType = trim((string) $request->post('sport_type', 'training'));
if (!in_array($sportType, ['training', 'recreational'], true)) {
$sportType = 'training';
}
$discipline = Discipline::create([ $discipline = Discipline::create([
'code' => $code, 'code' => $code,
'name_ar' => $nameAr, 'name_ar' => $nameAr,
'name_en' => $nameEn ?: null, 'name_en' => $nameEn ?: null,
'category' => $category ?: null, 'category' => $category ?: null,
'sport_type' => $sportType,
'icon' => $icon ?: null, 'icon' => $icon ?: null,
'description_ar' => $descriptionAr ?: null, 'description_ar' => $descriptionAr ?: null,
'sort_order' => $sortOrder, 'sort_order' => $sortOrder,
...@@ -231,11 +237,17 @@ class DisciplineController extends Controller ...@@ -231,11 +237,17 @@ class DisciplineController extends Controller
return $this->redirect('/sa/disciplines/' . $id . '/edit'); return $this->redirect('/sa/disciplines/' . $id . '/edit');
} }
$sportType = trim((string) $request->post('sport_type', 'training'));
if (!in_array($sportType, ['training', 'recreational'], true)) {
$sportType = 'training';
}
$discipline->update([ $discipline->update([
'code' => $code, 'code' => $code,
'name_ar' => $nameAr, 'name_ar' => $nameAr,
'name_en' => $nameEn ?: null, 'name_en' => $nameEn ?: null,
'category' => $category ?: null, 'category' => $category ?: null,
'sport_type' => $sportType,
'icon' => $icon ?: null, 'icon' => $icon ?: null,
'description_ar' => $descriptionAr ?: null, 'description_ar' => $descriptionAr ?: null,
'sort_order' => $sortOrder, 'sort_order' => $sortOrder,
......
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
class InstitutionController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$search = trim((string) $request->get('search', ''));
$type = trim((string) $request->get('type', ''));
$where = "is_archived = 0";
$params = [];
if ($search !== '') {
$where .= " AND (name_ar LIKE ? OR name_en LIKE ? OR code LIKE ?)";
$params[] = "%{$search}%";
$params[] = "%{$search}%";
$params[] = "%{$search}%";
}
if ($type !== '') {
$where .= " AND institution_type = ?";
$params[] = $type;
}
$institutions = $db->select(
"SELECT *,
(SELECT COUNT(*) FROM sa_bookings b WHERE b.institution_id = sa_institutions.id AND b.status != 'cancelled') as booking_count
FROM sa_institutions
WHERE {$where}
ORDER BY name_ar ASC",
$params
);
return $this->view('SportsActivity.Views.institutions.index', [
'institutions' => $institutions,
'filters' => ['search' => $search, 'type' => $type],
]);
}
public function create(Request $request): Response
{
return $this->view('SportsActivity.Views.institutions.form', [
'institution' => null,
'mode' => 'create',
]);
}
public function store(Request $request): Response
{
$data = $this->validateInstitution($request);
if ($data instanceof Response) {
return $data;
}
$db = App::getInstance()->db();
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$existing = $db->selectOne(
"SELECT id FROM sa_institutions WHERE code = ?",
[$data['code']]
);
if ($existing) {
return $this->redirect('/sa/institutions/create')->withError('كود المؤسسة مستخدم بالفعل');
}
$data['created_at'] = date('Y-m-d H:i:s');
$data['updated_at'] = date('Y-m-d H:i:s');
$data['created_by'] = $employeeId;
$db->insert('sa_institutions', $data);
return $this->redirect('/sa/institutions')->withSuccess('تم إضافة المؤسسة بنجاح');
}
public function edit(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$institution = $db->selectOne(
"SELECT * FROM sa_institutions WHERE id = ? AND is_archived = 0",
[(int) $id]
);
if (!$institution) {
return $this->redirect('/sa/institutions')->withError('المؤسسة غير موجودة');
}
return $this->view('SportsActivity.Views.institutions.form', [
'institution' => $institution,
'mode' => 'edit',
]);
}
public function update(Request $request, string $id): Response
{
$data = $this->validateInstitution($request);
if ($data instanceof Response) {
return $data;
}
$db = App::getInstance()->db();
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$existing = $db->selectOne(
"SELECT id FROM sa_institutions WHERE code = ? AND id != ?",
[$data['code'], (int) $id]
);
if ($existing) {
return $this->redirect("/sa/institutions/{$id}/edit")->withError('كود المؤسسة مستخدم بالفعل');
}
$data['updated_at'] = date('Y-m-d H:i:s');
$data['updated_by'] = $employeeId;
$db->update('sa_institutions', $data, 'id = ?', [(int) $id]);
return $this->redirect('/sa/institutions')->withSuccess('تم تحديث بيانات المؤسسة');
}
public function show(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$institution = $db->selectOne(
"SELECT * FROM sa_institutions WHERE id = ?",
[(int) $id]
);
if (!$institution) {
return $this->redirect('/sa/institutions')->withError('المؤسسة غير موجودة');
}
$bookings = $db->select(
"SELECT b.*, fu.name_ar as unit_name
FROM sa_bookings b
JOIN sa_facility_units fu ON fu.id = b.facility_unit_id
WHERE b.institution_id = ?
ORDER BY b.booking_date DESC
LIMIT 50",
[(int) $id]
);
$stats = $db->selectOne(
"SELECT
COUNT(*) as total_bookings,
SUM(CASE WHEN status = 'confirmed' THEN 1 ELSE 0 END) as confirmed,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled,
SUM(total_amount) as total_revenue
FROM sa_bookings WHERE institution_id = ?",
[(int) $id]
);
return $this->view('SportsActivity.Views.institutions.show', [
'institution' => $institution,
'bookings' => $bookings,
'stats' => $stats,
]);
}
public function archive(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$db->update('sa_institutions', [
'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
'archived_by' => $employeeId,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $id]);
return $this->redirect('/sa/institutions')->withSuccess('تم أرشفة المؤسسة');
}
private function validateInstitution(Request $request)
{
$code = trim((string) $request->post('code', ''));
$nameAr = trim((string) $request->post('name_ar', ''));
$type = trim((string) $request->post('institution_type', 'school'));
if ($code === '' || $nameAr === '') {
return $this->redirect('/sa/institutions')->withError('الكود والاسم مطلوبان');
}
$validTypes = ['school', 'university', 'company', 'club', 'government', 'other'];
if (!in_array($type, $validTypes, true)) {
$type = 'other';
}
return [
'code' => $code,
'name_ar' => $nameAr,
'name_en' => trim((string) $request->post('name_en', '')) ?: null,
'institution_type' => $type,
'contact_person' => trim((string) $request->post('contact_person', '')) ?: null,
'phone' => trim((string) $request->post('phone', '')) ?: null,
'email' => trim((string) $request->post('email', '')) ?: null,
'address' => trim((string) $request->post('address', '')) ?: null,
'contract_start' => trim((string) $request->post('contract_start', '')) ?: null,
'contract_end' => trim((string) $request->post('contract_end', '')) ?: null,
'hourly_rate' => trim((string) $request->post('hourly_rate', '')) ?: null,
'discount_percent' => (float) $request->post('discount_percent', 0),
'max_participants' => ((int) $request->post('max_participants', 0)) ?: null,
'notes' => trim((string) $request->post('notes', '')) ?: null,
'is_active' => (int) $request->post('is_active', 1),
];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\SportsActivity\Services\MakeupSessionService;
class MakeupSessionController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$status = trim((string) $request->get('status', ''));
$groupId = trim((string) $request->get('group_id', ''));
$where = "1=1";
$params = [];
if ($status !== '') {
$where .= " AND ms.status = ?";
$params[] = $status;
}
if ($groupId !== '') {
$where .= " AND ms.original_group_id = ?";
$params[] = (int) $groupId;
}
$makeups = $db->select(
"SELECT ms.*, sp.full_name_ar as player_name,
g1.name_ar as original_group_name, g2.name_ar as makeup_group_name
FROM sa_makeup_sessions ms
JOIN sa_players sp ON sp.id = ms.player_id
JOIN sa_groups g1 ON g1.id = ms.original_group_id
LEFT JOIN sa_groups g2 ON g2.id = ms.makeup_group_id
WHERE {$where}
ORDER BY ms.created_at DESC
LIMIT 200",
$params
);
$groups = $db->select(
"SELECT id, name_ar FROM sa_groups WHERE status = 'active' AND is_archived = 0 ORDER BY name_ar"
);
return $this->view('SportsActivity.Views.makeup-sessions.index', [
'makeups' => $makeups,
'groups' => $groups,
'filters' => ['status' => $status, 'group_id' => $groupId],
]);
}
public function create(Request $request): Response
{
$db = App::getInstance()->db();
$playerId = trim((string) $request->get('player_id', ''));
$groupId = trim((string) $request->get('group_id', ''));
$player = null;
$absences = [];
if ($playerId !== '' && $groupId !== '') {
$player = $db->selectOne("SELECT * FROM sa_players WHERE id = ?", [(int) $playerId]);
$absences = $db->select(
"SELECT ta.session_date, ta.status
FROM sa_training_attendance ta
WHERE ta.player_id = ? AND ta.group_id = ? AND ta.status IN ('absent','excused')
AND NOT EXISTS (
SELECT 1 FROM sa_makeup_sessions ms
WHERE ms.player_id = ta.player_id AND ms.original_group_id = ta.group_id
AND ms.missed_session_date = ta.session_date AND ms.status NOT IN ('cancelled','expired')
)
ORDER BY ta.session_date DESC
LIMIT 30",
[(int) $playerId, (int) $groupId]
);
}
$groups = $db->select(
"SELECT id, name_ar FROM sa_groups WHERE status = 'active' AND is_archived = 0 ORDER BY name_ar"
);
return $this->view('SportsActivity.Views.makeup-sessions.create', [
'player' => $player,
'absences' => $absences,
'groups' => $groups,
'filters' => ['player_id' => $playerId, 'group_id' => $groupId],
]);
}
public function store(Request $request): Response
{
$playerId = (int) $request->post('player_id', 0);
$groupId = (int) $request->post('group_id', 0);
$missedDate = trim((string) $request->post('missed_session_date', ''));
$reason = trim((string) $request->post('reason', ''));
if (!$playerId || !$groupId || !$missedDate) {
return $this->redirect('/sa/makeup-sessions/create')->withError('جميع الحقول مطلوبة');
}
$result = MakeupSessionService::createFromAbsence($playerId, $groupId, $missedDate, $reason);
if (!$result['success']) {
return $this->redirect("/sa/makeup-sessions/create?player_id={$playerId}&group_id={$groupId}")
->withError($result['error']);
}
return $this->redirect('/sa/makeup-sessions')
->withSuccess("تم إنشاء حصة تعويضية — تنتهي في {$result['expires_at']}");
}
public function schedule(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$makeup = $db->selectOne(
"SELECT ms.*, sp.full_name_ar as player_name, g.name_ar as group_name,
p.discipline_id
FROM sa_makeup_sessions ms
JOIN sa_players sp ON sp.id = ms.player_id
JOIN sa_groups g ON g.id = ms.original_group_id
JOIN sa_programs p ON p.id = g.program_id
WHERE ms.id = ? AND ms.status = 'eligible'",
[(int) $id]
);
if (!$makeup) {
return $this->redirect('/sa/makeup-sessions')->withError('الحصة التعويضية غير متاحة');
}
$availableGroups = $db->select(
"SELECT g.id, g.name_ar, g.code, c.full_name_ar as coach_name
FROM sa_groups g
JOIN sa_programs p ON p.id = g.program_id
LEFT JOIN sa_coaches c ON c.id = g.coach_id
WHERE g.status = 'active' AND g.is_archived = 0
AND p.discipline_id = ?
ORDER BY g.name_ar",
[(int) $makeup['discipline_id']]
);
return $this->view('SportsActivity.Views.makeup-sessions.schedule', [
'makeup' => $makeup,
'availableGroups' => $availableGroups,
]);
}
public function confirmSchedule(Request $request, string $id): Response
{
$makeupGroupId = (int) $request->post('makeup_group_id', 0);
$makeupDate = trim((string) $request->post('makeup_date', ''));
if (!$makeupGroupId || !$makeupDate) {
return $this->redirect("/sa/makeup-sessions/{$id}/schedule")
->withError('يجب اختيار المجموعة والتاريخ');
}
$result = MakeupSessionService::schedule((int) $id, $makeupGroupId, $makeupDate);
if (!$result['success']) {
return $this->redirect("/sa/makeup-sessions/{$id}/schedule")
->withError($result['error']);
}
return $this->redirect('/sa/makeup-sessions')
->withSuccess('تم جدولة الحصة التعويضية بنجاح');
}
public function complete(Request $request, string $id): Response
{
$result = MakeupSessionService::complete((int) $id);
if (!$result['success']) {
return $this->redirect('/sa/makeup-sessions')->withError($result['error']);
}
return $this->redirect('/sa/makeup-sessions')->withSuccess('تم تأكيد حضور الحصة التعويضية');
}
public function cancel(Request $request, string $id): Response
{
$result = MakeupSessionService::cancel((int) $id);
if (!$result['success']) {
return $this->redirect('/sa/makeup-sessions')->withError($result['error']);
}
return $this->redirect('/sa/makeup-sessions')->withSuccess('تم إلغاء الحصة التعويضية');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\SportsActivity\Services\PlayerLifecycleService;
class PlayerLifecycleController extends Controller
{
public function pause(Request $request, string $pid, string $gid): Response
{
$month = trim((string) $request->post('month', ''));
$reason = trim((string) $request->post('reason', ''));
if ($month === '') {
return $this->redirect("/sa/players/{$pid}")->withError('يجب تحديد الشهر');
}
$result = PlayerLifecycleService::pause((int) $gid, (int) $pid, $month, $reason);
if (!$result['success']) {
return $this->redirect("/sa/players/{$pid}")->withError($result['error']);
}
return $this->redirect("/sa/players/{$pid}")->withSuccess("تم إيقاف الاشتراك لشهر {$month}");
}
public function resume(Request $request, string $pid, string $gid): Response
{
$month = trim((string) $request->post('month', ''));
if ($month === '') {
return $this->redirect("/sa/players/{$pid}")->withError('يجب تحديد الشهر');
}
$result = PlayerLifecycleService::resume((int) $gid, (int) $pid, $month);
if (!$result['success']) {
return $this->redirect("/sa/players/{$pid}")->withError($result['error']);
}
return $this->redirect("/sa/players/{$pid}")->withSuccess("تم استئناف الاشتراك لشهر {$month}");
}
public function extendGrace(Request $request, string $pid, string $gid): Response
{
$days = (int) $request->post('extra_days', 14);
$enrollment = \App\Core\App::getInstance()->db()->selectOne(
"SELECT id FROM sa_group_players WHERE group_id = ? AND player_id = ? AND status IN ('active','pending_payment')",
[(int) $gid, (int) $pid]
);
if (!$enrollment) {
return $this->redirect("/sa/players/{$pid}")->withError('التسجيل غير موجود');
}
$result = PlayerLifecycleService::extendMedicalGrace((int) $enrollment['id'], $days);
if (!$result['success']) {
return $this->redirect("/sa/players/{$pid}")->withError($result['error']);
}
return $this->redirect("/sa/players/{$pid}")
->withSuccess("تم تمديد مهلة الشهادة الطبية حتى {$result['new_deadline']}");
}
}
...@@ -8,6 +8,7 @@ use App\Core\Request; ...@@ -8,6 +8,7 @@ use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Modules\SportsActivity\Services\ScheduleGeneratorService; use App\Modules\SportsActivity\Services\ScheduleGeneratorService;
use App\Modules\SportsActivity\Services\ScheduleCopyService;
class ScheduleController extends Controller class ScheduleController extends Controller
{ {
...@@ -189,4 +190,43 @@ class ScheduleController extends Controller ...@@ -189,4 +190,43 @@ class ScheduleController extends Controller
return $this->redirect('/sa/schedule/daily/' . ($fromDate ?: date('Y-m-d')))->withError($result['error']); return $this->redirect('/sa/schedule/daily/' . ($fromDate ?: date('Y-m-d')))->withError($result['error']);
} }
public function copySchedule(Request $request): Response
{
$sourceGroupId = (int) $request->post('source_group_id', 0);
$targetGroupId = (int) $request->post('target_group_id', 0);
if (!$sourceGroupId || !$targetGroupId) {
return $this->redirect('/sa/schedule')->withError('يجب اختيار المجموعة المصدر والمستهدفة');
}
$result = ScheduleCopyService::copyGroupSchedule($sourceGroupId, $targetGroupId);
if (!$result['success']) {
return $this->redirect('/sa/schedule')->withError($result['error']);
}
return $this->redirect('/sa/schedule')
->withSuccess("تم نسخ {$result['copied']} حصة من الجدول بنجاح");
}
public function shiftTime(Request $request): Response
{
$groupId = (int) $request->post('group_id', 0);
$minutes = (int) $request->post('minutes_delta', 0);
if (!$groupId || !$minutes) {
return $this->redirect('/sa/schedule')->withError('يجب تحديد المجموعة وعدد الدقائق');
}
$result = ScheduleCopyService::shiftScheduleTime($groupId, $minutes);
if (!$result['success']) {
return $this->redirect('/sa/schedule')->withError($result['error']);
}
$direction = $minutes > 0 ? 'تقديم' : 'تأخير';
return $this->redirect('/sa/schedule')
->withSuccess("تم {$direction} مواعيد المجموعة بـ " . abs($minutes) . " دقيقة");
}
} }
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\SportsActivity\Services\TrainingAttendanceService;
class TrainingAttendanceController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$date = trim((string) $request->get('date', date('Y-m-d')));
$disciplineId = trim((string) $request->get('discipline_id', ''));
$where = "g.status = 'active' AND g.is_archived = 0";
$params = [];
if ($disciplineId !== '') {
$where .= " AND p.discipline_id = ?";
$params[] = (int) $disciplineId;
}
$dayOfWeek = (int) date('w', strtotime($date));
$groups = $db->select(
"SELECT DISTINCT g.id, g.name_ar, g.code, p.name_ar as program_name,
d.name_ar as discipline_name, c.full_name_ar as coach_name,
gs.start_time, gs.end_time, fu.name_ar as unit_name,
g.current_count,
(SELECT COUNT(*) FROM sa_training_attendance ta
WHERE ta.group_id = g.id AND ta.session_date = ?) as attendance_recorded
FROM sa_groups g
JOIN sa_programs p ON p.id = g.program_id
JOIN sa_disciplines d ON d.id = p.discipline_id
LEFT JOIN sa_coaches c ON c.id = g.coach_id
JOIN sa_group_schedule gs ON gs.group_id = g.id AND gs.day_of_week = ? AND gs.is_active = 1
LEFT JOIN sa_facility_units fu ON fu.id = gs.facility_unit_id
WHERE {$where}
ORDER BY gs.start_time ASC, g.name_ar ASC",
array_merge([$date, $dayOfWeek], $params)
);
$disciplines = $db->select(
"SELECT id, name_ar FROM sa_disciplines WHERE is_archived = 0 AND is_active = 1 ORDER BY name_ar"
);
return $this->view('SportsActivity.Views.training-attendance.index', [
'groups' => $groups,
'date' => $date,
'disciplines' => $disciplines,
'filters' => ['discipline_id' => $disciplineId],
]);
}
public function record(Request $request, string $groupId): Response
{
$db = App::getInstance()->db();
$date = trim((string) $request->get('date', date('Y-m-d')));
$group = $db->selectOne(
"SELECT g.*, p.name_ar as program_name, d.name_ar as discipline_name,
c.full_name_ar as coach_name
FROM sa_groups g
JOIN sa_programs p ON p.id = g.program_id
JOIN sa_disciplines d ON d.id = p.discipline_id
LEFT JOIN sa_coaches c ON c.id = g.coach_id
WHERE g.id = ?",
[(int) $groupId]
);
if (!$group) {
return $this->redirect('/sa/training-attendance')->withError('المجموعة غير موجودة');
}
$sessions = TrainingAttendanceService::getGroupSessionsForDate((int) $groupId, $date);
$players = TrainingAttendanceService::getActivePlayers((int) $groupId);
$existingAttendance = TrainingAttendanceService::getExistingAttendance((int) $groupId, $date);
return $this->view('SportsActivity.Views.training-attendance.record', [
'group' => $group,
'date' => $date,
'sessions' => $sessions,
'players' => $players,
'existingAttendance' => $existingAttendance,
]);
}
public function store(Request $request, string $groupId): Response
{
$date = trim((string) $request->post('session_date', date('Y-m-d')));
$playerIds = $request->post('player_ids', []);
$statuses = $request->post('statuses', []);
if (!is_array($playerIds) || !is_array($statuses)) {
return $this->redirect("/sa/training-attendance/record/{$groupId}?date={$date}")
->withError('بيانات غير صالحة');
}
$attendanceData = [];
foreach ($playerIds as $i => $pid) {
$attendanceData[] = [
'player_id' => (int) $pid,
'status' => $statuses[$i] ?? 'absent',
];
}
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$result = TrainingAttendanceService::recordBulk((int) $groupId, $date, $attendanceData, $employeeId);
if (!$result['success']) {
return $this->redirect("/sa/training-attendance/record/{$groupId}?date={$date}")
->withError($result['error']);
}
return $this->redirect("/sa/training-attendance?date={$date}")
->withSuccess("تم تسجيل حضور {$result['recorded']} لاعب بنجاح");
}
public function report(Request $request): Response
{
$db = App::getInstance()->db();
$groupId = trim((string) $request->get('group_id', ''));
$playerId = trim((string) $request->get('player_id', ''));
$dateFrom = trim((string) $request->get('date_from', date('Y-m-01')));
$dateTo = trim((string) $request->get('date_to', date('Y-m-d')));
$records = [];
$summary = null;
$hasFilter = $groupId !== '' || $playerId !== '';
if ($hasFilter) {
$where = "ta.session_date BETWEEN ? AND ?";
$params = [$dateFrom, $dateTo];
if ($groupId !== '') {
$where .= " AND ta.group_id = ?";
$params[] = (int) $groupId;
}
if ($playerId !== '') {
$where .= " AND ta.player_id = ?";
$params[] = (int) $playerId;
}
$records = $db->select(
"SELECT ta.*, sp.full_name_ar as player_name, g.name_ar as group_name
FROM sa_training_attendance ta
JOIN sa_players sp ON sp.id = ta.player_id
JOIN sa_groups g ON g.id = ta.group_id
WHERE {$where}
ORDER BY ta.session_date DESC, sp.full_name_ar ASC
LIMIT 500",
$params
);
$summary = $db->selectOne(
"SELECT
COUNT(*) as total,
SUM(CASE WHEN ta.status = 'present' THEN 1 ELSE 0 END) as present_count,
SUM(CASE WHEN ta.status = 'absent' THEN 1 ELSE 0 END) as absent_count,
SUM(CASE WHEN ta.status = 'excused' THEN 1 ELSE 0 END) as excused_count,
SUM(CASE WHEN ta.status = 'late' THEN 1 ELSE 0 END) as late_count,
SUM(CASE WHEN ta.status = 'makeup' THEN 1 ELSE 0 END) as makeup_count
FROM sa_training_attendance ta WHERE {$where}",
$params
);
}
$groups = $db->select(
"SELECT id, name_ar FROM sa_groups WHERE status = 'active' AND is_archived = 0 ORDER BY name_ar"
);
return $this->view('SportsActivity.Views.training-attendance.report', [
'records' => $records,
'summary' => $summary,
'groups' => $groups,
'filters' => [
'group_id' => $groupId,
'player_id' => $playerId,
'date_from' => $dateFrom,
'date_to' => $dateTo,
],
]);
}
}
...@@ -15,7 +15,7 @@ class Discipline extends Model ...@@ -15,7 +15,7 @@ class Discipline extends Model
protected static bool $autoTrackAuthor = true; protected static bool $autoTrackAuthor = true;
protected static array $fillable = [ protected static array $fillable = [
'code', 'name_ar', 'name_en', 'category', 'icon', 'code', 'name_ar', 'name_en', 'category', 'sport_type', 'icon',
'description_ar', 'config_json', 'sort_order', 'is_active', 'description_ar', 'config_json', 'sort_order', 'is_active',
]; ];
......
...@@ -110,6 +110,8 @@ return [ ...@@ -110,6 +110,8 @@ return [
['GET', '/sa/schedule/daily/{date}', 'SportsActivity\Controllers\ScheduleController@daily', ['auth'], 'sa.schedule.view'], ['GET', '/sa/schedule/daily/{date}', 'SportsActivity\Controllers\ScheduleController@daily', ['auth'], 'sa.schedule.view'],
['GET', '/sa/schedule/weekly', 'SportsActivity\Controllers\ScheduleController@weekly', ['auth'], 'sa.schedule.view'], ['GET', '/sa/schedule/weekly', 'SportsActivity\Controllers\ScheduleController@weekly', ['auth'], 'sa.schedule.view'],
['POST', '/sa/schedule/generate', 'SportsActivity\Controllers\ScheduleController@generate', ['auth', 'csrf'], 'sa.schedule.manage'], ['POST', '/sa/schedule/generate', 'SportsActivity\Controllers\ScheduleController@generate', ['auth', 'csrf'], 'sa.schedule.manage'],
['POST', '/sa/schedule/copy', 'SportsActivity\Controllers\ScheduleController@copySchedule', ['auth', 'csrf'], 'sa.schedule.manage'],
['POST', '/sa/schedule/shift-time', 'SportsActivity\Controllers\ScheduleController@shiftTime', ['auth', 'csrf'], 'sa.schedule.manage'],
// Recreational Games // Recreational Games
['GET', '/sa/games', 'SportsActivity\Controllers\GameController@index', ['auth'], 'sa.game.view'], ['GET', '/sa/games', 'SportsActivity\Controllers\GameController@index', ['auth'], 'sa.game.view'],
...@@ -327,4 +329,33 @@ return [ ...@@ -327,4 +329,33 @@ return [
['GET', '/sa/academy-pricing/lane-rentals', 'SportsActivity\Controllers\AcademyPricingController@laneRentals', ['auth'], 'sa.pricing.view'], ['GET', '/sa/academy-pricing/lane-rentals', 'SportsActivity\Controllers\AcademyPricingController@laneRentals', ['auth'], 'sa.pricing.view'],
['GET', '/sa/academy-pricing/facility-rentals', 'SportsActivity\Controllers\AcademyPricingController@facilityRentals', ['auth'], 'sa.pricing.view'], ['GET', '/sa/academy-pricing/facility-rentals', 'SportsActivity\Controllers\AcademyPricingController@facilityRentals', ['auth'], 'sa.pricing.view'],
['POST', '/sa/academy-pricing/calculate', 'SportsActivity\Controllers\AcademyPricingController@calculate', ['auth', 'csrf'], 'sa.pricing.view'], ['POST', '/sa/academy-pricing/calculate', 'SportsActivity\Controllers\AcademyPricingController@calculate', ['auth', 'csrf'], 'sa.pricing.view'],
// ─── Training Attendance (group-session based) ──────────────────────────────
['GET', '/sa/training-attendance', 'SportsActivity\Controllers\TrainingAttendanceController@index', ['auth'], 'sa.attendance.view'],
['GET', '/sa/training-attendance/record/{groupId:\d+}', 'SportsActivity\Controllers\TrainingAttendanceController@record', ['auth'], 'sa.attendance.manage'],
['POST', '/sa/training-attendance/record/{groupId:\d+}', 'SportsActivity\Controllers\TrainingAttendanceController@store', ['auth', 'csrf'], 'sa.attendance.manage'],
['GET', '/sa/training-attendance/report', 'SportsActivity\Controllers\TrainingAttendanceController@report', ['auth'], 'sa.attendance.view'],
// ─── Make-up Sessions ───────────────────────────────────────────────────────
['GET', '/sa/makeup-sessions', 'SportsActivity\Controllers\MakeupSessionController@index', ['auth'], 'sa.makeup.view'],
['GET', '/sa/makeup-sessions/create', 'SportsActivity\Controllers\MakeupSessionController@create', ['auth'], 'sa.makeup.manage'],
['POST', '/sa/makeup-sessions', 'SportsActivity\Controllers\MakeupSessionController@store', ['auth', 'csrf'], 'sa.makeup.manage'],
['GET', '/sa/makeup-sessions/{id:\d+}/schedule', 'SportsActivity\Controllers\MakeupSessionController@schedule', ['auth'], 'sa.makeup.manage'],
['POST', '/sa/makeup-sessions/{id:\d+}/schedule', 'SportsActivity\Controllers\MakeupSessionController@confirmSchedule', ['auth', 'csrf'], 'sa.makeup.manage'],
['POST', '/sa/makeup-sessions/{id:\d+}/complete', 'SportsActivity\Controllers\MakeupSessionController@complete', ['auth', 'csrf'], 'sa.makeup.manage'],
['POST', '/sa/makeup-sessions/{id:\d+}/cancel', 'SportsActivity\Controllers\MakeupSessionController@cancel', ['auth', 'csrf'], 'sa.makeup.manage'],
// ─── Institutions ───────────────────────────────────────────────────────────
['GET', '/sa/institutions', 'SportsActivity\Controllers\InstitutionController@index', ['auth'], 'sa.institution.view'],
['GET', '/sa/institutions/create', 'SportsActivity\Controllers\InstitutionController@create', ['auth'], 'sa.institution.manage'],
['POST', '/sa/institutions', 'SportsActivity\Controllers\InstitutionController@store', ['auth', 'csrf'], 'sa.institution.manage'],
['GET', '/sa/institutions/{id:\d+}', 'SportsActivity\Controllers\InstitutionController@show', ['auth'], 'sa.institution.view'],
['GET', '/sa/institutions/{id:\d+}/edit', 'SportsActivity\Controllers\InstitutionController@edit', ['auth'], 'sa.institution.manage'],
['POST', '/sa/institutions/{id:\d+}', 'SportsActivity\Controllers\InstitutionController@update', ['auth', 'csrf'], 'sa.institution.manage'],
['POST', '/sa/institutions/{id:\d+}/archive', 'SportsActivity\Controllers\InstitutionController@archive', ['auth', 'csrf'], 'sa.institution.manage'],
// ─── Player Lifecycle (Pause/Resume) ────────────────────────────────────────
['POST', '/sa/players/{pid:\d+}/groups/{gid:\d+}/pause', 'SportsActivity\Controllers\PlayerLifecycleController@pause', ['auth', 'csrf'], 'sa.enrollment.manage'],
['POST', '/sa/players/{pid:\d+}/groups/{gid:\d+}/resume', 'SportsActivity\Controllers\PlayerLifecycleController@resume', ['auth', 'csrf'], 'sa.enrollment.manage'],
['POST', '/sa/players/{pid:\d+}/groups/{gid:\d+}/extend-grace', 'SportsActivity\Controllers\PlayerLifecycleController@extendGrace', ['auth', 'csrf'], 'sa.enrollment.manage'],
]; ];
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\SportsActivity\SaConstants;
final class MakeupSessionService
{
public static function createFromAbsence(int $playerId, int $groupId, string $missedDate, string $reason = ''): array
{
$db = App::getInstance()->db();
$attendance = $db->selectOne(
"SELECT id FROM sa_training_attendance
WHERE group_id = ? AND player_id = ? AND session_date = ? AND status IN ('absent','excused')",
[$groupId, $playerId, $missedDate]
);
if (!$attendance) {
return ['success' => false, 'error' => 'لا يوجد سجل غياب لهذا التاريخ'];
}
$existing = $db->selectOne(
"SELECT id FROM sa_makeup_sessions
WHERE player_id = ? AND original_group_id = ? AND missed_session_date = ? AND status NOT IN ('cancelled','expired')",
[$playerId, $groupId, $missedDate]
);
if ($existing) {
return ['success' => false, 'error' => 'يوجد بالفعل حصة تعويضية لهذا الغياب'];
}
$expiryDaysRow = $db->selectOne(
"SELECT config_value FROM system_config WHERE config_key = 'sa.makeup_expiry_days'",
[]
);
$expiryDays = (int) ($expiryDaysRow['config_value'] ?? 30);
$expiresAt = date('Y-m-d', strtotime($missedDate . " +{$expiryDays} days"));
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$id = $db->insert('sa_makeup_sessions', [
'player_id' => $playerId,
'original_group_id' => $groupId,
'missed_session_date' => $missedDate,
'missed_reason' => $reason ?: null,
'status' => 'eligible',
'expires_at' => $expiresAt,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employeeId,
]);
return ['success' => true, 'makeup_id' => $id, 'expires_at' => $expiresAt];
}
public static function schedule(int $makeupId, int $makeupGroupId, string $makeupDate): array
{
$db = App::getInstance()->db();
$makeup = $db->selectOne(
"SELECT * FROM sa_makeup_sessions WHERE id = ? AND status = 'eligible'",
[$makeupId]
);
if (!$makeup) {
return ['success' => false, 'error' => 'الحصة التعويضية غير متاحة للجدولة'];
}
if ($makeupDate > $makeup['expires_at']) {
return ['success' => false, 'error' => 'تاريخ التعويض بعد تاريخ انتهاء الصلاحية'];
}
$group = $db->selectOne(
"SELECT id, current_count, max_capacity FROM sa_groups WHERE id = ? AND status = ? AND is_archived = 0",
[$makeupGroupId, SaConstants::GROUP_ACTIVE]
);
if (!$group) {
return ['success' => false, 'error' => 'المجموعة المستهدفة غير موجودة أو غير نشطة'];
}
$dayOfWeek = (int) date('w', strtotime($makeupDate));
$hasSession = $db->selectOne(
"SELECT id FROM sa_group_schedule WHERE group_id = ? AND day_of_week = ? AND is_active = 1",
[$makeupGroupId, $dayOfWeek]
);
if (!$hasSession) {
return ['success' => false, 'error' => 'لا يوجد تمرين للمجموعة في هذا اليوم'];
}
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$db->update('sa_makeup_sessions', [
'makeup_group_id' => $makeupGroupId,
'makeup_date' => $makeupDate,
'status' => 'scheduled',
'scheduled_by' => $employeeId,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$makeupId]);
return ['success' => true];
}
public static function complete(int $makeupId): array
{
$db = App::getInstance()->db();
$makeup = $db->selectOne(
"SELECT * FROM sa_makeup_sessions WHERE id = ? AND status = 'scheduled'",
[$makeupId]
);
if (!$makeup) {
return ['success' => false, 'error' => 'الحصة التعويضية غير مجدولة'];
}
$db->update('sa_makeup_sessions', [
'status' => 'completed',
'completed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$makeupId]);
$db->insert('sa_training_attendance', [
'group_id' => (int) $makeup['makeup_group_id'],
'player_id' => (int) $makeup['player_id'],
'session_date' => $makeup['makeup_date'],
'day_of_week' => (int) date('w', strtotime($makeup['makeup_date'])),
'status' => 'makeup',
'makeup_session_id' => $makeupId,
'recorded_by' => (int) (App::getInstance()->session()->get('employee_id') ?? 0),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
return ['success' => true];
}
public static function cancel(int $makeupId): array
{
$db = App::getInstance()->db();
$makeup = $db->selectOne(
"SELECT id FROM sa_makeup_sessions WHERE id = ? AND status IN ('eligible','scheduled')",
[$makeupId]
);
if (!$makeup) {
return ['success' => false, 'error' => 'الحصة التعويضية غير قابلة للإلغاء'];
}
$db->update('sa_makeup_sessions', [
'status' => 'cancelled',
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$makeupId]);
return ['success' => true];
}
public static function expireOverdue(): array
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
$result = $db->query(
"UPDATE sa_makeup_sessions SET status = 'expired', updated_at = NOW()
WHERE status IN ('eligible','scheduled') AND expires_at < ?",
[$today]
);
return ['success' => true, 'expired_count' => $result->rowCount()];
}
public static function getPlayerMakeups(int $playerId, ?string $status = null): array
{
$db = App::getInstance()->db();
$where = "ms.player_id = ?";
$params = [$playerId];
if ($status) {
$where .= " AND ms.status = ?";
$params[] = $status;
}
return $db->select(
"SELECT ms.*, g1.name_ar as original_group_name, g2.name_ar as makeup_group_name
FROM sa_makeup_sessions ms
LEFT JOIN sa_groups g1 ON g1.id = ms.original_group_id
LEFT JOIN sa_groups g2 ON g2.id = ms.makeup_group_id
WHERE {$where}
ORDER BY ms.created_at DESC",
$params
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\SportsActivity\SaConstants;
final class PlayerLifecycleService
{
public static function pause(int $groupId, int $playerId, string $month, string $reason = ''): array
{
$db = App::getInstance()->db();
$enrollment = $db->selectOne(
"SELECT id, paused_months FROM sa_group_players WHERE group_id = ? AND player_id = ? AND status = ?",
[$groupId, $playerId, SaConstants::STATUS_ACTIVE]
);
if (!$enrollment) {
return ['success' => false, 'error' => 'اللاعب غير مسجل أو غير نشط في هذه المجموعة'];
}
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
return ['success' => false, 'error' => 'صيغة الشهر غير صحيحة (YYYY-MM)'];
}
$pausedMonths = json_decode($enrollment['paused_months'] ?? '[]', true) ?: [];
if (in_array($month, $pausedMonths, true)) {
return ['success' => false, 'error' => 'الشهر متوقف بالفعل'];
}
$maxPauses = self::getConfig('sa.max_pause_months', 3);
if (count($pausedMonths) >= $maxPauses) {
return ['success' => false, 'error' => "تم تجاوز الحد الأقصى لعدد شهور الإيقاف ({$maxPauses} شهور)"];
}
$pausedMonths[] = $month;
sort($pausedMonths);
$db->update('sa_group_players', [
'paused_months' => json_encode($pausedMonths),
'notes' => $reason ? ($enrollment['notes'] ?? '') . "\nإيقاف {$month}: {$reason}" : null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $enrollment['id']]);
$existingSub = $db->selectOne(
"SELECT id, payment_status FROM sa_subscriptions WHERE player_id = ? AND group_id = ? AND period_start = ?",
[$playerId, $groupId, $month . '-01']
);
if ($existingSub && $existingSub['payment_status'] === SaConstants::PAYMENT_UNPAID) {
$db->update('sa_subscriptions', [
'exemption_reason' => 'إيقاف مؤقت',
'final_amount' => '0.00',
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $existingSub['id']]);
}
EventBus::dispatch('sa.player.paused', [
'player_id' => $playerId,
'group_id' => $groupId,
'month' => $month,
'reason' => $reason,
]);
return ['success' => true, 'paused_months' => $pausedMonths];
}
public static function resume(int $groupId, int $playerId, string $month): array
{
$db = App::getInstance()->db();
$enrollment = $db->selectOne(
"SELECT id, paused_months FROM sa_group_players WHERE group_id = ? AND player_id = ? AND status = ?",
[$groupId, $playerId, SaConstants::STATUS_ACTIVE]
);
if (!$enrollment) {
return ['success' => false, 'error' => 'اللاعب غير مسجل أو غير نشط في هذه المجموعة'];
}
$pausedMonths = json_decode($enrollment['paused_months'] ?? '[]', true) ?: [];
$key = array_search($month, $pausedMonths, true);
if ($key === false) {
return ['success' => false, 'error' => 'هذا الشهر غير متوقف'];
}
array_splice($pausedMonths, $key, 1);
$db->update('sa_group_players', [
'paused_months' => !empty($pausedMonths) ? json_encode($pausedMonths) : null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $enrollment['id']]);
EventBus::dispatch('sa.player.resumed', [
'player_id' => $playerId,
'group_id' => $groupId,
'month' => $month,
]);
return ['success' => true, 'paused_months' => $pausedMonths];
}
public static function checkMedicalGraceExpired(): array
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
$expired = $db->select(
"SELECT gp.id, gp.player_id, gp.group_id, sp.full_name_ar, g.name_ar as group_name
FROM sa_group_players gp
JOIN sa_players sp ON sp.id = gp.player_id
JOIN sa_groups g ON g.id = gp.group_id
WHERE gp.status = ? AND gp.medical_grace_deadline IS NOT NULL AND gp.medical_grace_deadline < ?
AND sp.medical_status NOT IN (?, ?)",
[
SaConstants::STATUS_ACTIVE,
$today,
SaConstants::MEDICAL_FIT,
SaConstants::MEDICAL_CONDITIONAL,
]
);
$suspended = 0;
foreach ($expired as $row) {
$db->update('sa_group_players', [
'status' => SaConstants::STATUS_WITHDRAWN,
'left_at' => $today,
'notes' => 'إيقاف تلقائي — انتهاء مهلة الشهادة الطبية',
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $row['id']]);
$group = $db->selectOne(
"SELECT current_count FROM sa_groups WHERE id = ?",
[(int) $row['group_id']]
);
$newCount = max(0, (int) ($group['current_count'] ?? 0) - 1);
$db->update('sa_groups', [
'current_count' => $newCount,
'is_full' => 0,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $row['group_id']]);
EventBus::dispatch('sa.medical_grace.expired', [
'player_id' => (int) $row['player_id'],
'group_id' => (int) $row['group_id'],
'player_name' => $row['full_name_ar'],
]);
$suspended++;
}
return ['success' => true, 'suspended' => $suspended];
}
public static function extendMedicalGrace(int $enrollmentId, int $extraDays): array
{
$db = App::getInstance()->db();
$enrollment = $db->selectOne(
"SELECT * FROM sa_group_players WHERE id = ? AND status IN (?, ?)",
[$enrollmentId, SaConstants::STATUS_ACTIVE, SaConstants::STATUS_PENDING_PAYMENT]
);
if (!$enrollment) {
return ['success' => false, 'error' => 'التسجيل غير موجود'];
}
$baseDate = !empty($enrollment['medical_grace_deadline'])
? max($enrollment['medical_grace_deadline'], date('Y-m-d'))
: date('Y-m-d');
$newDeadline = date('Y-m-d', strtotime($baseDate . " +{$extraDays} days"));
$db->update('sa_group_players', [
'medical_grace_deadline' => $newDeadline,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$enrollmentId]);
return ['success' => true, 'new_deadline' => $newDeadline];
}
private static function getConfig(string $key, $default = null)
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT config_value FROM system_config WHERE config_key = ?",
[$key]
);
return $row ? $row['config_value'] : $default;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
final class ScheduleCopyService
{
public static function copyGroupSchedule(int $sourceGroupId, int $targetGroupId): array
{
$db = App::getInstance()->db();
$source = $db->selectOne(
"SELECT id, name_ar FROM sa_groups WHERE id = ? AND is_archived = 0",
[$sourceGroupId]
);
if (!$source) {
return ['success' => false, 'error' => 'المجموعة المصدر غير موجودة'];
}
$target = $db->selectOne(
"SELECT id, name_ar FROM sa_groups WHERE id = ? AND is_archived = 0",
[$targetGroupId]
);
if (!$target) {
return ['success' => false, 'error' => 'المجموعة المستهدفة غير موجودة'];
}
$schedules = $db->select(
"SELECT * FROM sa_group_schedule WHERE group_id = ? AND is_active = 1",
[$sourceGroupId]
);
if (empty($schedules)) {
return ['success' => false, 'error' => 'المجموعة المصدر ليس لها جدول'];
}
$db->beginTransaction();
try {
$db->query(
"UPDATE sa_group_schedule SET is_active = 0, updated_at = NOW() WHERE group_id = ?",
[$targetGroupId]
);
$copied = 0;
$now = date('Y-m-d H:i:s');
foreach ($schedules as $sch) {
$db->insert('sa_group_schedule', [
'group_id' => $targetGroupId,
'facility_unit_id' => (int) $sch['facility_unit_id'],
'day_of_week' => (int) $sch['day_of_week'],
'start_time' => $sch['start_time'],
'end_time' => $sch['end_time'],
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
$copied++;
}
$db->commit();
return ['success' => true, 'copied' => $copied];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل نسخ الجدول: ' . $e->getMessage()];
}
}
public static function copyProgramScheduleToAllGroups(int $programId): array
{
$db = App::getInstance()->db();
$groups = $db->select(
"SELECT id FROM sa_groups WHERE program_id = ? AND status = 'active' AND is_archived = 0",
[$programId]
);
if (count($groups) < 2) {
return ['success' => false, 'error' => 'يجب وجود مجموعتين على الأقل في البرنامج'];
}
$templateGroup = $db->selectOne(
"SELECT g.id FROM sa_groups g
INNER JOIN sa_group_schedule gs ON gs.group_id = g.id AND gs.is_active = 1
WHERE g.program_id = ? AND g.status = 'active' AND g.is_archived = 0
GROUP BY g.id
ORDER BY COUNT(gs.id) DESC
LIMIT 1",
[$programId]
);
if (!$templateGroup) {
return ['success' => false, 'error' => 'لا توجد مجموعة بها جدول يمكن نسخه'];
}
$sourceId = (int) $templateGroup['id'];
$totalCopied = 0;
$errors = [];
foreach ($groups as $g) {
if ((int) $g['id'] === $sourceId) {
continue;
}
$result = self::copyGroupSchedule($sourceId, (int) $g['id']);
if ($result['success']) {
$totalCopied += $result['copied'];
} else {
$errors[] = "مجموعة #{$g['id']}: {$result['error']}";
}
}
return [
'success' => true,
'source_id' => $sourceId,
'total_copied' => $totalCopied,
'groups_updated' => count($groups) - 1,
'errors' => $errors,
];
}
public static function shiftScheduleTime(int $groupId, int $minutesDelta): array
{
$db = App::getInstance()->db();
$schedules = $db->select(
"SELECT id, start_time, end_time FROM sa_group_schedule WHERE group_id = ? AND is_active = 1",
[$groupId]
);
if (empty($schedules)) {
return ['success' => false, 'error' => 'لا يوجد جدول لهذه المجموعة'];
}
$db->beginTransaction();
try {
foreach ($schedules as $sch) {
$newStart = date('H:i:s', strtotime($sch['start_time']) + ($minutesDelta * 60));
$newEnd = date('H:i:s', strtotime($sch['end_time']) + ($minutesDelta * 60));
$db->update('sa_group_schedule', [
'start_time' => $newStart,
'end_time' => $newEnd,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $sch['id']]);
}
$db->commit();
return ['success' => true, 'shifted' => count($schedules), 'delta_minutes' => $minutesDelta];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل تعديل المواعيد: ' . $e->getMessage()];
}
}
}
...@@ -18,6 +18,7 @@ final class SubscriptionGeneratorService ...@@ -18,6 +18,7 @@ final class SubscriptionGeneratorService
$groupPlayers = $db->select( $groupPlayers = $db->select(
"SELECT gp.player_id, gp.group_id, gp.enrolled_at, gp.paused_months, "SELECT gp.player_id, gp.group_id, gp.enrolled_at, gp.paused_months,
sp.player_type, sp.full_name_ar as player_name, sp.player_type, sp.full_name_ar as player_name,
sp.medical_status, sp.medical_expiry_date,
p.monthly_fee_member, p.monthly_fee_nonmember, g.name_ar as group_name p.monthly_fee_member, p.monthly_fee_nonmember, g.name_ar as group_name
FROM sa_group_players gp FROM sa_group_players gp
JOIN sa_players sp ON sp.id = gp.player_id JOIN sa_players sp ON sp.id = gp.player_id
...@@ -39,6 +40,7 @@ final class SubscriptionGeneratorService ...@@ -39,6 +40,7 @@ final class SubscriptionGeneratorService
$generated = 0; $generated = 0;
$skipped = 0; $skipped = 0;
$medicalBlocked = 0;
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0); $employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
foreach ($groupPlayers as $gp) { foreach ($groupPlayers as $gp) {
...@@ -54,6 +56,8 @@ final class SubscriptionGeneratorService ...@@ -54,6 +56,8 @@ final class SubscriptionGeneratorService
continue; continue;
} }
$medicalVerified = self::checkMedicalForRenewal($gp);
$amount = $gp['player_type'] === SaConstants::PLAYER_MEMBER $amount = $gp['player_type'] === SaConstants::PLAYER_MEMBER
? (float) $gp['monthly_fee_member'] ? (float) $gp['monthly_fee_member']
: (float) $gp['monthly_fee_nonmember']; : (float) $gp['monthly_fee_nonmember'];
...@@ -84,19 +88,36 @@ final class SubscriptionGeneratorService ...@@ -84,19 +88,36 @@ final class SubscriptionGeneratorService
'final_amount' => $amount, 'final_amount' => $amount,
'payment_status' => SaConstants::PAYMENT_UNPAID, 'payment_status' => SaConstants::PAYMENT_UNPAID,
'paid_amount' => 0.00, 'paid_amount' => 0.00,
'auto_generated' => 1,
'medical_verified' => $medicalVerified ? 1 : 0,
'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 (!$medicalVerified) {
$medicalBlocked++;
}
$generated++; $generated++;
} }
return [ return [
'success' => true, 'success' => true,
'generated' => $generated, 'generated' => $generated,
'skipped' => $skipped, 'skipped' => $skipped,
'month' => $yearMonth, 'medical_blocked' => $medicalBlocked,
'month' => $yearMonth,
]; ];
} }
private static function checkMedicalForRenewal(array $gp): bool
{
if (in_array($gp['medical_status'], [SaConstants::MEDICAL_FIT, SaConstants::MEDICAL_CONDITIONAL], true)) {
if (!empty($gp['medical_expiry_date']) && $gp['medical_expiry_date'] < date('Y-m-d')) {
return false;
}
return true;
}
return false;
}
} }
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\SportsActivity\SaConstants;
final class TrainingAttendanceService
{
public static function getGroupSessionsForDate(int $groupId, string $date): array
{
$db = App::getInstance()->db();
$dayOfWeek = (int) date('w', strtotime($date));
return $db->select(
"SELECT gs.*, fu.name_ar as unit_name, f.name_ar as facility_name
FROM sa_group_schedule gs
JOIN sa_facility_units fu ON fu.id = gs.facility_unit_id
JOIN sa_facilities f ON f.id = fu.facility_id
WHERE gs.group_id = ? AND gs.day_of_week = ? AND gs.is_active = 1",
[$groupId, $dayOfWeek]
);
}
public static function getActivePlayers(int $groupId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT gp.id as enrollment_id, gp.player_id, gp.medical_grace_deadline, gp.paused_months,
sp.full_name_ar, sp.registration_serial, sp.medical_status, sp.medical_expiry_date,
sp.photo_path, sp.player_type
FROM sa_group_players gp
JOIN sa_players sp ON sp.id = gp.player_id
WHERE gp.group_id = ? AND gp.status = ?
ORDER BY sp.full_name_ar ASC",
[$groupId, SaConstants::STATUS_ACTIVE]
);
}
public static function getExistingAttendance(int $groupId, string $date): array
{
$db = App::getInstance()->db();
$rows = $db->select(
"SELECT * FROM sa_training_attendance WHERE group_id = ? AND session_date = ?",
[$groupId, $date]
);
$map = [];
foreach ($rows as $row) {
$map[(int) $row['player_id']] = $row;
}
return $map;
}
public static function recordBulk(int $groupId, string $date, array $attendanceData, int $recordedBy): array
{
$db = App::getInstance()->db();
$dayOfWeek = (int) date('w', strtotime($date));
$schedule = $db->selectOne(
"SELECT id FROM sa_group_schedule WHERE group_id = ? AND day_of_week = ? AND is_active = 1 LIMIT 1",
[$groupId, $dayOfWeek]
);
$db->beginTransaction();
try {
$db->query(
"DELETE FROM sa_training_attendance WHERE group_id = ? AND session_date = ?",
[$groupId, $date]
);
$recorded = 0;
$absentees = [];
$now = date('Y-m-d H:i:s');
foreach ($attendanceData as $entry) {
$playerId = (int) ($entry['player_id'] ?? 0);
$status = $entry['status'] ?? 'absent';
if (!in_array($status, ['present', 'absent', 'excused', 'late', 'makeup'], true)) {
$status = 'absent';
}
$db->insert('sa_training_attendance', [
'group_id' => $groupId,
'player_id' => $playerId,
'session_date' => $date,
'day_of_week' => $dayOfWeek,
'schedule_id' => $schedule ? (int) $schedule['id'] : null,
'status' => $status,
'check_in_time' => $entry['check_in_time'] ?? null,
'check_out_time' => $entry['check_out_time'] ?? null,
'excuse_reason' => $status === 'excused' ? ($entry['excuse_reason'] ?? null) : null,
'recorded_by' => $recordedBy,
'created_at' => $now,
'updated_at' => $now,
]);
if ($status === 'absent') {
$absentees[] = $playerId;
}
$recorded++;
}
$db->commit();
if (!empty($absentees)) {
self::checkAbsenceThreshold($groupId, $absentees, $date);
}
return ['success' => true, 'recorded' => $recorded];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل تسجيل الحضور: ' . $e->getMessage()];
}
}
public static function getPlayerAttendanceSummary(int $playerId, int $groupId, ?string $from = null, ?string $to = null): array
{
$db = App::getInstance()->db();
$where = "group_id = ? AND player_id = ?";
$params = [$groupId, $playerId];
if ($from) {
$where .= " AND session_date >= ?";
$params[] = $from;
}
if ($to) {
$where .= " AND session_date <= ?";
$params[] = $to;
}
$row = $db->selectOne(
"SELECT
COUNT(*) as total_sessions,
SUM(CASE WHEN status = 'present' THEN 1 ELSE 0 END) as present_count,
SUM(CASE WHEN status = 'absent' THEN 1 ELSE 0 END) as absent_count,
SUM(CASE WHEN status = 'excused' THEN 1 ELSE 0 END) as excused_count,
SUM(CASE WHEN status = 'late' THEN 1 ELSE 0 END) as late_count,
SUM(CASE WHEN status = 'makeup' THEN 1 ELSE 0 END) as makeup_count
FROM sa_training_attendance WHERE {$where}",
$params
);
$total = (int) ($row['total_sessions'] ?? 0);
$present = (int) ($row['present_count'] ?? 0) + (int) ($row['late_count'] ?? 0) + (int) ($row['makeup_count'] ?? 0);
return [
'total' => $total,
'present' => (int) ($row['present_count'] ?? 0),
'absent' => (int) ($row['absent_count'] ?? 0),
'excused' => (int) ($row['excused_count'] ?? 0),
'late' => (int) ($row['late_count'] ?? 0),
'makeup' => (int) ($row['makeup_count'] ?? 0),
'attendance_rate' => $total > 0 ? round(($present / $total) * 100, 1) : 0.0,
];
}
private static function checkAbsenceThreshold(int $groupId, array $playerIds, string $date): void
{
$db = App::getInstance()->db();
$thresholdRow = $db->selectOne(
"SELECT config_value FROM system_config WHERE config_key = 'sa.absence_threshold'",
[]
);
$threshold = (int) ($thresholdRow['config_value'] ?? 5);
$monthStart = date('Y-m-01', strtotime($date));
$monthEnd = date('Y-m-t', strtotime($date));
foreach ($playerIds as $playerId) {
$absentCount = $db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_training_attendance
WHERE group_id = ? AND player_id = ? AND status = 'absent'
AND session_date BETWEEN ? AND ?",
[$groupId, $playerId, $monthStart, $monthEnd]
);
if ((int) ($absentCount['cnt'] ?? 0) >= $threshold) {
EventBus::dispatch('sa.absence_threshold.reached', [
'player_id' => $playerId,
'group_id' => $groupId,
'count' => (int) $absentCount['cnt'],
'month' => date('Y-m', strtotime($date)),
]);
}
}
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= $mode === 'edit' ? 'تعديل مؤسسة' : 'إضافة مؤسسة جديدة' ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/institutions" class="btn btn-outline"><i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> العودة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="padding:20px;max-width:700px;">
<form method="POST" action="<?= $mode === 'edit' ? '/sa/institutions/' . (int)$institution['id'] : '/sa/institutions' ?>">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:15px;">
<div>
<label class="form-label">الكود <span style="color:#DC2626;">*</span></label>
<input type="text" name="code" class="form-control" value="<?= e($institution['code'] ?? old('code', '')) ?>" required maxlength="30">
</div>
<div>
<label class="form-label">النوع <span style="color:#DC2626;">*</span></label>
<select name="institution_type" class="form-select" required>
<?php
$types = ['school'=>'مدرسة','university'=>'جامعة','company'=>'شركة','club'=>'نادي','government'=>'حكومي','other'=>'أخرى'];
$current = $institution['institution_type'] ?? 'school';
foreach ($types as $val => $label): ?>
<option value="<?= $val ?>" <?= $current === $val ? 'selected' : '' ?>><?= $label ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:15px;">
<div>
<label class="form-label">الاسم بالعربي <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_ar" class="form-control" value="<?= e($institution['name_ar'] ?? old('name_ar', '')) ?>" required>
</div>
<div>
<label class="form-label">الاسم بالإنجليزي</label>
<input type="text" name="name_en" class="form-control" value="<?= e($institution['name_en'] ?? old('name_en', '')) ?>">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:15px;">
<div>
<label class="form-label">المسؤول</label>
<input type="text" name="contact_person" class="form-control" value="<?= e($institution['contact_person'] ?? '') ?>">
</div>
<div>
<label class="form-label">الهاتف</label>
<input type="text" name="phone" class="form-control" value="<?= e($institution['phone'] ?? '') ?>">
</div>
</div>
<div style="margin-bottom:15px;">
<label class="form-label">البريد الإلكتروني</label>
<input type="email" name="email" class="form-control" value="<?= e($institution['email'] ?? '') ?>">
</div>
<div style="margin-bottom:15px;">
<label class="form-label">العنوان</label>
<textarea name="address" class="form-control" rows="2"><?= e($institution['address'] ?? '') ?></textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:15px;">
<div>
<label class="form-label">بداية العقد</label>
<input type="date" name="contract_start" class="form-control" value="<?= e($institution['contract_start'] ?? '') ?>">
</div>
<div>
<label class="form-label">نهاية العقد</label>
<input type="date" name="contract_end" class="form-control" value="<?= e($institution['contract_end'] ?? '') ?>">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;margin-bottom:15px;">
<div>
<label class="form-label">سعر الساعة</label>
<input type="number" step="0.01" name="hourly_rate" class="form-control" value="<?= e($institution['hourly_rate'] ?? '') ?>">
</div>
<div>
<label class="form-label">نسبة الخصم %</label>
<input type="number" step="0.01" name="discount_percent" class="form-control" value="<?= e($institution['discount_percent'] ?? '0') ?>">
</div>
<div>
<label class="form-label">الحد الأقصى</label>
<input type="number" name="max_participants" class="form-control" value="<?= e($institution['max_participants'] ?? '') ?>" placeholder="عدد المشاركين">
</div>
</div>
<div style="margin-bottom:15px;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-control" rows="3"><?= e($institution['notes'] ?? '') ?></textarea>
</div>
<div style="margin-bottom:20px;">
<label style="display:flex;align-items:center;gap:8px;">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" <?= (int)($institution['is_active'] ?? 1) ? 'checked' : '' ?>>
<span>نشط</span>
</label>
</div>
<button type="submit" class="btn btn-primary" style="padding:10px 30px;">
<i data-lucide="save" style="width:16px;height:16px;vertical-align:middle;margin-left:6px;"></i>
<?= $mode === 'edit' ? 'حفظ التعديلات' : 'إضافة المؤسسة' ?>
</button>
</form>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>المؤسسات<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<?php if (can('sa.institution.manage')): ?>
<a href="/sa/institutions/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إضافة مؤسسة</a>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;padding:12px 15px;">
<form method="GET" action="/sa/institutions" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:200px;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="search" class="form-control" value="<?= e($filters['search'] ?? '') ?>" placeholder="اسم أو كود...">
</div>
<div style="min-width:140px;">
<label class="form-label" style="font-size:12px;">النوع</label>
<select name="type" class="form-select">
<option value="">-- الكل --</option>
<option value="school" <?= ($filters['type'] ?? '') === 'school' ? 'selected' : '' ?>>مدرسة</option>
<option value="university" <?= ($filters['type'] ?? '') === 'university' ? 'selected' : '' ?>>جامعة</option>
<option value="company" <?= ($filters['type'] ?? '') === 'company' ? 'selected' : '' ?>>شركة</option>
<option value="club" <?= ($filters['type'] ?? '') === 'club' ? 'selected' : '' ?>>نادي</option>
<option value="government" <?= ($filters['type'] ?? '') === 'government' ? 'selected' : '' ?>>حكومي</option>
<option value="other" <?= ($filters['type'] ?? '') === 'other' ? 'selected' : '' ?>>أخرى</option>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:14px;height:14px;"></i> بحث</button>
</form>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>الكود</th>
<th>الاسم</th>
<th>النوع</th>
<th>المسؤول</th>
<th>الهاتف</th>
<th>عدد الحجوزات</th>
<th>الحالة</th>
<th>إجراءات</th>
</tr>
</thead>
<tbody>
<?php if (!empty($institutions)): ?>
<?php foreach ($institutions as $inst): ?>
<?php
$typeMap = ['school'=>'مدرسة','university'=>'جامعة','company'=>'شركة','club'=>'نادي','government'=>'حكومي','other'=>'أخرى'];
?>
<tr>
<td style="font-family:monospace;font-size:12px;"><?= e($inst['code']) ?></td>
<td style="font-weight:500;"><a href="/sa/institutions/<?= (int)$inst['id'] ?>"><?= e($inst['name_ar']) ?></a></td>
<td><span style="background:#EFF6FF;color:#2563EB;padding:2px 8px;border-radius:8px;font-size:11px;"><?= e($typeMap[$inst['institution_type']] ?? $inst['institution_type']) ?></span></td>
<td><?= e($inst['contact_person'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;"><?= e($inst['phone'] ?? '—') ?></td>
<td style="text-align:center;"><?= (int)($inst['booking_count'] ?? 0) ?></td>
<td>
<?php if ((int)$inst['is_active']): ?>
<span style="background:#ECFDF5;color:#059669;padding:2px 8px;border-radius:8px;font-size:11px;">نشط</span>
<?php else: ?>
<span style="background:#F3F4F6;color:#6B7280;padding:2px 8px;border-radius:8px;font-size:11px;">معطل</span>
<?php endif; ?>
</td>
<td style="white-space:nowrap;">
<a href="/sa/institutions/<?= (int)$inst['id'] ?>" class="btn btn-sm btn-outline" style="font-size:11px;">عرض</a>
<?php if (can('sa.institution.manage')): ?>
<a href="/sa/institutions/<?= (int)$inst['id'] ?>/edit" class="btn btn-sm btn-outline" style="font-size:11px;">تعديل</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="8" style="text-align:center;padding:40px;color:#6B7280;">لا توجد مؤسسات مسجلة</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= e($institution['name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<?php if (can('sa.institution.manage')): ?>
<a href="/sa/institutions/<?= (int)$institution['id'] ?>/edit" class="btn btn-outline"><i data-lucide="edit" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> تعديل</a>
<form method="POST" action="/sa/institutions/<?= (int)$institution['id'] ?>/archive" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-outline" style="color:#DC2626;" onclick="return confirm('هل تريد أرشفة هذه المؤسسة؟')"><i data-lucide="archive" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> أرشفة</button>
</form>
<?php endif; ?>
<a href="/sa/institutions" class="btn btn-outline"><i data-lucide="arrow-right" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> العودة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php $typeMap = ['school'=>'مدرسة','university'=>'جامعة','company'=>'شركة','club'=>'نادي','government'=>'حكومي','other'=>'أخرى']; ?>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-bottom:15px;">
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:22px;font-weight:700;"><?= (int)($stats['total_bookings'] ?? 0) ?></div>
<div style="font-size:12px;color:#6B7280;">إجمالي الحجوزات</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#059669;"><?= (int)($stats['completed'] ?? 0) ?></div>
<div style="font-size:12px;color:#6B7280;">مكتملة</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#2563EB;"><?= (int)($stats['confirmed'] ?? 0) ?></div>
<div style="font-size:12px;color:#6B7280;">مؤكدة</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#D97706;"><?= money((float)($stats['total_revenue'] ?? 0)) ?></div>
<div style="font-size:12px;color:#6B7280;">إجمالي الإيرادات</div>
</div>
</div>
<div class="card" style="padding:20px;margin-bottom:15px;">
<h3 style="margin-bottom:15px;border-bottom:1px solid #E5E7EB;padding-bottom:10px;">بيانات المؤسسة</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:14px;">
<div><strong>الكود:</strong> <?= e($institution['code']) ?></div>
<div><strong>النوع:</strong> <?= e($typeMap[$institution['institution_type']] ?? '') ?></div>
<div><strong>المسؤول:</strong> <?= e($institution['contact_person'] ?? '—') ?></div>
<div><strong>الهاتف:</strong> <?= e($institution['phone'] ?? '—') ?></div>
<div><strong>البريد:</strong> <?= e($institution['email'] ?? '—') ?></div>
<div><strong>بداية العقد:</strong> <?= e($institution['contract_start'] ?? '—') ?></div>
<div><strong>نهاية العقد:</strong> <?= e($institution['contract_end'] ?? '—') ?></div>
<div><strong>سعر الساعة:</strong> <?= $institution['hourly_rate'] ? money((float)$institution['hourly_rate']) : '—' ?></div>
<div><strong>نسبة الخصم:</strong> <?= e($institution['discount_percent'] ?? 0) ?>%</div>
<div><strong>الحد الأقصى:</strong> <?= e($institution['max_participants'] ?? '—') ?></div>
</div>
<?php if (!empty($institution['address'])): ?>
<div style="margin-top:10px;"><strong>العنوان:</strong> <?= e($institution['address']) ?></div>
<?php endif; ?>
<?php if (!empty($institution['notes'])): ?>
<div style="margin-top:10px;"><strong>ملاحظات:</strong> <?= e($institution['notes']) ?></div>
<?php endif; ?>
</div>
<div class="card">
<h3 style="padding:15px 20px;border-bottom:1px solid #E5E7EB;margin:0;">آخر الحجوزات</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>رقم الحجز</th>
<th>التاريخ</th>
<th>المرفق</th>
<th>الوقت</th>
<th>المبلغ</th>
<th>الحالة</th>
</tr>
</thead>
<tbody>
<?php if (!empty($bookings)): ?>
<?php foreach ($bookings as $b): ?>
<tr>
<td style="font-family:monospace;font-size:12px;"><?= e($b['booking_number']) ?></td>
<td><?= e($b['booking_date']) ?></td>
<td><?= e($b['unit_name'] ?? '') ?></td>
<td><?= e($b['start_time'] ?? '') ?> - <?= e($b['end_time'] ?? '') ?></td>
<td><?= money((float)($b['total_amount'] ?? 0)) ?></td>
<td>
<?php
$bStatus = match($b['status'] ?? '') {
'confirmed' => '<span style="background:#EFF6FF;color:#2563EB;padding:2px 8px;border-radius:8px;font-size:11px;">مؤكد</span>',
'completed' => '<span style="background:#ECFDF5;color:#059669;padding:2px 8px;border-radius:8px;font-size:11px;">مكتمل</span>',
'cancelled' => '<span style="background:#FEF2F2;color:#DC2626;padding:2px 8px;border-radius:8px;font-size:11px;">ملغي</span>',
default => '<span style="background:#F3F4F6;color:#6B7280;padding:2px 8px;border-radius:8px;font-size:11px;">' . e($b['status'] ?? '') . '</span>',
};
echo $bStatus;
?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="6" style="text-align:center;padding:30px;color:#6B7280;">لا توجد حجوزات</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إنشاء حصة تعويضية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/makeup-sessions" class="btn btn-outline"><i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> العودة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="padding:20px;margin-bottom:15px;">
<form method="GET" action="/sa/makeup-sessions/create" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:180px;">
<label class="form-label" style="font-size:12px;">المجموعة</label>
<select name="group_id" class="form-select" data-searchable="true" required>
<option value="">-- اختر مجموعة --</option>
<?php foreach ($groups as $g): ?>
<option value="<?= (int)$g['id'] ?>" <?= ($filters['group_id'] ?? '') == $g['id'] ? 'selected' : '' ?>><?= e($g['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:180px;">
<label class="form-label" style="font-size:12px;">رقم اللاعب (ID)</label>
<input type="number" name="player_id" class="form-control" value="<?= e($filters['player_id'] ?? '') ?>" placeholder="أدخل رقم اللاعب">
</div>
<button type="submit" class="btn btn-outline">بحث عن الغياب</button>
</form>
</div>
<?php if ($player && !empty($absences)): ?>
<div class="card" style="padding:20px;">
<h3 style="margin-bottom:15px;">غيابات <?= e($player['full_name_ar'] ?? '') ?> بدون حصة تعويضية</h3>
<form method="POST" action="/sa/makeup-sessions">
<?= csrf_field() ?>
<input type="hidden" name="player_id" value="<?= (int)($filters['player_id'] ?? 0) ?>">
<input type="hidden" name="group_id" value="<?= (int)($filters['group_id'] ?? 0) ?>">
<div class="table-responsive" style="margin-bottom:15px;">
<table class="data-table">
<thead><tr><th>اختر</th><th>التاريخ</th><th>الحالة</th></tr></thead>
<tbody>
<?php foreach ($absences as $a): ?>
<tr>
<td><input type="radio" name="missed_session_date" value="<?= e($a['session_date']) ?>" required></td>
<td><?= e($a['session_date']) ?></td>
<td><?= $a['status'] === 'excused' ? 'عذر' : 'غياب' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div style="margin-bottom:15px;">
<label class="form-label">سبب (اختياري)</label>
<input type="text" name="reason" class="form-control" placeholder="سبب الغياب أو ملاحظات">
</div>
<button type="submit" class="btn btn-primary">إنشاء حصة تعويضية</button>
</form>
</div>
<?php elseif ($player && empty($absences)): ?>
<div class="card" style="padding:30px;text-align:center;color:#6B7280;">
لا توجد غيابات بدون حصة تعويضية لهذا اللاعب في المجموعة المحددة
</div>
<?php endif; ?>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الحصص التعويضية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<?php if (can('sa.makeup.manage')): ?>
<a href="/sa/makeup-sessions/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> حصة تعويضية جديدة</a>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;padding:12px 15px;">
<form method="GET" action="/sa/makeup-sessions" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:140px;">
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-select">
<option value="">-- الكل --</option>
<option value="eligible" <?= ($filters['status'] ?? '') === 'eligible' ? 'selected' : '' ?>>متاحة</option>
<option value="scheduled" <?= ($filters['status'] ?? '') === 'scheduled' ? 'selected' : '' ?>>مجدولة</option>
<option value="completed" <?= ($filters['status'] ?? '') === 'completed' ? 'selected' : '' ?>>مكتملة</option>
<option value="expired" <?= ($filters['status'] ?? '') === 'expired' ? 'selected' : '' ?>>منتهية</option>
<option value="cancelled" <?= ($filters['status'] ?? '') === 'cancelled' ? 'selected' : '' ?>>ملغاة</option>
</select>
</div>
<div style="min-width:180px;">
<label class="form-label" style="font-size:12px;">المجموعة الأصلية</label>
<select name="group_id" class="form-select" data-searchable="true">
<option value="">-- الكل --</option>
<?php foreach ($groups as $g): ?>
<option value="<?= (int)$g['id'] ?>" <?= ($filters['group_id'] ?? '') == $g['id'] ? 'selected' : '' ?>><?= e($g['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="filter" style="width:14px;height:14px;"></i> تصفية</button>
</form>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>اللاعب</th>
<th>المجموعة الأصلية</th>
<th>تاريخ الغياب</th>
<th>مجموعة التعويض</th>
<th>تاريخ التعويض</th>
<th>ينتهي في</th>
<th>الحالة</th>
<th>إجراءات</th>
</tr>
</thead>
<tbody>
<?php if (!empty($makeups)): ?>
<?php foreach ($makeups as $m): ?>
<?php
$statusBadge = match($m['status']) {
'eligible' => '<span style="background:#EFF6FF;color:#2563EB;padding:2px 8px;border-radius:8px;font-size:11px;">متاحة</span>',
'scheduled' => '<span style="background:#FEF3C7;color:#D97706;padding:2px 8px;border-radius:8px;font-size:11px;">مجدولة</span>',
'completed' => '<span style="background:#ECFDF5;color:#059669;padding:2px 8px;border-radius:8px;font-size:11px;">مكتملة</span>',
'expired' => '<span style="background:#FEF2F2;color:#DC2626;padding:2px 8px;border-radius:8px;font-size:11px;">منتهية</span>',
'cancelled' => '<span style="background:#F3F4F6;color:#6B7280;padding:2px 8px;border-radius:8px;font-size:11px;">ملغاة</span>',
default => e($m['status']),
};
?>
<tr>
<td style="font-weight:500;"><?= e($m['player_name'] ?? '') ?></td>
<td><?= e($m['original_group_name'] ?? '') ?></td>
<td><?= e($m['missed_session_date']) ?></td>
<td><?= e($m['makeup_group_name'] ?? '—') ?></td>
<td><?= e($m['makeup_date'] ?? '—') ?></td>
<td style="font-size:12px;"><?= e($m['expires_at']) ?></td>
<td><?= $statusBadge ?></td>
<td style="white-space:nowrap;">
<?php if ($m['status'] === 'eligible' && can('sa.makeup.manage')): ?>
<a href="/sa/makeup-sessions/<?= (int)$m['id'] ?>/schedule" class="btn btn-sm btn-outline" style="font-size:11px;">جدولة</a>
<form method="POST" action="/sa/makeup-sessions/<?= (int)$m['id'] ?>/cancel" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm" style="font-size:11px;color:#DC2626;" onclick="return confirm('هل أنت متأكد؟')">إلغاء</button>
</form>
<?php elseif ($m['status'] === 'scheduled' && can('sa.makeup.manage')): ?>
<form method="POST" action="/sa/makeup-sessions/<?= (int)$m['id'] ?>/complete" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-primary" style="font-size:11px;">تأكيد الحضور</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="8" style="text-align:center;padding:40px;color:#6B7280;">لا توجد حصص تعويضية</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>جدولة حصة تعويضية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/makeup-sessions" class="btn btn-outline"><i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> العودة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="padding:15px;margin-bottom:15px;background:#EFF6FF;border-right:4px solid #2563EB;">
<div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px;">
<div>
<strong>اللاعب:</strong> <?= e($makeup['player_name'] ?? '') ?>
<span style="margin-right:15px;"><strong>المجموعة الأصلية:</strong> <?= e($makeup['group_name'] ?? '') ?></span>
</div>
<div style="font-size:13px;color:#4B5563;">
<strong>تاريخ الغياب:</strong> <?= e($makeup['missed_session_date']) ?>
| <strong>ينتهي في:</strong> <?= e($makeup['expires_at']) ?>
</div>
</div>
</div>
<div class="card" style="padding:20px;">
<form method="POST" action="/sa/makeup-sessions/<?= (int)$makeup['id'] ?>/schedule">
<?= csrf_field() ?>
<div style="margin-bottom:15px;">
<label class="form-label">المجموعة المستهدفة للتعويض</label>
<select name="makeup_group_id" class="form-select" data-searchable="true" required>
<option value="">-- اختر مجموعة --</option>
<?php foreach ($availableGroups as $g): ?>
<option value="<?= (int)$g['id'] ?>"><?= e($g['name_ar']) ?> (<?= e($g['coach_name'] ?? '') ?>)</option>
<?php endforeach; ?>
</select>
<small style="color:#6B7280;">يجب أن تكون في نفس النشاط الرياضي</small>
</div>
<div style="margin-bottom:15px;">
<label class="form-label">تاريخ التعويض</label>
<input type="date" name="makeup_date" class="form-control" required
min="<?= date('Y-m-d') ?>" max="<?= e($makeup['expires_at']) ?>">
<small style="color:#6B7280;">يجب أن يكون يوم تدريب المجموعة المختارة وقبل <?= e($makeup['expires_at']) ?></small>
</div>
<button type="submit" class="btn btn-primary">تأكيد الجدولة</button>
</form>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>حضور التدريب<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/training-attendance/report" class="btn btn-outline"><i data-lucide="bar-chart-2" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> التقارير</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;padding:12px 15px;">
<form method="GET" action="/sa/training-attendance" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:160px;">
<label class="form-label" style="font-size:12px;">التاريخ</label>
<input type="date" name="date" class="form-control" value="<?= e($date) ?>">
</div>
<div style="min-width:160px;">
<label class="form-label" style="font-size:12px;">النشاط</label>
<select name="discipline_id" class="form-select">
<option value="">-- الكل --</option>
<?php foreach ($disciplines as $d): ?>
<option value="<?= (int) $d['id'] ?>" <?= ($filters['discipline_id'] ?? '') == $d['id'] ? 'selected' : '' ?>><?= e($d['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="filter" style="width:14px;height:14px;vertical-align:middle;"></i> عرض</button>
</form>
</div>
<div class="card" style="margin-bottom:15px;padding:15px;background:#F0FDF4;border-right:4px solid #059669;">
<strong>مجموعات التدريب ليوم:</strong> <?= e($date) ?>
<span style="color:#6B7280;margin-right:10px;">(<?= ['الأحد','الإثنين','الثلاثاء','الأربعاء','الخميس','الجمعة','السبت'][(int)date('w', strtotime($date))] ?>)</span>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>الوقت</th>
<th>المجموعة</th>
<th>النشاط</th>
<th>المدرب</th>
<th>المرفق</th>
<th>اللاعبين</th>
<th>الحضور</th>
<th>إجراءات</th>
</tr>
</thead>
<tbody>
<?php if (!empty($groups)): ?>
<?php foreach ($groups as $g): ?>
<tr>
<td style="font-weight:600;white-space:nowrap;"><?= e($g['start_time'] ?? '') ?> - <?= e($g['end_time'] ?? '') ?></td>
<td><?= e($g['name_ar'] ?? '') ?></td>
<td><span style="background:#EFF6FF;color:#1D4ED8;padding:2px 8px;border-radius:8px;font-size:11px;"><?= e($g['discipline_name'] ?? '') ?></span></td>
<td><?= e($g['coach_name'] ?? '—') ?></td>
<td><?= e($g['unit_name'] ?? '—') ?></td>
<td style="text-align:center;"><?= (int) ($g['current_count'] ?? 0) ?></td>
<td style="text-align:center;">
<?php if ((int)($g['attendance_recorded'] ?? 0) > 0): ?>
<span style="background:#ECFDF5;color:#059669;padding:3px 10px;border-radius:10px;font-size:12px;">✓ مسجل (<?= (int)$g['attendance_recorded'] ?>)</span>
<?php else: ?>
<span style="background:#FEF3C7;color:#92400E;padding:3px 10px;border-radius:10px;font-size:12px;">لم يسجل</span>
<?php endif; ?>
</td>
<td>
<?php if (can('sa.attendance.manage')): ?>
<a href="/sa/training-attendance/record/<?= (int) $g['id'] ?>?date=<?= e($date) ?>" class="btn btn-sm btn-primary" style="font-size:12px;padding:4px 12px;">
<i data-lucide="clipboard-check" style="width:13px;height:13px;vertical-align:middle;margin-left:4px;"></i> تسجيل
</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="8" style="text-align:center;padding:40px;color:#6B7280;">
<i data-lucide="calendar-x" style="width:36px;height:36px;color:#D1D5DB;display:block;margin:0 auto 10px;"></i>
لا توجد مجموعات تدريب في هذا اليوم
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تسجيل حضور: <?= e($group['name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/training-attendance?date=<?= e($date) ?>" class="btn btn-outline"><i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> العودة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;padding:15px;background:#EFF6FF;border-right:4px solid #2563EB;">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
<div>
<strong><?= e($group['name_ar']) ?></strong>
<span style="color:#6B7280;margin-right:8px;"><?= e($group['discipline_name'] ?? '') ?><?= e($group['coach_name'] ?? '') ?></span>
</div>
<div style="font-size:13px;color:#4B5563;">
<strong>التاريخ:</strong> <?= e($date) ?>
<?php if (!empty($sessions)): ?>
| <strong>الوقت:</strong> <?= e($sessions[0]['start_time'] ?? '') ?> - <?= e($sessions[0]['end_time'] ?? '') ?>
| <strong>المرفق:</strong> <?= e($sessions[0]['unit_name'] ?? '') ?>
<?php endif; ?>
</div>
</div>
</div>
<?php if (empty($players)): ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">
<i data-lucide="users" style="width:36px;height:36px;color:#D1D5DB;display:block;margin:0 auto 10px;"></i>
لا يوجد لاعبين مسجلين في هذه المجموعة
</div>
<?php else: ?>
<form method="POST" action="/sa/training-attendance/record/<?= (int) $group['id'] ?>">
<?= csrf_field() ?>
<input type="hidden" name="session_date" value="<?= e($date) ?>">
<div class="card" style="margin-bottom:15px;padding:10px 15px;display:flex;gap:15px;align-items:center;flex-wrap:wrap;">
<button type="button" onclick="setAll('present')" class="btn btn-sm" style="background:#ECFDF5;color:#059669;border:1px solid #059669;">تحضير الكل</button>
<button type="button" onclick="setAll('absent')" class="btn btn-sm" style="background:#FEF2F2;color:#DC2626;border:1px solid #DC2626;">غياب الكل</button>
<span style="color:#6B7280;font-size:13px;">عدد اللاعبين: <strong><?= count($players) ?></strong></span>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th style="width:40px;">#</th>
<th>اللاعب</th>
<th>الكود</th>
<th>الحالة الطبية</th>
<th style="min-width:180px;">الحضور</th>
</tr>
</thead>
<tbody>
<?php foreach ($players as $i => $p): ?>
<?php
$existing = $existingAttendance[(int) $p['player_id']] ?? null;
$currentStatus = $existing ? $existing['status'] : 'present';
$medicalBadge = match($p['medical_status'] ?? 'pending') {
'fit' => '<span style="background:#ECFDF5;color:#059669;padding:2px 8px;border-radius:8px;font-size:11px;">لائق</span>',
'conditional' => '<span style="background:#FEF3C7;color:#92400E;padding:2px 8px;border-radius:8px;font-size:11px;">مشروط</span>',
'expired' => '<span style="background:#FEF2F2;color:#DC2626;padding:2px 8px;border-radius:8px;font-size:11px;">منتهي</span>',
default => '<span style="background:#F3F4F6;color:#6B7280;padding:2px 8px;border-radius:8px;font-size:11px;">بانتظار</span>',
};
?>
<tr>
<td><?= $i + 1 ?></td>
<td style="font-weight:500;"><?= e($p['full_name_ar']) ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e($p['registration_serial'] ?? '—') ?></td>
<td><?= $medicalBadge ?></td>
<td>
<input type="hidden" name="player_ids[]" value="<?= (int) $p['player_id'] ?>">
<div style="display:flex;gap:4px;">
<?php
$options = [
'present' => ['label' => 'حاضر', 'color' => '#059669', 'bg' => '#ECFDF5'],
'absent' => ['label' => 'غائب', 'color' => '#DC2626', 'bg' => '#FEF2F2'],
'late' => ['label' => 'متأخر', 'color' => '#D97706', 'bg' => '#FEF3C7'],
'excused' => ['label' => 'عذر', 'color' => '#6B7280', 'bg' => '#F3F4F6'],
];
foreach ($options as $val => $opt): ?>
<label style="cursor:pointer;">
<input type="radio" name="statuses[<?= $i ?>]" value="<?= $val ?>" <?= $currentStatus === $val ? 'checked' : '' ?> class="status-radio" data-index="<?= $i ?>" style="display:none;">
<span class="att-btn att-btn-<?= $val ?>" style="display:inline-block;padding:4px 10px;border-radius:8px;font-size:12px;border:2px solid transparent;transition:all 0.15s;"><?= $opt['label'] ?></span>
</label>
<?php endforeach; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<div style="margin-top:15px;display:flex;justify-content:end;">
<button type="submit" class="btn btn-primary" style="padding:10px 30px;font-size:15px;">
<i data-lucide="save" style="width:16px;height:16px;vertical-align:middle;margin-left:6px;"></i> حفظ الحضور
</button>
</div>
</form>
<?php endif; ?>
<style>
.att-btn { background:#F9FAFB; color:#6B7280; }
.att-btn-present { background:#ECFDF5; color:#059669; }
.att-btn-absent { background:#FEF2F2; color:#DC2626; }
.att-btn-late { background:#FEF3C7; color:#D97706; }
.att-btn-excused { background:#F3F4F6; color:#6B7280; }
input.status-radio:checked + .att-btn { border-color:currentColor; font-weight:600; box-shadow:0 0 0 1px currentColor; }
</style>
<script>
function setAll(status) {
document.querySelectorAll('.status-radio[value="' + status + '"]').forEach(r => { r.checked = true; });
}
document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تقرير حضور التدريب<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/training-attendance" class="btn btn-outline"><i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> العودة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;padding:12px 15px;">
<form method="GET" action="/sa/training-attendance/report" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:180px;">
<label class="form-label" style="font-size:12px;">المجموعة</label>
<select name="group_id" class="form-select" data-searchable="true" data-placeholder="-- اختر مجموعة --">
<option value="">-- الكل --</option>
<?php foreach ($groups as $g): ?>
<option value="<?= (int)$g['id'] ?>" <?= ($filters['group_id'] ?? '') == $g['id'] ? 'selected' : '' ?>><?= e($g['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:120px;">
<label class="form-label" style="font-size:12px;">من</label>
<input type="date" name="date_from" class="form-control" value="<?= e($filters['date_from'] ?? '') ?>">
</div>
<div style="min-width:120px;">
<label class="form-label" style="font-size:12px;">إلى</label>
<input type="date" name="date_to" class="form-control" value="<?= e($filters['date_to'] ?? '') ?>">
</div>
<button type="submit" class="btn btn-primary"><i data-lucide="search" style="width:14px;height:14px;vertical-align:middle;"></i> بحث</button>
</form>
</div>
<?php if ($summary): ?>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-bottom:15px;">
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#1F2937;"><?= (int)$summary['total'] ?></div>
<div style="font-size:12px;color:#6B7280;">إجمالي السجلات</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#059669;"><?= (int)$summary['present_count'] ?></div>
<div style="font-size:12px;color:#6B7280;">حاضر</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#DC2626;"><?= (int)$summary['absent_count'] ?></div>
<div style="font-size:12px;color:#6B7280;">غائب</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#D97706;"><?= (int)$summary['late_count'] ?></div>
<div style="font-size:12px;color:#6B7280;">متأخر</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#6B7280;"><?= (int)$summary['excused_count'] ?></div>
<div style="font-size:12px;color:#6B7280;">عذر</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#7C3AED;"><?= (int)$summary['makeup_count'] ?></div>
<div style="font-size:12px;color:#6B7280;">تعويضي</div>
</div>
</div>
<?php endif; ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>التاريخ</th>
<th>اللاعب</th>
<th>المجموعة</th>
<th>الحالة</th>
</tr>
</thead>
<tbody>
<?php if (!empty($records)): ?>
<?php foreach ($records as $r): ?>
<tr>
<td><?= e($r['session_date']) ?></td>
<td><?= e($r['player_name'] ?? '') ?></td>
<td><?= e($r['group_name'] ?? '') ?></td>
<td>
<?php
$statusBadge = match($r['status']) {
'present' => '<span style="background:#ECFDF5;color:#059669;padding:2px 8px;border-radius:8px;font-size:11px;">حاضر</span>',
'absent' => '<span style="background:#FEF2F2;color:#DC2626;padding:2px 8px;border-radius:8px;font-size:11px;">غائب</span>',
'late' => '<span style="background:#FEF3C7;color:#D97706;padding:2px 8px;border-radius:8px;font-size:11px;">متأخر</span>',
'excused' => '<span style="background:#F3F4F6;color:#6B7280;padding:2px 8px;border-radius:8px;font-size:11px;">عذر</span>',
'makeup' => '<span style="background:#EDE9FE;color:#7C3AED;padding:2px 8px;border-radius:8px;font-size:11px;">تعويضي</span>',
default => '<span style="background:#F3F4F6;color:#6B7280;padding:2px 8px;border-radius:8px;font-size:11px;">' . e($r['status']) . '</span>',
};
echo $statusBadge;
?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="4" style="text-align:center;padding:40px;color:#6B7280;">اختر مجموعة أو لاعب لعرض التقرير</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
...@@ -37,6 +37,9 @@ MenuRegistry::register('sports_activity', [ ...@@ -37,6 +37,9 @@ MenuRegistry::register('sports_activity', [
['label_ar' => 'أسعار الأكاديميات', 'label_en' => 'Academy Pricing', 'route' => '/sa/academy-pricing','permission' => 'sa.pricing.view', 'order' => 16.5], ['label_ar' => 'أسعار الأكاديميات', 'label_en' => 'Academy Pricing', 'route' => '/sa/academy-pricing','permission' => 'sa.pricing.view', 'order' => 16.5],
['label_ar' => 'الاشتراكات', 'label_en' => 'Subscriptions', 'route' => '/sa/subscriptions', 'permission' => 'sa.subscription.view', 'order' => 17], ['label_ar' => 'الاشتراكات', 'label_en' => 'Subscriptions', 'route' => '/sa/subscriptions', 'permission' => 'sa.subscription.view', 'order' => 17],
['label_ar' => 'الحضور', 'label_en' => 'Attendance', 'route' => '/sa/attendance', 'permission' => 'sa.attendance.view', 'order' => 18], ['label_ar' => 'الحضور', 'label_en' => 'Attendance', 'route' => '/sa/attendance', 'permission' => 'sa.attendance.view', 'order' => 18],
['label_ar' => 'حضور التدريب', 'label_en' => 'Training Attendance','route' => '/sa/training-attendance','permission' => 'sa.attendance.view','order' => 18.5],
['label_ar' => 'الحصص التعويضية', 'label_en' => 'Makeup Sessions', 'route' => '/sa/makeup-sessions','permission' => 'sa.makeup.view', 'order' => 18.7],
['label_ar' => 'المؤسسات', 'label_en' => 'Institutions', 'route' => '/sa/institutions', 'permission' => 'sa.institution.view', 'order' => 18.9],
['label_ar' => 'قائمة الانتظار', 'label_en' => 'Waitlist', 'route' => '/sa/waitlist', 'permission' => 'sa.waitlist.view', 'order' => 19], ['label_ar' => 'قائمة الانتظار', 'label_en' => 'Waitlist', 'route' => '/sa/waitlist', 'permission' => 'sa.waitlist.view', 'order' => 19],
['label_ar' => 'اللوكرات', 'label_en' => 'Lockers', 'route' => '/sa/lockers', 'permission' => 'sa.locker.view', 'order' => 20], ['label_ar' => 'اللوكرات', 'label_en' => 'Lockers', 'route' => '/sa/lockers', 'permission' => 'sa.locker.view', 'order' => 20],
['label_ar' => 'إيجارات اللوكرات', 'label_en' => 'Locker Rentals', 'route' => '/sa/locker-rentals', 'permission' => 'sa.locker_rental.view','order' => 21], ['label_ar' => 'إيجارات اللوكرات', 'label_en' => 'Locker Rentals', 'route' => '/sa/locker-rentals', 'permission' => 'sa.locker_rental.view','order' => 21],
...@@ -111,6 +114,11 @@ PermissionRegistry::register('sports_activity', [ ...@@ -111,6 +114,11 @@ PermissionRegistry::register('sports_activity', [
'sa.swimming.dashboard' => ['ar' => 'لوحة تحكم السباحة', 'en' => 'Swimming Dashboard'], 'sa.swimming.dashboard' => ['ar' => 'لوحة تحكم السباحة', 'en' => 'Swimming Dashboard'],
'sa.swimming.register' => ['ar' => 'تسجيل لاعب سباحة', 'en' => 'Register Swimming Player'], 'sa.swimming.register' => ['ar' => 'تسجيل لاعب سباحة', 'en' => 'Register Swimming Player'],
'sa.swimming.assign' => ['ar' => 'تعيين لاعب في مجموعة سباحة','en' => 'Assign Swimming Player to Group'], 'sa.swimming.assign' => ['ar' => 'تعيين لاعب في مجموعة سباحة','en' => 'Assign Swimming Player to Group'],
'sa.makeup.view' => ['ar' => 'عرض الحصص التعويضية', 'en' => 'View Makeup Sessions'],
'sa.makeup.manage' => ['ar' => 'إدارة الحصص التعويضية', 'en' => 'Manage Makeup Sessions'],
'sa.institution.view' => ['ar' => 'عرض المؤسسات', 'en' => 'View Institutions'],
'sa.institution.manage' => ['ar' => 'إدارة المؤسسات', 'en' => 'Manage Institutions'],
'sa.enrollment.manage' => ['ar' => 'إدارة تسجيلات اللاعبين', 'en' => 'Manage Player Enrollments'],
]); ]);
// ─── Event Listeners ──────────────────────────────────────────────────────── // ─── Event Listeners ────────────────────────────────────────────────────────
......
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\Logger;
use App\Modules\SportsActivity\Services\MakeupSessionService;
class SaMakeupExpiryJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool
{
return true;
}
public function run(): array
{
$result = MakeupSessionService::expireOverdue();
Logger::info("SaMakeupExpiryJob: expired {$result['expired_count']} makeup sessions");
return ['processed' => $result['expired_count']];
}
}
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\Logger;
use App\Modules\SportsActivity\Services\PlayerLifecycleService;
class SaMedicalGraceJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool
{
return true;
}
public function run(): array
{
$result = PlayerLifecycleService::checkMedicalGraceExpired();
Logger::info("SaMedicalGraceJob: suspended {$result['suspended']} players");
return ['processed' => $result['suspended']];
}
}
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS sa_training_attendance (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
group_id BIGINT UNSIGNED NOT NULL,
player_id BIGINT UNSIGNED NOT NULL,
session_date DATE NOT NULL,
day_of_week TINYINT UNSIGNED NOT NULL COMMENT '0=Sun,1=Mon...6=Sat',
schedule_id BIGINT UNSIGNED NULL COMMENT 'ref sa_group_schedule',
status ENUM('present','absent','excused','late','makeup') NOT NULL DEFAULT 'absent',
check_in_time TIME NULL,
check_out_time TIME NULL,
excuse_reason VARCHAR(500) NULL,
makeup_session_id BIGINT UNSIGNED NULL COMMENT 'ref sa_makeup_sessions if status=makeup',
recorded_by BIGINT UNSIGNED 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_ta_group_date (group_id, session_date),
INDEX idx_ta_player_date (player_id, session_date),
INDEX idx_ta_status (status),
UNIQUE KEY uq_ta_player_session (group_id, player_id, session_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS sa_training_attendance"
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS sa_makeup_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
player_id BIGINT UNSIGNED NOT NULL,
original_group_id BIGINT UNSIGNED NOT NULL,
missed_session_date DATE NOT NULL,
missed_reason VARCHAR(500) NULL,
makeup_group_id BIGINT UNSIGNED NULL COMMENT 'group where makeup will be taken',
makeup_date DATE NULL,
status ENUM('eligible','scheduled','completed','expired','cancelled') NOT NULL DEFAULT 'eligible',
expires_at DATE NOT NULL COMMENT 'deadline to use this makeup',
scheduled_by BIGINT UNSIGNED NULL,
completed_at TIMESTAMP NULL,
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by BIGINT UNSIGNED NULL,
INDEX idx_ms_player (player_id),
INDEX idx_ms_status (status),
INDEX idx_ms_expires (expires_at),
INDEX idx_ms_original_group (original_group_id, missed_session_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS sa_makeup_sessions"
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS sa_institutions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(30) NOT NULL,
name_ar VARCHAR(300) NOT NULL,
name_en VARCHAR(300) NULL,
institution_type ENUM('school','university','company','club','government','other') NOT NULL DEFAULT 'school',
contact_person VARCHAR(200) NULL,
phone VARCHAR(30) NULL,
email VARCHAR(200) NULL,
address TEXT NULL,
contract_start DATE NULL,
contract_end DATE NULL,
hourly_rate DECIMAL(10,2) NULL COMMENT 'default rate per hour',
discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
max_participants INT UNSIGNED NULL,
notes TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
is_archived TINYINT(1) NOT NULL DEFAULT 0,
archived_at TIMESTAMP NULL,
archived_by BIGINT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by BIGINT UNSIGNED NULL,
updated_by BIGINT UNSIGNED NULL,
UNIQUE KEY uq_inst_code (code),
INDEX idx_inst_type (institution_type),
INDEX idx_inst_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS sa_institutions"
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE sa_disciplines
ADD COLUMN sport_type ENUM('training','recreational') NOT NULL DEFAULT 'training' AFTER category",
'down' => "
ALTER TABLE sa_disciplines DROP COLUMN sport_type"
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE sa_bookings
ADD COLUMN institution_id BIGINT UNSIGNED NULL AFTER organization_contact,
ADD INDEX idx_bk_institution (institution_id)",
'down' => "
ALTER TABLE sa_bookings
DROP INDEX idx_bk_institution,
DROP COLUMN institution_id"
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE sa_subscriptions
ADD COLUMN auto_generated TINYINT(1) NOT NULL DEFAULT 0 AFTER receipt_number,
ADD COLUMN renewal_of_id BIGINT UNSIGNED NULL AFTER auto_generated,
ADD COLUMN medical_verified TINYINT(1) NOT NULL DEFAULT 0 AFTER renewal_of_id",
'down' => "
ALTER TABLE sa_subscriptions
DROP COLUMN medical_verified,
DROP COLUMN renewal_of_id,
DROP COLUMN auto_generated"
];
This diff is collapsed.
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