Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
C
Clubphp
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
Clubphp
Commits
d28573d7
Commit
d28573d7
authored
Apr 11, 2026
by
Mahmoud Aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
something Updated
parent
085a50cb
Changes
24
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
24 changed files
with
1018 additions
and
565 deletions
+1018
-565
EnrollmentService.php
app/Modules/Academies/Services/EnrollmentService.php
+170
-13
ActivitySubscriptionController.php
...scriptions/Controllers/ActivitySubscriptionController.php
+25
-5
index.php
app/Modules/ActivitySubscriptions/Views/index.php
+34
-0
DisciplineController.php
app/Modules/Disciplines/Controllers/DisciplineController.php
+1
-50
SportsDashboardController.php
...les/Disciplines/Controllers/SportsDashboardController.php
+119
-0
Routes.php
app/Modules/Disciplines/Routes.php
+1
-0
DisciplineService.php
app/Modules/Disciplines/Services/DisciplineService.php
+0
-46
create.php
app/Modules/Disciplines/Views/create.php
+0
-114
edit.php
app/Modules/Disciplines/Views/edit.php
+0
-154
index.php
app/Modules/Disciplines/Views/index.php
+5
-13
show.php
app/Modules/Disciplines/Views/show.php
+1
-83
sports_dashboard.php
app/Modules/Disciplines/Views/sports_dashboard.php
+290
-0
bootstrap.php
app/Modules/Disciplines/bootstrap.php
+2
-0
AttendanceController.php
...odules/PlayerAffairs/Controllers/AttendanceController.php
+136
-0
PlayerController.php
app/Modules/PlayerAffairs/Controllers/PlayerController.php
+12
-2
EventListeners.php
app/Modules/PlayerAffairs/EventListeners.php
+15
-2
Player.php
app/Modules/PlayerAffairs/Models/Player.php
+4
-3
Routes.php
app/Modules/PlayerAffairs/Routes.php
+4
-0
attendance.php
app/Modules/PlayerAffairs/Views/attendance.php
+161
-0
show.php
app/Modules/PlayerAffairs/Views/show.php
+10
-6
ReservationService.php
app/Modules/Reservations/Services/ReservationService.php
+3
-1
ActivitySubGeneratorJob.php
cron/jobs/ActivitySubGeneratorJob.php
+10
-2
Phase_05_003_seed_service_catalog.php
database/seeds/Phase_05_003_seed_service_catalog.php
+1
-1
Phase_17_004_seed_sports_service_catalog.php
database/seeds/Phase_17_004_seed_sports_service_catalog.php
+14
-70
No files found.
app/Modules/Academies/Services/EnrollmentService.php
View file @
d28573d7
...
...
@@ -3,41 +3,198 @@ declare(strict_types=1);
namespace
App\Modules\Academies\Services
;
use
App\Core\App
;
use
App\Core\EventBus
;
use
App\Core\Logger
;
class
EnrollmentService
{
/**
* Enroll a player in an academy level.
* Placeholder — full logic in PlayerAffairs Phase 19.
*/
public
static
function
enroll
(
int
$playerId
,
int
$academyId
,
int
$levelId
,
?
int
$scheduleId
=
null
)
:
bool
public
static
function
enroll
(
int
$playerId
,
int
$academyId
,
int
$levelId
,
?
int
$scheduleId
=
null
,
?
string
$season
=
null
)
:
int
{
throw
new
\RuntimeException
(
'Enrollment features require the PlayerAffairs module.'
);
$db
=
App
::
getInstance
()
->
db
();
$employee
=
App
::
getInstance
()
->
currentEmployee
();
$ts
=
date
(
'Y-m-d H:i:s'
);
// Check for duplicate active enrollment
$existing
=
$db
->
selectOne
(
"SELECT id FROM academy_enrollments WHERE player_id = ? AND academy_id = ? AND status = 'active'"
,
[
$playerId
,
$academyId
]
);
if
(
$existing
)
{
throw
new
\RuntimeException
(
'اللاعب مسجل بالفعل في هذه الأكاديمية'
);
}
$enrollmentId
=
$db
->
insert
(
'academy_enrollments'
,
[
'player_id'
=>
$playerId
,
'academy_id'
=>
$academyId
,
'level_id'
=>
$levelId
,
'schedule_id'
=>
$scheduleId
,
'season'
=>
$season
,
'enrolled_at'
=>
$ts
,
'enrollment_day'
=>
(
int
)
date
(
'j'
),
'status'
=>
'active'
,
'created_by'
=>
$employee
?
(
int
)
$employee
->
id
:
null
,
'created_at'
=>
$ts
,
'updated_at'
=>
$ts
,
]);
EventBus
::
dispatch
(
'academy.enrollment_created'
,
[
'enrollment_id'
=>
$enrollmentId
,
'player_id'
=>
$playerId
,
'academy_id'
=>
$academyId
,
'level_id'
=>
$levelId
,
]);
Logger
::
info
(
"Player #
{
$playerId
}
enrolled in academy #
{
$academyId
}
level #
{
$levelId
}
"
);
return
$enrollmentId
;
}
/**
* Promote an enrollment to a new level.
*
Placeholder — full logic in PlayerAffairs Phase 19
.
*
Creates a new enrollment record linked to the previous one via promoted_from_id
.
*/
public
static
function
promote
(
int
$enrollmentId
,
int
$newLevelId
)
:
bool
public
static
function
promote
(
int
$enrollmentId
,
int
$newLevelId
)
:
int
{
throw
new
\RuntimeException
(
'Enrollment features require the PlayerAffairs module.'
);
$db
=
App
::
getInstance
()
->
db
();
$employee
=
App
::
getInstance
()
->
currentEmployee
();
$ts
=
date
(
'Y-m-d H:i:s'
);
$enrollment
=
$db
->
selectOne
(
"SELECT * FROM academy_enrollments WHERE id = ?"
,
[
$enrollmentId
]);
if
(
!
$enrollment
)
{
throw
new
\RuntimeException
(
'التسجيل غير موجود'
);
}
if
(
$enrollment
[
'status'
]
!==
'active'
)
{
throw
new
\RuntimeException
(
'لا يمكن ترقية تسجيل غير نشط'
);
}
// Mark current enrollment as promoted
$db
->
update
(
'academy_enrollments'
,
[
'status'
=>
'promoted'
,
'updated_at'
=>
$ts
,
],
'id = ?'
,
[
$enrollmentId
]);
// Create new enrollment at the higher level
$newEnrollmentId
=
$db
->
insert
(
'academy_enrollments'
,
[
'player_id'
=>
(
int
)
$enrollment
[
'player_id'
],
'academy_id'
=>
(
int
)
$enrollment
[
'academy_id'
],
'level_id'
=>
$newLevelId
,
'schedule_id'
=>
$enrollment
[
'schedule_id'
]
?
(
int
)
$enrollment
[
'schedule_id'
]
:
null
,
'season'
=>
$enrollment
[
'season'
],
'enrolled_at'
=>
$ts
,
'enrollment_day'
=>
(
int
)
date
(
'j'
),
'status'
=>
'active'
,
'promoted_from_id'
=>
$enrollmentId
,
'created_by'
=>
$employee
?
(
int
)
$employee
->
id
:
null
,
'created_at'
=>
$ts
,
'updated_at'
=>
$ts
,
]);
EventBus
::
dispatch
(
'academy.enrollment_promoted'
,
[
'old_enrollment_id'
=>
$enrollmentId
,
'new_enrollment_id'
=>
$newEnrollmentId
,
'player_id'
=>
(
int
)
$enrollment
[
'player_id'
],
'academy_id'
=>
(
int
)
$enrollment
[
'academy_id'
],
'new_level_id'
=>
$newLevelId
,
]);
Logger
::
info
(
"Enrollment #
{
$enrollmentId
}
promoted to level #
{
$newLevelId
}
→ new enrollment #
{
$newEnrollmentId
}
"
);
return
$newEnrollmentId
;
}
/**
* Suspend an enrollment.
* Placeholder — full logic in PlayerAffairs Phase 19.
*/
public
static
function
suspend
(
int
$enrollmentId
)
:
bool
public
static
function
suspend
(
int
$enrollmentId
,
?
string
$reason
=
null
)
:
void
{
throw
new
\RuntimeException
(
'Enrollment features require the PlayerAffairs module.'
);
$db
=
App
::
getInstance
()
->
db
();
$ts
=
date
(
'Y-m-d H:i:s'
);
$enrollment
=
$db
->
selectOne
(
"SELECT * FROM academy_enrollments WHERE id = ?"
,
[
$enrollmentId
]);
if
(
!
$enrollment
)
{
throw
new
\RuntimeException
(
'التسجيل غير موجود'
);
}
if
(
$enrollment
[
'status'
]
!==
'active'
)
{
throw
new
\RuntimeException
(
'لا يمكن إيقاف تسجيل غير نشط'
);
}
$db
->
update
(
'academy_enrollments'
,
[
'status'
=>
'suspended'
,
'updated_at'
=>
$ts
,
],
'id = ?'
,
[
$enrollmentId
]);
EventBus
::
dispatch
(
'academy.enrollment_suspended'
,
[
'enrollment_id'
=>
$enrollmentId
,
'player_id'
=>
(
int
)
$enrollment
[
'player_id'
],
'academy_id'
=>
(
int
)
$enrollment
[
'academy_id'
],
'reason'
=>
$reason
,
]);
Logger
::
info
(
"Enrollment #
{
$enrollmentId
}
suspended"
.
(
$reason
?
":
{
$reason
}
"
:
''
));
}
/**
* Drop an enrollment.
* Placeholder — full logic in PlayerAffairs Phase 19.
* Drop (withdraw) an enrollment.
*/
public
static
function
drop
(
int
$enrollmentId
)
:
bool
public
static
function
drop
(
int
$enrollmentId
,
?
string
$reason
=
null
)
:
void
{
throw
new
\RuntimeException
(
'Enrollment features require the PlayerAffairs module.'
);
$db
=
App
::
getInstance
()
->
db
();
$ts
=
date
(
'Y-m-d H:i:s'
);
$enrollment
=
$db
->
selectOne
(
"SELECT * FROM academy_enrollments WHERE id = ?"
,
[
$enrollmentId
]);
if
(
!
$enrollment
)
{
throw
new
\RuntimeException
(
'التسجيل غير موجود'
);
}
if
(
!
in_array
(
$enrollment
[
'status'
],
[
'active'
,
'suspended'
]))
{
throw
new
\RuntimeException
(
'لا يمكن إلغاء هذا التسجيل'
);
}
$db
->
update
(
'academy_enrollments'
,
[
'status'
=>
'dropped'
,
'dropped_at'
=>
$ts
,
'dropped_reason'
=>
$reason
,
'updated_at'
=>
$ts
,
],
'id = ?'
,
[
$enrollmentId
]);
EventBus
::
dispatch
(
'academy.enrollment_dropped'
,
[
'enrollment_id'
=>
$enrollmentId
,
'player_id'
=>
(
int
)
$enrollment
[
'player_id'
],
'academy_id'
=>
(
int
)
$enrollment
[
'academy_id'
],
'reason'
=>
$reason
,
]);
Logger
::
info
(
"Enrollment #
{
$enrollmentId
}
dropped"
.
(
$reason
?
":
{
$reason
}
"
:
''
));
}
/**
* Reactivate a suspended enrollment.
*/
public
static
function
reactivate
(
int
$enrollmentId
)
:
void
{
$db
=
App
::
getInstance
()
->
db
();
$ts
=
date
(
'Y-m-d H:i:s'
);
$enrollment
=
$db
->
selectOne
(
"SELECT * FROM academy_enrollments WHERE id = ?"
,
[
$enrollmentId
]);
if
(
!
$enrollment
)
{
throw
new
\RuntimeException
(
'التسجيل غير موجود'
);
}
if
(
$enrollment
[
'status'
]
!==
'suspended'
)
{
throw
new
\RuntimeException
(
'لا يمكن إعادة تفعيل إلا التسجيلات الموقفة'
);
}
$db
->
update
(
'academy_enrollments'
,
[
'status'
=>
'active'
,
'updated_at'
=>
$ts
,
],
'id = ?'
,
[
$enrollmentId
]);
EventBus
::
dispatch
(
'academy.enrollment_reactivated'
,
[
'enrollment_id'
=>
$enrollmentId
,
'player_id'
=>
(
int
)
$enrollment
[
'player_id'
],
'academy_id'
=>
(
int
)
$enrollment
[
'academy_id'
],
]);
Logger
::
info
(
"Enrollment #
{
$enrollmentId
}
reactivated"
);
}
}
app/Modules/ActivitySubscriptions/Controllers/ActivitySubscriptionController.php
View file @
d28573d7
...
...
@@ -32,12 +32,32 @@ class ActivitySubscriptionController extends Controller
$page
=
max
(
1
,
(
int
)
$request
->
get
(
'page'
,
1
));
$result
=
ActivitySubscription
::
search
(
$filters
,
25
,
$page
);
// Collection summary for selected month (or current month)
$summaryMonth
=
$filters
[
'subscription_month'
]
?:
date
(
'Y-m'
);
$db
=
App
::
getInstance
()
->
db
();
$collectionSummary
=
$db
->
selectOne
(
"
SELECT
COUNT(*) AS total,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN status = 'overdue' THEN 1 ELSE 0 END) AS overdue,
SUM(CASE WHEN status = 'exempt' THEN 1 ELSE 0 END) AS exempt,
SUM(CASE WHEN status = 'revoked' THEN 1 ELSE 0 END) AS revoked,
COALESCE(SUM(total_amount), 0) AS total_expected,
COALESCE(SUM(CASE WHEN status = 'paid' THEN total_amount ELSE 0 END), 0) AS total_collected,
COALESCE(SUM(CASE WHEN status IN ('pending', 'overdue') THEN total_amount ELSE 0 END), 0) AS total_outstanding
FROM activity_subscriptions
WHERE subscription_month = ?
"
,
[
$summaryMonth
])
?:
[];
return
$this
->
view
(
'ActivitySubscriptions.Views.index'
,
[
'subscriptions'
=>
$result
[
'data'
],
'pagination'
=>
$result
[
'pagination'
],
'filters'
=>
$filters
,
'statuses'
=>
ActivitySubscription
::
getStatuses
(),
'disciplines'
=>
SportDiscipline
::
allActive
(),
'subscriptions'
=>
$result
[
'data'
],
'pagination'
=>
$result
[
'pagination'
],
'filters'
=>
$filters
,
'statuses'
=>
ActivitySubscription
::
getStatuses
(),
'disciplines'
=>
SportDiscipline
::
allActive
(),
'collectionSummary'
=>
$collectionSummary
,
'summaryMonth'
=>
$summaryMonth
,
]);
}
...
...
app/Modules/ActivitySubscriptions/Views/index.php
View file @
d28573d7
...
...
@@ -38,6 +38,40 @@ $allStatuses = ActivitySubscription::getStatuses();
</div>
</div>
<!-- Collection Summary -->
<?php
if
(
!
empty
(
$collectionSummary
)
&&
(
int
)
(
$collectionSummary
[
'total'
]
??
0
)
>
0
)
:
$cs
=
$collectionSummary
;
$csTotal
=
(
int
)
$cs
[
'total'
];
$csPaid
=
(
int
)
$cs
[
'paid'
];
$csRate
=
$csTotal
>
0
?
round
((
$csPaid
/
$csTotal
)
*
100
,
1
)
:
0
;
$csRateColor
=
$csRate
>=
80
?
'#059669'
:
(
$csRate
>=
50
?
'#D97706'
:
'#DC2626'
);
?>
<div
style=
"display:grid;grid-template-columns:repeat(5, 1fr);gap:12px;margin-bottom:20px;"
>
<div
class=
"card"
style=
"padding:15px;text-align:center;border-right:3px solid #0D7377;"
>
<div
style=
"font-size:20px;font-weight:700;color:#0D7377;"
>
<?=
number_format
((
float
)
$cs
[
'total_expected'
],
0
)
?>
</div>
<div
style=
"font-size:11px;color:#6B7280;"
>
ج.م المتوقع (
<?=
e
(
$summaryMonth
)
?>
)
</div>
</div>
<div
class=
"card"
style=
"padding:15px;text-align:center;border-right:3px solid #059669;"
>
<div
style=
"font-size:20px;font-weight:700;color:#059669;"
>
<?=
number_format
((
float
)
$cs
[
'total_collected'
],
0
)
?>
</div>
<div
style=
"font-size:11px;color:#6B7280;"
>
ج.م محصّل
</div>
</div>
<div
class=
"card"
style=
"padding:15px;text-align:center;border-right:3px solid #DC2626;"
>
<div
style=
"font-size:20px;font-weight:700;color:#DC2626;"
>
<?=
number_format
((
float
)
$cs
[
'total_outstanding'
],
0
)
?>
</div>
<div
style=
"font-size:11px;color:#6B7280;"
>
ج.م مستحق
</div>
</div>
<div
class=
"card"
style=
"padding:15px;text-align:center;border-right:3px solid
<?=
$csRateColor
?>
;"
>
<div
style=
"font-size:20px;font-weight:700;color:
<?=
$csRateColor
?>
;"
>
<?=
$csRate
?>
%
</div>
<div
style=
"font-size:11px;color:#6B7280;"
>
نسبة التحصيل
</div>
</div>
<div
class=
"card"
style=
"padding:15px;text-align:center;border-right:3px solid #6B7280;"
>
<div
style=
"font-size:20px;font-weight:700;color:#374151;"
>
<?=
$csTotal
?>
</div>
<div
style=
"font-size:11px;color:#6B7280;"
>
<?=
$csPaid
?>
مسدد
·
<?=
(
int
)
$cs
[
'pending'
]
?>
معلق
·
<?=
(
int
)
$cs
[
'overdue'
]
?>
متأخر
</div>
</div>
</div>
<?php
endif
;
?>
<!-- Search & Filters -->
<div
class=
"card"
style=
"margin-bottom:20px;padding:15px;"
>
<form
method=
"GET"
action=
"/activity-subscriptions"
style=
"display:flex;flex-wrap:wrap;gap:10px;align-items:end;"
>
...
...
app/Modules/Disciplines/Controllers/DisciplineController.php
View file @
d28573d7
...
...
@@ -115,8 +115,6 @@ class DisciplineController extends Controller
return
$this
->
view
(
'Disciplines.Views.show'
,
[
'discipline'
=>
$discipline
,
'config'
=>
$discipline
->
getConfig
(),
'ageGroups'
=>
$discipline
->
getAgeGroups
(),
'skillLevels'
=>
$discipline
->
getSkillLevels
(),
'categories'
=>
SportDiscipline
::
getCategories
(),
]);
}
...
...
@@ -221,59 +219,12 @@ class DisciplineController extends Controller
}
/**
* Build the config array from the request
(age groups, skill levels, etc.)
.
* Build the config array from the request.
*/
private
function
buildConfigFromRequest
(
Request
$request
)
:
array
{
$config
=
[];
// Age groups
$ageCodes
=
$request
->
post
(
'age_group_code'
);
$ageLabels
=
$request
->
post
(
'age_group_label'
);
$ageMinAges
=
$request
->
post
(
'age_group_min_age'
);
$ageMaxAges
=
$request
->
post
(
'age_group_max_age'
);
if
(
is_array
(
$ageCodes
))
{
$ageGroups
=
[];
foreach
(
$ageCodes
as
$i
=>
$code
)
{
$code
=
trim
((
string
)
(
$code
??
''
));
$label
=
trim
((
string
)
(
$ageLabels
[
$i
]
??
''
));
if
(
$code
!==
''
&&
$label
!==
''
)
{
$ageGroups
[]
=
[
'code'
=>
$code
,
'label_ar'
=>
$label
,
'min_age'
=>
(
int
)
(
$ageMinAges
[
$i
]
??
0
),
'max_age'
=>
(
int
)
(
$ageMaxAges
[
$i
]
??
99
),
];
}
}
if
(
!
empty
(
$ageGroups
))
{
$config
[
'age_groups'
]
=
$ageGroups
;
}
}
// Skill levels
$skillCodes
=
$request
->
post
(
'skill_level_code'
);
$skillLabels
=
$request
->
post
(
'skill_level_label'
);
if
(
is_array
(
$skillCodes
))
{
$skillLevels
=
[];
foreach
(
$skillCodes
as
$i
=>
$code
)
{
$code
=
trim
((
string
)
(
$code
??
''
));
$label
=
trim
((
string
)
(
$skillLabels
[
$i
]
??
''
));
if
(
$code
!==
''
&&
$label
!==
''
)
{
$skillLevels
[]
=
[
'code'
=>
$code
,
'label_ar'
=>
$label
,
];
}
}
if
(
!
empty
(
$skillLevels
))
{
$config
[
'skill_levels'
]
=
$skillLevels
;
}
}
// Additional config options
$requiresMedicalCert
=
$request
->
post
(
'requires_medical_cert'
);
if
(
$requiresMedicalCert
)
{
$config
[
'requires_medical_cert'
]
=
true
;
...
...
app/Modules/Disciplines/Controllers/SportsDashboardController.php
0 → 100644
View file @
d28573d7
<?php
declare
(
strict_types
=
1
);
namespace
App\Modules\Disciplines\Controllers
;
use
App\Core\Controller
;
use
App\Core\Request
;
use
App\Core\Response
;
use
App\Core\App
;
class
SportsDashboardController
extends
Controller
{
public
function
index
(
Request
$request
)
:
Response
{
$db
=
App
::
getInstance
()
->
db
();
// Players summary
$playerStats
=
$db
->
selectOne
(
"
SELECT
COUNT(*) AS total,
SUM(CASE WHEN card_status = 'active' THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN card_status = 'suspended' THEN 1 ELSE 0 END) AS suspended,
SUM(CASE WHEN card_status = 'revoked' THEN 1 ELSE 0 END) AS revoked,
SUM(CASE WHEN card_status = 'inactive' THEN 1 ELSE 0 END) AS inactive
FROM players WHERE is_archived = 0
"
)
?:
[
'total'
=>
0
,
'active'
=>
0
,
'suspended'
=>
0
,
'revoked'
=>
0
,
'inactive'
=>
0
];
// Active enrollments
$enrollmentCount
=
$db
->
selectOne
(
"SELECT COUNT(*) AS total FROM academy_enrollments WHERE status = 'active'"
)[
'total'
]
??
0
;
// This month subscriptions
$currentMonth
=
date
(
'Y-m'
);
$subStats
=
$db
->
selectOne
(
"
SELECT
COUNT(*) AS total,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN status = 'overdue' THEN 1 ELSE 0 END) AS overdue,
COALESCE(SUM(CASE WHEN status = 'paid' THEN total_amount ELSE 0 END), 0) AS collected,
COALESCE(SUM(CASE WHEN status IN ('pending', 'overdue') THEN total_amount ELSE 0 END), 0) AS outstanding
FROM activity_subscriptions
WHERE subscription_month = ?
"
,
[
$currentMonth
])
?:
[
'total'
=>
0
,
'paid'
=>
0
,
'pending'
=>
0
,
'overdue'
=>
0
,
'collected'
=>
0
,
'outstanding'
=>
0
];
$collectionRate
=
(
$subStats
[
'total'
]
>
0
)
?
round
(((
int
)
$subStats
[
'paid'
]
/
(
int
)
$subStats
[
'total'
])
*
100
,
1
)
:
0
;
// Facility utilization (reservations this month)
$monthStart
=
date
(
'Y-m-01'
);
$monthEnd
=
date
(
'Y-m-t'
);
$facilityStats
=
$db
->
selectOne
(
"
SELECT
COUNT(*) AS total_reservations,
COALESCE(SUM(total_amount), 0) AS revenue
FROM reservations
WHERE reservation_date BETWEEN ? AND ?
AND status NOT IN ('cancelled')
"
,
[
$monthStart
,
$monthEnd
])
?:
[
'total_reservations'
=>
0
,
'revenue'
=>
0
];
// Recent registrations (last 10)
$recentPlayers
=
$db
->
select
(
"
SELECT id, full_name_ar, player_type, card_status, registration_serial, created_at
FROM players
WHERE is_archived = 0
ORDER BY created_at DESC
LIMIT 10
"
);
// Unpaid subscriptions (pending + overdue this month)
$unpaidSubs
=
$db
->
select
(
"
SELECT s.id, s.subscription_month, s.total_amount, s.status, s.due_date,
p.full_name_ar AS player_name, p.id AS player_id
FROM activity_subscriptions s
JOIN players p ON p.id = s.player_id
WHERE s.subscription_month = ?
AND s.status IN ('pending', 'overdue')
ORDER BY s.due_date ASC
LIMIT 15
"
,
[
$currentMonth
]);
// Expiring medical certs (next 30 days)
$expiringMedical
=
$db
->
select
(
"
SELECT p.id, p.full_name_ar, p.medical_expiry_date, p.medical_status
FROM players p
WHERE p.is_archived = 0
AND p.medical_expiry_date IS NOT NULL
AND p.medical_expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 30 DAY)
ORDER BY p.medical_expiry_date ASC
LIMIT 10
"
);
// Academies count
$academyCount
=
$db
->
selectOne
(
"SELECT COUNT(*) AS total FROM academies WHERE is_active = 1"
)[
'total'
]
??
0
;
// Disciplines count
$disciplineCount
=
$db
->
selectOne
(
"SELECT COUNT(*) AS total FROM sport_disciplines WHERE is_active = 1 AND is_archived = 0"
)[
'total'
]
??
0
;
return
$this
->
view
(
'Disciplines.Views.sports_dashboard'
,
[
'playerStats'
=>
$playerStats
,
'enrollmentCount'
=>
$enrollmentCount
,
'subStats'
=>
$subStats
,
'collectionRate'
=>
$collectionRate
,
'facilityStats'
=>
$facilityStats
,
'recentPlayers'
=>
$recentPlayers
,
'unpaidSubs'
=>
$unpaidSubs
,
'expiringMedical'
=>
$expiringMedical
,
'academyCount'
=>
$academyCount
,
'disciplineCount'
=>
$disciplineCount
,
'currentMonth'
=>
$currentMonth
,
]);
}
}
app/Modules/Disciplines/Routes.php
View file @
d28573d7
...
...
@@ -2,6 +2,7 @@
declare
(
strict_types
=
1
);
return
[
[
'GET'
,
'/sports-dashboard'
,
'Disciplines\Controllers\SportsDashboardController@index'
,
[
'auth'
],
'discipline.view'
],
[
'GET'
,
'/disciplines'
,
'Disciplines\Controllers\DisciplineController@index'
,
[
'auth'
],
'discipline.view'
],
[
'GET'
,
'/disciplines/create'
,
'Disciplines\Controllers\DisciplineController@create'
,
[
'auth'
],
'discipline.manage'
],
[
'POST'
,
'/disciplines'
,
'Disciplines\Controllers\DisciplineController@store'
,
[
'auth'
,
'csrf'
],
'discipline.manage'
],
...
...
app/Modules/Disciplines/Services/DisciplineService.php
View file @
d28573d7
...
...
@@ -23,52 +23,6 @@ class DisciplineService
return
SportDiscipline
::
getByCategory
(
$category
);
}
/**
* Get age groups for a specific discipline.
*/
public
static
function
getAgeGroups
(
int
$disciplineId
)
:
array
{
$discipline
=
SportDiscipline
::
find
(
$disciplineId
);
if
(
!
$discipline
)
{
return
[];
}
return
$discipline
->
getAgeGroups
();
}
/**
* Get skill levels for a specific discipline.
*/
public
static
function
getSkillLevels
(
int
$disciplineId
)
:
array
{
$discipline
=
SportDiscipline
::
find
(
$disciplineId
);
if
(
!
$discipline
)
{
return
[];
}
return
$discipline
->
getSkillLevels
();
}
/**
* Validate whether a given age is acceptable for a discipline.
*/
public
static
function
validateAgeForDiscipline
(
int
$disciplineId
,
int
$age
)
:
bool
{
$ageGroups
=
self
::
getAgeGroups
(
$disciplineId
);
if
(
empty
(
$ageGroups
))
{
// No age groups defined — allow all ages
return
true
;
}
foreach
(
$ageGroups
as
$group
)
{
$minAge
=
(
int
)
(
$group
[
'min_age'
]
??
0
);
$maxAge
=
(
int
)
(
$group
[
'max_age'
]
??
999
);
if
(
$age
>=
$minAge
&&
$age
<=
$maxAge
)
{
return
true
;
}
}
return
false
;
}
/**
* Get all active disciplines grouped by category.
*/
...
...
app/Modules/Disciplines/Views/create.php
View file @
d28573d7
...
...
@@ -99,42 +99,6 @@
</div>
</div>
<!-- Age Groups -->
<div
class=
"card"
style=
"margin-bottom:20px;"
>
<div
style=
"padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between;"
>
<div
style=
"display:flex;align-items:center;gap:8px;"
>
<i
data-lucide=
"users"
style=
"width:18px;height:18px;color:#0D7377;"
></i>
<h3
style=
"margin:0;color:#0D7377;font-size:15px;"
>
الفئات العمرية
</h3>
</div>
<button
type=
"button"
class=
"btn btn-sm btn-outline"
onclick=
"addAgeGroup()"
>
<i
data-lucide=
"plus"
style=
"width:14px;height:14px;vertical-align:middle;"
></i>
إضافة فئة
</button>
</div>
<div
style=
"padding:20px;"
id=
"ageGroupsContainer"
>
<div
style=
"color:#9CA3AF;font-size:13px;text-align:center;padding:15px;"
id=
"ageGroupsEmpty"
>
لم يتم إضافة فئات عمرية بعد. اضغط "إضافة فئة" لإضافة فئة عمرية.
</div>
</div>
</div>
<!-- Skill Levels -->
<div
class=
"card"
style=
"margin-bottom:20px;"
>
<div
style=
"padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between;"
>
<div
style=
"display:flex;align-items:center;gap:8px;"
>
<i
data-lucide=
"bar-chart-2"
style=
"width:18px;height:18px;color:#7C3AED;"
></i>
<h3
style=
"margin:0;color:#7C3AED;font-size:15px;"
>
مستويات المهارة
</h3>
</div>
<button
type=
"button"
class=
"btn btn-sm btn-outline"
onclick=
"addSkillLevel()"
>
<i
data-lucide=
"plus"
style=
"width:14px;height:14px;vertical-align:middle;"
></i>
إضافة مستوى
</button>
</div>
<div
style=
"padding:20px;"
id=
"skillLevelsContainer"
>
<div
style=
"color:#9CA3AF;font-size:13px;text-align:center;padding:15px;"
id=
"skillLevelsEmpty"
>
لم يتم إضافة مستويات مهارة بعد. اضغط "إضافة مستوى" لإضافة مستوى.
</div>
</div>
</div>
<!-- Additional Settings -->
<div
class=
"card"
style=
"margin-bottom:20px;"
>
<div
style=
"padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;"
>
...
...
@@ -212,85 +176,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
var
ageGroupIndex
=
0
;
function
addAgeGroup
()
{
var
container
=
document
.
getElementById
(
'ageGroupsContainer'
);
var
emptyMsg
=
document
.
getElementById
(
'ageGroupsEmpty'
);
if
(
emptyMsg
)
emptyMsg
.
style
.
display
=
'none'
;
var
row
=
document
.
createElement
(
'div'
);
row
.
className
=
'age-group-row'
;
row
.
style
.
cssText
=
'display:grid;grid-template-columns:1fr 1.5fr 0.7fr 0.7fr auto;gap:10px;align-items:end;margin-bottom:10px;padding:12px;background:#F9FAFB;border-radius:8px;'
;
row
.
innerHTML
=
'<div class="form-group" style="margin:0;">'
+
'<label class="form-label" style="font-size:11px;">الكود</label>'
+
'<input type="text" name="age_group_code[]" class="form-input" placeholder="U12" style="direction:ltr;text-align:left;font-size:13px;">'
+
'</div>'
+
'<div class="form-group" style="margin:0;">'
+
'<label class="form-label" style="font-size:11px;">الاسم</label>'
+
'<input type="text" name="age_group_label[]" class="form-input" placeholder="تحت 12 سنة" style="font-size:13px;">'
+
'</div>'
+
'<div class="form-group" style="margin:0;">'
+
'<label class="form-label" style="font-size:11px;">من سن</label>'
+
'<input type="number" name="age_group_min_age[]" class="form-input" value="0" min="0" max="99" style="direction:ltr;text-align:left;font-size:13px;">'
+
'</div>'
+
'<div class="form-group" style="margin:0;">'
+
'<label class="form-label" style="font-size:11px;">إلى سن</label>'
+
'<input type="number" name="age_group_max_age[]" class="form-input" value="99" min="0" max="99" style="direction:ltr;text-align:left;font-size:13px;">'
+
'</div>'
+
'<button type="button" onclick="this.parentElement.remove();checkAgeGroupsEmpty();" class="btn btn-sm" style="color:#DC2626;padding:8px;border:1px solid #FCA5A5;border-radius:6px;background:#FEF2F2;cursor:pointer;height:38px;">'
+
'<i data-lucide="trash-2" style="width:14px;height:14px;"></i>'
+
'</button>'
;
container
.
appendChild
(
row
);
ageGroupIndex
++
;
if
(
typeof
lucide
!==
'undefined'
)
{
lucide
.
createIcons
();
}
}
function
checkAgeGroupsEmpty
()
{
var
container
=
document
.
getElementById
(
'ageGroupsContainer'
);
var
emptyMsg
=
document
.
getElementById
(
'ageGroupsEmpty'
);
var
rows
=
container
.
querySelectorAll
(
'.age-group-row'
);
if
(
emptyMsg
)
emptyMsg
.
style
.
display
=
rows
.
length
===
0
?
'block'
:
'none'
;
}
var
skillLevelIndex
=
0
;
function
addSkillLevel
()
{
var
container
=
document
.
getElementById
(
'skillLevelsContainer'
);
var
emptyMsg
=
document
.
getElementById
(
'skillLevelsEmpty'
);
if
(
emptyMsg
)
emptyMsg
.
style
.
display
=
'none'
;
var
row
=
document
.
createElement
(
'div'
);
row
.
className
=
'skill-level-row'
;
row
.
style
.
cssText
=
'display:grid;grid-template-columns:1fr 2fr auto;gap:10px;align-items:end;margin-bottom:10px;padding:12px;background:#F9FAFB;border-radius:8px;'
;
row
.
innerHTML
=
'<div class="form-group" style="margin:0;">'
+
'<label class="form-label" style="font-size:11px;">الكود</label>'
+
'<input type="text" name="skill_level_code[]" class="form-input" placeholder="beginner" style="direction:ltr;text-align:left;font-size:13px;">'
+
'</div>'
+
'<div class="form-group" style="margin:0;">'
+
'<label class="form-label" style="font-size:11px;">الاسم</label>'
+
'<input type="text" name="skill_level_label[]" class="form-input" placeholder="مبتدئ" style="font-size:13px;">'
+
'</div>'
+
'<button type="button" onclick="this.parentElement.remove();checkSkillLevelsEmpty();" class="btn btn-sm" style="color:#DC2626;padding:8px;border:1px solid #FCA5A5;border-radius:6px;background:#FEF2F2;cursor:pointer;height:38px;">'
+
'<i data-lucide="trash-2" style="width:14px;height:14px;"></i>'
+
'</button>'
;
container
.
appendChild
(
row
);
skillLevelIndex
++
;
if
(
typeof
lucide
!==
'undefined'
)
{
lucide
.
createIcons
();
}
}
function
checkSkillLevelsEmpty
()
{
var
container
=
document
.
getElementById
(
'skillLevelsContainer'
);
var
emptyMsg
=
document
.
getElementById
(
'skillLevelsEmpty'
);
var
rows
=
container
.
querySelectorAll
(
'.skill-level-row'
);
if
(
emptyMsg
)
emptyMsg
.
style
.
display
=
rows
.
length
===
0
?
'block'
:
'none'
;
}
</script>
<?php
$__template
->
endSection
();
?>
app/Modules/Disciplines/Views/edit.php
View file @
d28573d7
This diff is collapsed.
Click to expand it.
app/Modules/Disciplines/Views/index.php
View file @
d28573d7
...
...
@@ -50,9 +50,6 @@ $allCategories = SportDiscipline::getCategories();
<?php
if
(
!
empty
(
$disciplines
))
:
?>
<div
style=
"display:grid;grid-template-columns:repeat(auto-fill, minmax(320px, 1fr));gap:20px;margin-bottom:20px;"
>
<?php
foreach
(
$disciplines
as
$d
)
:
$config
=
json_decode
(
$d
[
'config_json'
]
??
'{}'
,
true
)
?:
[];
$ageGroupsCount
=
count
(
$config
[
'age_groups'
]
??
[]);
$skillLevelsCount
=
count
(
$config
[
'skill_levels'
]
??
[]);
$catColor
=
SportDiscipline
::
getCategoryColor
(
$d
[
'category'
]
??
''
);
$catLabel
=
SportDiscipline
::
getCategoryLabel
(
$d
[
'category'
]
??
''
);
$isActive
=
(
int
)
(
$d
[
'is_active'
]
??
0
);
...
...
@@ -79,16 +76,11 @@ $allCategories = SportDiscipline::getCategories();
</div>
</div>
<!-- Card Body Stats -->
<div
style=
"padding:0 20px 15px;display:flex;gap:15px;font-size:12px;color:#6B7280;"
>
<span
style=
"display:flex;align-items:center;gap:4px;"
>
<i
data-lucide=
"users"
style=
"width:14px;height:14px;"
></i>
<?=
$ageGroupsCount
?>
فئة عمرية
</span>
<span
style=
"display:flex;align-items:center;gap:4px;"
>
<i
data-lucide=
"bar-chart-2"
style=
"width:14px;height:14px;"
></i>
<?=
$skillLevelsCount
?>
مستوى مهارة
</span>
<!-- Card Body -->
<div
style=
"padding:0 20px 15px;font-size:12px;color:#6B7280;"
>
<?php
if
(
!
empty
(
$d
[
'description_ar'
]))
:
?>
<span
style=
"display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;"
>
<?=
e
(
$d
[
'description_ar'
])
?>
</span>
<?php
endif
;
?>
</div>
</a>
...
...
app/Modules/Disciplines/Views/show.php
View file @
d28573d7
...
...
@@ -70,15 +70,7 @@ $isActive = (int) $discipline->is_active;
</div>
<!-- Stats Cards Row -->
<div
style=
"display:grid;grid-template-columns:repeat(4, 1fr);gap:15px;margin-bottom:20px;"
>
<div
class=
"card"
style=
"padding:20px;text-align:center;"
>
<div
style=
"font-size:28px;font-weight:700;color:#0D7377;"
>
<?=
count
(
$ageGroups
)
?>
</div>
<div
style=
"font-size:12px;color:#6B7280;margin-top:4px;"
>
فئة عمرية
</div>
</div>
<div
class=
"card"
style=
"padding:20px;text-align:center;"
>
<div
style=
"font-size:28px;font-weight:700;color:#7C3AED;"
>
<?=
count
(
$skillLevels
)
?>
</div>
<div
style=
"font-size:12px;color:#6B7280;margin-top:4px;"
>
مستوى مهارة
</div>
</div>
<div
style=
"display:grid;grid-template-columns:repeat(2, 1fr);gap:15px;margin-bottom:20px;"
>
<div
class=
"card"
style=
"padding:20px;text-align:center;"
>
<div
style=
"font-size:28px;font-weight:700;color:#0284C7;"
>
0
</div>
<div
style=
"font-size:12px;color:#6B7280;margin-top:4px;"
>
لاعب مسجل
</div>
...
...
@@ -89,80 +81,6 @@ $isActive = (int) $discipline->is_active;
</div>
</div>
<!-- Config Details Grid -->
<div
style=
"display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px;"
>
<!-- Age Groups Table -->
<div
class=
"card"
>
<div
style=
"padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;"
>
<i
data-lucide=
"users"
style=
"width:18px;height:18px;color:#0D7377;"
></i>
<h3
style=
"margin:0;color:#0D7377;font-size:15px;"
>
الفئات العمرية
</h3>
</div>
<?php
if
(
!
empty
(
$ageGroups
))
:
?>
<div
class=
"table-responsive"
>
<table
class=
"data-table"
>
<thead>
<tr>
<th>
الكود
</th>
<th>
الفئة
</th>
<th>
من سن
</th>
<th>
إلى سن
</th>
</tr>
</thead>
<tbody>
<?php
foreach
(
$ageGroups
as
$ag
)
:
?>
<tr>
<td><code
style=
"font-size:11px;background:#F3F4F6;padding:2px 6px;border-radius:4px;"
>
<?=
e
(
$ag
[
'code'
]
??
''
)
?>
</code></td>
<td
style=
"font-weight:600;"
>
<?=
e
(
$ag
[
'label_ar'
]
??
''
)
?>
</td>
<td
style=
"text-align:center;"
>
<?=
(
int
)
(
$ag
[
'min_age'
]
??
0
)
?>
</td>
<td
style=
"text-align:center;"
>
<?=
(
int
)
(
$ag
[
'max_age'
]
??
0
)
?>
</td>
</tr>
<?php
endforeach
;
?>
</tbody>
</table>
</div>
<?php
else
:
?>
<div
style=
"padding:30px;text-align:center;color:#9CA3AF;font-size:14px;"
>
<i
data-lucide=
"info"
style=
"width:20px;height:20px;margin-bottom:8px;"
></i>
<div>
لم يتم تحديد فئات عمرية
</div>
</div>
<?php
endif
;
?>
</div>
<!-- Skill Levels Table -->
<div
class=
"card"
>
<div
style=
"padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;"
>
<i
data-lucide=
"bar-chart-2"
style=
"width:18px;height:18px;color:#7C3AED;"
></i>
<h3
style=
"margin:0;color:#7C3AED;font-size:15px;"
>
مستويات المهارة
</h3>
</div>
<?php
if
(
!
empty
(
$skillLevels
))
:
?>
<div
class=
"table-responsive"
>
<table
class=
"data-table"
>
<thead>
<tr>
<th>
الكود
</th>
<th>
المستوى
</th>
</tr>
</thead>
<tbody>
<?php
foreach
(
$skillLevels
as
$sl
)
:
?>
<tr>
<td><code
style=
"font-size:11px;background:#F3F4F6;padding:2px 6px;border-radius:4px;"
>
<?=
e
(
$sl
[
'code'
]
??
''
)
?>
</code></td>
<td
style=
"font-weight:600;"
>
<?=
e
(
$sl
[
'label_ar'
]
??
''
)
?>
</td>
</tr>
<?php
endforeach
;
?>
</tbody>
</table>
</div>
<?php
else
:
?>
<div
style=
"padding:30px;text-align:center;color:#9CA3AF;font-size:14px;"
>
<i
data-lucide=
"info"
style=
"width:20px;height:20px;margin-bottom:8px;"
></i>
<div>
لم يتم تحديد مستويات مهارة
</div>
</div>
<?php
endif
;
?>
</div>
</div>
<!-- Additional Config -->
<?php
$requiresMedical
=
!
empty
(
$config
[
'requires_medical_cert'
]);
...
...
app/Modules/Disciplines/Views/sports_dashboard.php
0 → 100644
View file @
d28573d7
This diff is collapsed.
Click to expand it.
app/Modules/Disciplines/bootstrap.php
View file @
d28573d7
...
...
@@ -18,10 +18,12 @@ MenuRegistry::register('sports_activities', [
'parent'
=>
null
,
'order'
=>
395
,
'children'
=>
[
[
'label_ar'
=>
'لوحة التحكم'
,
'label_en'
=>
'Sports Dashboard'
,
'route'
=>
'/sports-dashboard'
,
'permission'
=>
'discipline.view'
,
'order'
=>
0
],
[
'label_ar'
=>
'الأنشطة الرياضية'
,
'label_en'
=>
'Disciplines'
,
'route'
=>
'/disciplines'
,
'permission'
=>
'discipline.view'
,
'order'
=>
1
],
[
'label_ar'
=>
'الملاعب والمرافق'
,
'label_en'
=>
'Facilities'
,
'route'
=>
'/facilities'
,
'permission'
=>
'facility.view'
,
'order'
=>
2
],
[
'label_ar'
=>
'الأكاديميات'
,
'label_en'
=>
'Academies'
,
'route'
=>
'/academies'
,
'permission'
=>
'academy.view'
,
'order'
=>
3
],
[
'label_ar'
=>
'شئون اللاعبين'
,
'label_en'
=>
'Players'
,
'route'
=>
'/players'
,
'permission'
=>
'player.view'
,
'order'
=>
4
],
[
'label_ar'
=>
'الحضور والغياب'
,
'label_en'
=>
'Attendance'
,
'route'
=>
'/attendance'
,
'permission'
=>
'player.view'
,
'order'
=>
4.5
],
[
'label_ar'
=>
'الحجوزات'
,
'label_en'
=>
'Reservations'
,
'route'
=>
'/reservations'
,
'permission'
=>
'reservation.view'
,
'order'
=>
5
],
[
'label_ar'
=>
'التأجير المؤسسي'
,
'label_en'
=>
'Corporate Rentals'
,
'route'
=>
'/rentals'
,
'permission'
=>
'rental.view'
,
'order'
=>
6
],
[
'label_ar'
=>
'اشتراكات الأنشطة'
,
'label_en'
=>
'Activity Subscriptions'
,
'route'
=>
'/activity-subscriptions'
,
'permission'
=>
'activity_sub.view'
,
'order'
=>
7
],
...
...
app/Modules/PlayerAffairs/Controllers/AttendanceController.php
0 → 100644
View file @
d28573d7
<?php
declare
(
strict_types
=
1
);
namespace
App\Modules\PlayerAffairs\Controllers
;
use
App\Core\Controller
;
use
App\Core\Request
;
use
App\Core\Response
;
use
App\Core\App
;
class
AttendanceController
extends
Controller
{
public
function
index
(
Request
$request
)
:
Response
{
$db
=
App
::
getInstance
()
->
db
();
$academyId
=
(
int
)
$request
->
get
(
'academy_id'
,
0
);
$date
=
trim
((
string
)
$request
->
get
(
'date'
,
date
(
'Y-m-d'
)));
// Get all active academies for the dropdown
$academies
=
$db
->
select
(
"SELECT a.id, a.name_ar, sd.name_ar AS discipline_name
FROM academies a
JOIN sport_disciplines sd ON sd.id = a.discipline_id
WHERE a.is_active = 1
ORDER BY a.name_ar"
);
$players
=
[];
$existingAttendance
=
[];
if
(
$academyId
>
0
)
{
// Get active players enrolled in this academy
$players
=
$db
->
select
(
"
SELECT ae.id AS enrollment_id, ae.player_id,
p.full_name_ar, p.activity_id_number, p.card_status
FROM academy_enrollments ae
JOIN players p ON p.id = ae.player_id AND p.is_archived = 0
WHERE ae.academy_id = ? AND ae.status = 'active'
ORDER BY p.full_name_ar
"
,
[
$academyId
]);
// Get existing attendance records for this date and academy's players
if
(
!
empty
(
$players
))
{
$playerIds
=
array_map
(
fn
(
$p
)
=>
(
int
)
$p
[
'player_id'
],
$players
);
$placeholders
=
implode
(
','
,
array_fill
(
0
,
count
(
$playerIds
),
'?'
));
$rows
=
$db
->
select
(
"SELECT player_id, status, notes FROM player_attendance
WHERE attendance_date = ? AND player_id IN (
{
$placeholders
}
)"
,
array_merge
([
$date
],
$playerIds
)
);
foreach
(
$rows
as
$row
)
{
$existingAttendance
[(
int
)
$row
[
'player_id'
]]
=
$row
;
}
}
}
// Attendance summary for this academy/date
$summary
=
null
;
if
(
$academyId
>
0
&&
!
empty
(
$existingAttendance
))
{
$summary
=
[
'present'
=>
0
,
'absent'
=>
0
,
'late'
=>
0
,
'excused'
=>
0
];
foreach
(
$existingAttendance
as
$att
)
{
$s
=
$att
[
'status'
]
??
'present'
;
if
(
isset
(
$summary
[
$s
]))
$summary
[
$s
]
++
;
}
}
return
$this
->
view
(
'PlayerAffairs.Views.attendance'
,
[
'academies'
=>
$academies
,
'selectedAcademyId'
=>
$academyId
,
'selectedDate'
=>
$date
,
'players'
=>
$players
,
'existingAttendance'
=>
$existingAttendance
,
'summary'
=>
$summary
,
]);
}
public
function
record
(
Request
$request
)
:
Response
{
$db
=
App
::
getInstance
()
->
db
();
$employee
=
App
::
getInstance
()
->
currentEmployee
();
$academyId
=
(
int
)
$request
->
post
(
'academy_id'
,
0
);
$date
=
trim
((
string
)
$request
->
post
(
'date'
,
date
(
'Y-m-d'
)));
$statuses
=
(
array
)
(
$request
->
post
(
'status'
)
??
[]);
$notes
=
(
array
)
(
$request
->
post
(
'notes'
)
??
[]);
if
(
$academyId
<=
0
||
empty
(
$statuses
))
{
return
$this
->
redirect
(
'/attendance?academy_id='
.
$academyId
.
'&date='
.
$date
)
->
withError
(
'بيانات غير صالحة'
);
}
$validStatuses
=
[
'present'
,
'absent'
,
'late'
,
'excused'
];
$ts
=
date
(
'Y-m-d H:i:s'
);
$recorded
=
0
;
// Get enrollment_id mapping for this academy
$enrollments
=
$db
->
select
(
"SELECT id AS enrollment_id, player_id FROM academy_enrollments WHERE academy_id = ? AND status = 'active'"
,
[
$academyId
]
);
$enrollmentMap
=
[];
foreach
(
$enrollments
as
$e
)
{
$enrollmentMap
[(
int
)
$e
[
'player_id'
]]
=
(
int
)
$e
[
'enrollment_id'
];
}
foreach
(
$statuses
as
$playerId
=>
$status
)
{
$playerId
=
(
int
)
$playerId
;
$status
=
trim
((
string
)
$status
);
if
(
!
in_array
(
$status
,
$validStatuses
))
continue
;
$enrollmentId
=
$enrollmentMap
[
$playerId
]
??
null
;
$note
=
trim
((
string
)
(
$notes
[
$playerId
]
??
''
));
// Upsert: delete existing then insert
$db
->
delete
(
'player_attendance'
,
'player_id = ? AND attendance_date = ? AND enrollment_id = ?'
,
[
$playerId
,
$date
,
$enrollmentId
]);
$db
->
insert
(
'player_attendance'
,
[
'player_id'
=>
$playerId
,
'enrollment_id'
=>
$enrollmentId
,
'attendance_date'
=>
$date
,
'status'
=>
$status
,
'notes'
=>
$note
?:
null
,
'created_at'
=>
$ts
,
'updated_at'
=>
$ts
,
'created_by'
=>
$employee
?
(
int
)
$employee
->
id
:
null
,
]);
$recorded
++
;
}
return
$this
->
redirect
(
'/attendance?academy_id='
.
$academyId
.
'&date='
.
$date
)
->
withSuccess
(
'تم تسجيل الحضور بنجاح ('
.
$recorded
.
' لاعب)'
);
}
}
app/Modules/PlayerAffairs/Controllers/PlayerController.php
View file @
d28573d7
...
...
@@ -8,7 +8,6 @@ use App\Core\Request;
use
App\Core\Response
;
use
App\Core\App
;
use
App\Modules\PlayerAffairs\Models\Player
;
use
App\Modules\PlayerAffairs\Models\PlayerDiscipline
;
use
App\Modules\PlayerAffairs\Models\AcademyEnrollment
;
use
App\Modules\PlayerAffairs\Models\PlayerMedicalRecord
;
use
App\Modules\PlayerAffairs\Services\PlayerRegistrationService
;
...
...
@@ -145,7 +144,18 @@ class PlayerController extends Controller
return
$this
->
redirect
(
'/players'
)
->
withError
(
'اللاعب غير موجود'
);
}
$disciplines
=
PlayerDiscipline
::
getForPlayer
((
int
)
$id
);
// Derive disciplines from active enrollments (not the redundant player_disciplines table)
$db
=
App
::
getInstance
()
->
db
();
$disciplines
=
$db
->
select
(
"SELECT DISTINCT sd.id, sd.name_ar AS discipline_name, sd.icon,
a.name_ar AS academy_name, ae.season, ae.status
FROM academy_enrollments ae
JOIN academies a ON a.id = ae.academy_id
JOIN sport_disciplines sd ON sd.id = a.discipline_id
WHERE ae.player_id = ? AND ae.status IN ('active', 'suspended')
ORDER BY sd.name_ar"
,
[(
int
)
$id
]
);
$enrollments
=
AcademyEnrollment
::
getForPlayer
((
int
)
$id
);
$medicalRecords
=
PlayerMedicalRecord
::
getForPlayer
((
int
)
$id
);
...
...
app/Modules/PlayerAffairs/EventListeners.php
View file @
d28573d7
...
...
@@ -164,9 +164,22 @@ EventBus::listen('academy.enrollment_created', function (array $data) {
}
$baseRate
=
$pricing
?
(
string
)
$pricing
[
'rate'
]
:
'0.00'
;
$isHalfMonth
=
(
$enrollmentDay
>
15
)
?
1
:
0
;
// Half-month: only for FIRST subscription month AND enrollment after 15th
$isFirstMonth
=
!
$db
->
selectOne
(
"SELECT id FROM activity_subscriptions WHERE enrollment_id = ? AND subscription_month < ? LIMIT 1"
,
[
$enrollmentId
,
$month
]
);
$isHalfMonth
=
(
$isFirstMonth
&&
$enrollmentDay
>
15
)
?
1
:
0
;
$appliedRate
=
$isHalfMonth
?
bcdiv
(
$baseRate
,
'2'
,
2
)
:
$baseRate
;
$dueDate
=
date
(
'Y-m-07'
);
// Due date: for first month with enrollment after 7th, give 7 days grace
$defaultDue
=
date
(
'Y-m-07'
);
if
(
$isFirstMonth
&&
$enrollmentDay
>
7
)
{
$dueDate
=
date
(
'Y-m-d'
,
strtotime
(
'+7 days'
));
}
else
{
$dueDate
=
$defaultDue
;
}
$subId
=
$db
->
insert
(
'activity_subscriptions'
,
[
'player_id'
=>
$playerId
,
...
...
app/Modules/PlayerAffairs/Models/Player.php
View file @
d28573d7
...
...
@@ -229,10 +229,11 @@ class Player extends Model
$params
[]
=
$filters
[
'medical_status'
];
}
// Discipline filter (
join player_
disciplines)
// Discipline filter (
derived from enrollments → academies →
disciplines)
if
(
!
empty
(
$filters
[
'discipline_id'
]))
{
$joins
.=
' INNER JOIN player_disciplines pd ON pd.player_id = p.id'
;
$where
[]
=
'pd.discipline_id = ?'
;
$joins
.=
' INNER JOIN academy_enrollments ae_disc ON ae_disc.player_id = p.id AND ae_disc.status IN (\'active\', \'suspended\')'
;
$joins
.=
' INNER JOIN academies a_disc ON a_disc.id = ae_disc.academy_id'
;
$where
[]
=
'a_disc.discipline_id = ?'
;
$params
[]
=
(
int
)
$filters
[
'discipline_id'
];
}
...
...
app/Modules/PlayerAffairs/Routes.php
View file @
d28573d7
...
...
@@ -14,4 +14,8 @@ return [
[
'POST'
,
'/players/{id:\d+}/medical'
,
'PlayerAffairs\Controllers\PlayerController@addMedical'
,
[
'auth'
,
'csrf'
],
'player.manage_medical'
],
[
'POST'
,
'/players/{id:\d+}/enroll'
,
'PlayerAffairs\Controllers\PlayerController@enroll'
,
[
'auth'
,
'csrf'
],
'academy.enroll'
],
[
'POST'
,
'/players/{id:\d+}/enroll/drop'
,
'PlayerAffairs\Controllers\PlayerController@dropEnrollment'
,
[
'auth'
,
'csrf'
],
'academy.enroll'
],
// Attendance
[
'GET'
,
'/attendance'
,
'PlayerAffairs\Controllers\AttendanceController@index'
,
[
'auth'
],
'player.view'
],
[
'POST'
,
'/attendance/record'
,
'PlayerAffairs\Controllers\AttendanceController@record'
,
[
'auth'
,
'csrf'
],
'player.edit'
],
];
app/Modules/PlayerAffairs/Views/attendance.php
0 → 100644
View file @
d28573d7
This diff is collapsed.
Click to expand it.
app/Modules/PlayerAffairs/Views/show.php
View file @
d28573d7
...
...
@@ -236,20 +236,24 @@ $enrollmentStatuses = AcademyEnrollment::getStatuses();
<thead>
<tr
style=
"background:#F9FAFB;border-bottom:2px solid #E5E7EB;"
>
<th
style=
"padding:12px 15px;text-align:right;font-weight:600;color:#374151;"
>
اللعبة
</th>
<th
style=
"padding:12px 15px;text-align:right;font-weight:600;color:#374151;"
>
الفئة العمرية
</th>
<th
style=
"padding:12px 15px;text-align:right;font-weight:600;color:#374151;"
>
مستوى المهارة
</th>
<th
style=
"padding:12px 15px;text-align:right;font-weight:600;color:#374151;"
>
الأكاديمية
</th>
<th
style=
"padding:12px 15px;text-align:right;font-weight:600;color:#374151;"
>
الموسم
</th>
<th
style=
"padding:12px 15px;text-align:right;font-weight:600;color:#374151;"
>
الحالة
</th>
</tr>
</thead>
<tbody>
<?php
foreach
(
$disciplines
as
$disc
)
:
?>
<?php
foreach
(
$disciplines
as
$disc
)
:
$dStatus
=
$disc
[
'status'
]
??
'active'
;
$dStatusColor
=
$dStatus
===
'active'
?
'#059669'
:
'#D97706'
;
$dStatusLabel
=
$dStatus
===
'active'
?
'نشط'
:
(
$dStatus
===
'suspended'
?
'موقف'
:
$dStatus
);
?>
<tr
style=
"border-bottom:1px solid #F3F4F6;"
>
<td
style=
"padding:12px 15px;font-weight:600;"
>
<?=
e
(
$disc
[
'discipline_name'
]
??
'—'
)
?>
</td>
<td
style=
"padding:12px 15px;"
>
<?=
e
(
$disc
[
'age_group'
]
??
'—'
)
?>
</td>
<td
style=
"padding:12px 15px;"
>
<?=
e
(
$disc
[
'skill_level'
]
??
'—'
)
?>
</td>
<td
style=
"padding:12px 15px;"
>
<?=
e
(
$disc
[
'academy_name'
]
??
'—'
)
?>
</td>
<td
style=
"padding:12px 15px;direction:ltr;text-align:right;"
>
<?=
e
(
$disc
[
'season'
]
??
'—'
)
?>
</td>
<td
style=
"padding:12px 15px;"
>
<?=
e
(
$disc
[
'status'
]
??
'—'
)
?>
</td>
<td
style=
"padding:12px 15px;"
>
<span
style=
"display:inline-block;padding:4px 12px;border-radius:10px;font-size:12px;font-weight:600;background:
<?=
$dStatusColor
?>
15;color:
<?=
$dStatusColor
?>
;"
>
<?=
e
(
$dStatusLabel
)
?>
</span>
</td>
</tr>
<?php
endforeach
;
?>
</tbody>
...
...
app/Modules/Reservations/Services/ReservationService.php
View file @
d28573d7
...
...
@@ -7,6 +7,7 @@ use App\Core\App;
use
App\Core\EventBus
;
use
App\Modules\Reservations\Models\Reservation
;
use
App\Modules\Facilities\Models\Facility
;
use
App\Modules\Rules\Services\RuleEngine
;
class
ReservationService
{
...
...
@@ -42,7 +43,8 @@ class ReservationService
// Determine time tier if not provided
if
(
empty
(
$data
[
'time_tier'
]))
{
$hour
=
(
int
)
date
(
'H'
,
strtotime
(
$data
[
'start_time'
]));
$data
[
'time_tier'
]
=
$hour
<
12
?
'AM'
:
'PM'
;
$pmStartHour
=
(
int
)
(
RuleEngine
::
getValue
(
'SPORTS_PM_START_HOUR'
,
'hour'
)
??
17
);
$data
[
'time_tier'
]
=
$hour
<
$pmStartHour
?
'AM'
:
'PM'
;
}
// Calculate unit rate and total amount if facility is set
...
...
cron/jobs/ActivitySubGeneratorJob.php
View file @
d28573d7
...
...
@@ -24,14 +24,14 @@ class ActivitySubGeneratorJob
{
$month
=
date
(
'Y-m'
);
// Current month YYYY-MM
$ts
=
date
(
'Y-m-d H:i:s'
);
$dueDate
=
date
(
'Y-m-07'
);
// 7th of the month
$d
efaultD
ueDate
=
date
(
'Y-m-07'
);
// 7th of the month
$processed
=
0
;
$skipped
=
0
;
// Get all active enrollments with player info
$enrollments
=
$this
->
db
->
select
(
"
SELECT ae.id AS enrollment_id, ae.player_id, ae.academy_id, ae.level_id,
ae.enrollment_day, ae.season,
ae.enrollment_day, ae.season,
ae.enrolled_at,
p.player_type,
a.discipline_id
FROM academy_enrollments ae
...
...
@@ -79,6 +79,14 @@ class ActivitySubGeneratorJob
$appliedRate
=
$isHalfMonth
?
bcdiv
(
$baseRate
,
'2'
,
2
)
:
$baseRate
;
// Due date: for first month with enrollment after 7th, give 7 days from enrollment
if
(
$isFirstMonth
&&
$enrollmentDay
>
7
)
{
$enrolledAt
=
$e
[
'enrolled_at'
]
??
date
(
'Y-m-d'
);
$dueDate
=
date
(
'Y-m-d'
,
strtotime
(
'+7 days'
,
strtotime
(
$enrolledAt
)));
}
else
{
$dueDate
=
$defaultDueDate
;
}
$this
->
db
->
insert
(
'activity_subscriptions'
,
[
'player_id'
=>
$playerId
,
'enrollment_id'
=>
$enrollmentId
,
...
...
database/seeds/Phase_05_003_seed_service_catalog.php
View file @
d28573d7
...
...
@@ -28,7 +28,7 @@ return function (Database $db): void {
[
'code'
=>
'SVC_SPOUSE_ACQUIRED'
,
'name_ar'
=>
'رسوم زوج مكتسب'
,
'name_en'
=>
'Acquired Spouse Fee'
,
'price_type'
=>
'percentage'
,
'percentage'
=>
'50.00'
],
[
'code'
=>
'SVC_SPOUSE_BASE'
,
'name_ar'
=>
'رسوم زوج أساسي'
,
'name_en'
=>
'Base Spouse Fee'
,
'price_type'
=>
'percentage'
,
'percentage'
=>
'15.00'
],
[
'code'
=>
'SVC_WAIVER'
,
'name_ar'
=>
'رسوم تنازل'
,
'name_en'
=>
'Waiver Fee'
,
'price_type'
=>
'percentage'
,
'percentage'
=>
'30.00'
],
[
'code'
=>
'SVC_SPORTS_CONV'
,
'name_ar'
=>
'رسوم تحويل رياضي'
,
'name_en'
=>
'Sports Conversion Fee'
,
'price_type'
=>
'percentage'
,
'percentage'
=>
'50.00'
],
// SVC_SPORTS_CONV removed — no implementation exists, pricing lives in business_rules
[
'code'
=>
'SVC_ANNUAL_MEMBER'
,
'name_ar'
=>
'اشتراك سنوي - عضو'
,
'name_en'
=>
'Annual Sub - Member'
,
'price_type'
=>
'fixed'
,
'base_amount'
=>
'492.00'
],
[
'code'
=>
'SVC_ANNUAL_SPOUSE'
,
'name_ar'
=>
'اشتراك سنوي - زوجة'
,
'name_en'
=>
'Annual Sub - Spouse'
,
'price_type'
=>
'fixed'
,
'base_amount'
=>
'492.00'
],
[
'code'
=>
'SVC_ANNUAL_CHILD'
,
'name_ar'
=>
'اشتراك سنوي - ابن/ابنة'
,
'name_en'
=>
'Annual Sub - Child'
,
'price_type'
=>
'fixed'
,
'base_amount'
=>
'222.00'
],
...
...
database/seeds/Phase_17_004_seed_sports_service_catalog.php
View file @
d28573d7
...
...
@@ -3,75 +3,19 @@ declare(strict_types=1);
use
App\Core\Database
;
/**
* DEPRECATED — Sports service catalog entries removed.
*
* Pricing authority:
* - Registration fees → business_rules (SPORTS_REG_FEE_MEMBER / _NONMEMBER)
* - Subscription rates → activity_pricing table
* - Facility rates → facilities table (am_member_rate, pm_member_rate, etc.)
* - Rental contracts → rental_contracts.total_amount (per-contract)
*
* These service_catalog entries were never read by application code and duplicated
* pricing that already lives in the authoritative tables above. Keeping them caused
* admin confusion (two places showing different numbers for the same fee).
*/
return
function
(
Database
$db
)
:
void
{
$ts
=
date
(
'Y-m-d H:i:s'
);
$now
=
date
(
'Y-m-d'
);
$services
=
[
[
'code'
=>
'SVC_SPORTS_REG_MEMBER'
,
'name_ar'
=>
'رسوم تسجيل نشاط رياضي - عضو'
,
'name_en'
=>
'Sports Registration Fee - Member'
,
'price_type'
=>
'fixed'
,
'base_amount'
=>
'50.00'
,
],
[
'code'
=>
'SVC_SPORTS_REG_NONMEMBER'
,
'name_ar'
=>
'رسوم تسجيل نشاط رياضي - غير عضو'
,
'name_en'
=>
'Sports Registration Fee - Non-Member'
,
'price_type'
=>
'fixed'
,
'base_amount'
=>
'100.00'
,
],
[
'code'
=>
'SVC_FACILITY_RESERVATION'
,
'name_ar'
=>
'حجز ملعب'
,
'name_en'
=>
'Facility Reservation'
,
'price_type'
=>
'fixed'
,
'base_amount'
=>
'0.00'
,
],
[
'code'
=>
'SVC_RENTAL_CONTRACT'
,
'name_ar'
=>
'عقد تأجير مؤسسي'
,
'name_en'
=>
'Institutional Rental Contract'
,
'price_type'
=>
'fixed'
,
'base_amount'
=>
'0.00'
,
],
[
'code'
=>
'SVC_RENTAL_DEPOSIT'
,
'name_ar'
=>
'تأمين تأجير مؤسسي'
,
'name_en'
=>
'Institutional Rental Deposit'
,
'price_type'
=>
'fixed'
,
'base_amount'
=>
'0.00'
,
],
[
'code'
=>
'SVC_ACTIVITY_SUB_MONTHLY'
,
'name_ar'
=>
'اشتراك نشاط رياضي شهري'
,
'name_en'
=>
'Monthly Sports Activity Subscription'
,
'price_type'
=>
'fixed'
,
'base_amount'
=>
'0.00'
,
],
];
foreach
(
$services
as
$s
)
{
$existing
=
$db
->
selectOne
(
"SELECT id FROM service_catalog WHERE service_code = ? AND branch_id IS NULL"
,
[
$s
[
'code'
]]);
if
(
$existing
)
{
continue
;
}
$db
->
insert
(
'service_catalog'
,
[
'service_code'
=>
$s
[
'code'
],
'name_ar'
=>
$s
[
'name_ar'
],
'name_en'
=>
$s
[
'name_en'
]
??
null
,
'price_type'
=>
$s
[
'price_type'
],
'base_amount'
=>
$s
[
'base_amount'
]
??
null
,
'percentage'
=>
null
,
'annual_amount'
=>
null
,
'currency'
=>
'EGP'
,
'applies_to'
=>
null
,
'branch_id'
=>
null
,
'effective_from'
=>
$now
,
'is_active'
=>
1
,
'created_at'
=>
$ts
,
'updated_at'
=>
$ts
,
]);
}
// No-op: sports pricing is managed by business_rules, activity_pricing, and facilities tables.
};
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment