Commit d01e77d7 authored by Mahmoud Aglan's avatar Mahmoud Aglan

sdgjdhfk

parent 85a4f4fb
<?php
declare(strict_types=1);
namespace App\Modules\AcademyContracts\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\AcademyContracts\Models\AcademySettlement;
use App\Modules\AcademyContracts\Services\SettlementService;
use App\Modules\Academies\Models\Academy;
class SettlementController extends Controller
{
/**
* List all settlements with filters and pagination.
*/
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->get('q', '')),
'status' => trim((string) $request->get('status', '')),
'academy_id' => trim((string) $request->get('academy_id', '')),
'period_month' => trim((string) $request->get('period_month', '')),
'direction' => trim((string) $request->get('direction', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = AcademySettlement::search($filters, 25, $page);
return $this->view('AcademyContracts.Views.settlement_index', [
'settlements' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'statuses' => AcademySettlement::getStatuses(),
'directions' => AcademySettlement::getDirections(),
'academies' => Academy::allActive(),
]);
}
/**
* Show settlement detail page.
*/
public function show(Request $request, string $id): Response
{
$settlement = AcademySettlement::find((int) $id);
if (!$settlement) {
return $this->redirect('/academy-settlements')->withError('التسوية غير موجودة');
}
$academy = Academy::find((int) $settlement->academy_id);
$db = App::getInstance()->db();
$contract = $db->selectOne(
"SELECT * FROM academy_contracts WHERE id = ?",
[(int) $settlement->contract_id]
);
return $this->view('AcademyContracts.Views.settlement_show', [
'settlement' => $settlement,
'academy' => $academy,
'contract' => $contract,
]);
}
/**
* Generate settlements for a given month (POST with month param).
*/
public function generate(Request $request): Response
{
$month = trim((string) $request->post('period_month', ''));
if ($month === '' || !preg_match('/^\d{4}-\d{2}$/', $month)) {
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'error', 'message' => 'الشهر مطلوب بالصيغة YYYY-MM']]);
return $this->redirect('/academy-settlements');
}
$count = SettlementService::generateMonthlySettlements($month);
if ($count > 0) {
return $this->redirect('/academy-settlements?period_month=' . $month)
->withSuccess("تم إنشاء {$count} تسوية لشهر {$month}");
}
return $this->redirect('/academy-settlements')
->withWarning('لا توجد عقود نشطة تحتاج لتسوية أو تم إنشاء التسويات مسبقاً');
}
/**
* Approve a settlement.
*/
public function approve(Request $request, string $id): Response
{
$settlement = AcademySettlement::find((int) $id);
if (!$settlement) {
return $this->redirect('/academy-settlements')->withError('التسوية غير موجودة');
}
$session = App::getInstance()->session();
$currentUser = $session->get('employee_id', 0);
SettlementService::approveSettlement((int) $id, (int) $currentUser);
return $this->redirect('/academy-settlements/' . $id)->withSuccess('تم اعتماد التسوية بنجاح');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\AcademyContracts\Models;
use App\Core\Model;
use App\Core\App;
class AcademyContract extends Model
{
protected static string $table = 'academy_contracts';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'contract_number',
'academy_id',
'contract_type',
'start_date',
'end_date',
'minimum_revenue_guarantee',
'club_commission_pct',
'academy_share_pct',
'fixed_monthly_rent',
'deposit_amount',
'deposit_status',
'settlement_day',
'grace_period_days',
'penalty_rate_pct',
'auto_renew',
'renewal_notice_days',
'status',
'approved_by',
'approved_at',
'terminated_by',
'terminated_at',
'termination_reason',
'terms_json',
'notes',
];
/**
* Get all contract types with Arabic labels.
*/
public static function getContractTypes(): array
{
return [
'revenue_share' => 'مشاركة إيرادات',
'fixed_rent' => 'إيجار ثابت',
'hybrid' => 'نظام مختلط',
];
}
/**
* Get all statuses with Arabic labels.
*/
public static function getStatuses(): array
{
return [
'draft' => 'مسودة',
'pending_approval' => 'في انتظار الموافقة',
'active' => 'نشط',
'suspended' => 'موقوف',
'expired' => 'منتهي',
'terminated' => 'ملغى',
];
}
/**
* Get the Arabic label for a status.
*/
public static function getStatusLabel(string $status): string
{
$statuses = self::getStatuses();
return $statuses[$status] ?? $status;
}
/**
* Get the badge color for a status.
*/
public static function getStatusColor(string $status): string
{
$colors = [
'draft' => '#9CA3AF',
'pending_approval' => '#D97706',
'active' => '#059669',
'suspended' => '#DC2626',
'expired' => '#6B7280',
'terminated' => '#991B1B',
];
return $colors[$status] ?? '#6B7280';
}
/**
* Get the Arabic label for a contract type.
*/
public static function getContractTypeLabel(string $type): string
{
$types = self::getContractTypes();
return $types[$type] ?? $type;
}
/**
* Search contracts with filters and pagination.
*/
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$query = static::query()
->leftJoin('academies', '`academy_contracts`.`academy_id` = `academies`.`id`')
->select('`academy_contracts`.*, `academies`.`name_ar` as academy_name');
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$query = $query->whereRaw(
'(`academy_contracts`.`contract_number` LIKE ? OR `academies`.`name_ar` LIKE ?)',
[$search, $search]
);
}
if (!empty($filters['status'])) {
$query = $query->where('academy_contracts.status', '=', $filters['status']);
}
if (!empty($filters['academy_id'])) {
$query = $query->where('academy_contracts.academy_id', '=', (int) $filters['academy_id']);
}
if (!empty($filters['contract_type'])) {
$query = $query->where('academy_contracts.contract_type', '=', $filters['contract_type']);
}
$query = $query->orderBy('academy_contracts.created_at', 'DESC');
return $query->paginate($perPage, $page);
}
/**
* Get the active contract for a specific academy.
*/
public static function getActiveForAcademy(int $academyId): ?array
{
$result = static::query()
->where('academy_id', '=', $academyId)
->where('status', '=', 'active')
->orderBy('start_date', 'DESC')
->first();
return $result ?: null;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\AcademyContracts\Models;
use App\Core\Model;
use App\Core\App;
class AcademySettlement extends Model
{
protected static string $table = 'academy_settlements';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'settlement_number',
'academy_id',
'contract_id',
'period_month',
'total_revenue',
'minimum_guarantee',
'revenue_surplus',
'revenue_shortfall',
'club_commission',
'academy_share',
'net_amount',
'penalty_amount',
'direction',
'status',
'approved_by',
'approved_at',
'payment_id',
'paid_at',
'dispute_reason',
'notes',
];
/**
* Get all statuses with Arabic labels.
*/
public static function getStatuses(): array
{
return [
'draft' => 'مسودة',
'pending_approval' => 'في انتظار الموافقة',
'approved' => 'معتمدة',
'paid' => 'مدفوعة',
'disputed' => 'متنازع عليها',
];
}
/**
* Get all directions with Arabic labels.
*/
public static function getDirections(): array
{
return [
'club_pays_academy' => 'النادي يدفع للأكاديمية',
'academy_pays_club' => 'الأكاديمية تدفع للنادي',
'balanced' => 'متوازن',
];
}
/**
* Get the Arabic label for a status.
*/
public static function getStatusLabel(string $status): string
{
$statuses = self::getStatuses();
return $statuses[$status] ?? $status;
}
/**
* Get the badge color for a status.
*/
public static function getStatusColor(string $status): string
{
$colors = [
'draft' => '#9CA3AF',
'pending_approval' => '#D97706',
'approved' => '#059669',
'paid' => '#2563EB',
'disputed' => '#DC2626',
];
return $colors[$status] ?? '#6B7280';
}
/**
* Get the Arabic label for a direction.
*/
public static function getDirectionLabel(string $direction): string
{
$directions = self::getDirections();
return $directions[$direction] ?? $direction;
}
/**
* Get the color for a direction.
*/
public static function getDirectionColor(string $direction): string
{
$colors = [
'club_pays_academy' => '#059669',
'academy_pays_club' => '#DC2626',
'balanced' => '#6B7280',
];
return $colors[$direction] ?? '#6B7280';
}
/**
* Search settlements with filters and pagination.
*/
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$query = static::query()
->leftJoin('academies', '`academy_settlements`.`academy_id` = `academies`.`id`')
->leftJoin('academy_contracts', '`academy_settlements`.`contract_id` = `academy_contracts`.`id`')
->select('`academy_settlements`.*, `academies`.`name_ar` as academy_name, `academy_contracts`.`contract_number`');
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$query = $query->whereRaw(
'(`academy_settlements`.`settlement_number` LIKE ? OR `academies`.`name_ar` LIKE ?)',
[$search, $search]
);
}
if (!empty($filters['status'])) {
$query = $query->where('academy_settlements.status', '=', $filters['status']);
}
if (!empty($filters['academy_id'])) {
$query = $query->where('academy_settlements.academy_id', '=', (int) $filters['academy_id']);
}
if (!empty($filters['period_month'])) {
$query = $query->where('academy_settlements.period_month', '=', $filters['period_month']);
}
if (!empty($filters['direction'])) {
$query = $query->where('academy_settlements.direction', '=', $filters['direction']);
}
$query = $query->orderBy('academy_settlements.period_month', 'DESC')
->orderBy('academy_settlements.created_at', 'DESC');
return $query->paginate($perPage, $page);
}
}
<?php
declare(strict_types=1);
return [
['GET', '/academy-contracts', 'AcademyContracts\Controllers\AcademyContractController@index', ['auth'], 'academy_contract.view'],
['GET', '/academy-contracts/create', 'AcademyContracts\Controllers\AcademyContractController@create', ['auth'], 'academy_contract.manage'],
['POST', '/academy-contracts', 'AcademyContracts\Controllers\AcademyContractController@store', ['auth', 'csrf'], 'academy_contract.manage'],
['GET', '/academy-contracts/{id:\d+}', 'AcademyContracts\Controllers\AcademyContractController@show', ['auth'], 'academy_contract.view'],
['GET', '/academy-contracts/{id:\d+}/edit', 'AcademyContracts\Controllers\AcademyContractController@edit', ['auth'], 'academy_contract.manage'],
['POST', '/academy-contracts/{id:\d+}', 'AcademyContracts\Controllers\AcademyContractController@update', ['auth', 'csrf'], 'academy_contract.manage'],
['POST', '/academy-contracts/{id:\d+}/activate', 'AcademyContracts\Controllers\AcademyContractController@activate', ['auth', 'csrf'], 'academy_contract.manage'],
['POST', '/academy-contracts/{id:\d+}/terminate', 'AcademyContracts\Controllers\AcademyContractController@terminate', ['auth', 'csrf'], 'academy_contract.manage'],
['GET', '/academy-settlements', 'AcademyContracts\Controllers\SettlementController@index', ['auth'], 'academy_contract.settlement'],
['GET', '/academy-settlements/{id:\d+}', 'AcademyContracts\Controllers\SettlementController@show', ['auth'], 'academy_contract.settlement'],
['POST', '/academy-settlements/generate', 'AcademyContracts\Controllers\SettlementController@generate', ['auth', 'csrf'], 'academy_contract.settlement'],
['POST', '/academy-settlements/{id:\d+}/approve', 'AcademyContracts\Controllers\SettlementController@approve', ['auth', 'csrf'], 'academy_contract.settlement'],
];
<?php
declare(strict_types=1);
namespace App\Modules\AcademyContracts\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\AcademyContracts\Models\AcademyContract;
final class AcademyContractService
{
/**
* Activate a contract (set status to 'active', record approver).
*/
public static function activate(int $contractId, int $approvedBy): void
{
$contract = AcademyContract::findOrFail($contractId);
$contract->update([
'status' => 'active',
'approved_by' => $approvedBy,
'approved_at' => date('Y-m-d H:i:s'),
]);
EventBus::dispatch('academy.contract.activated', [
'contract_id' => $contractId,
'approved_by' => $approvedBy,
]);
}
/**
* Terminate a contract.
*/
public static function terminate(int $contractId, int $terminatedBy, string $reason): void
{
$contract = AcademyContract::findOrFail($contractId);
$contract->update([
'status' => 'terminated',
'terminated_by' => $terminatedBy,
'terminated_at' => date('Y-m-d H:i:s'),
'termination_reason' => $reason,
]);
EventBus::dispatch('academy.contract.terminated', [
'contract_id' => $contractId,
'terminated_by' => $terminatedBy,
'reason' => $reason,
]);
}
/**
* Generate a unique contract number in the format AC-YYYY-XXXX.
*/
public static function generateContractNumber(): string
{
$db = App::getInstance()->db();
$year = date('Y');
$prefix = 'AC-' . $year . '-';
$lastRow = $db->selectOne(
"SELECT contract_number FROM academy_contracts
WHERE contract_number LIKE ?
ORDER BY contract_number DESC LIMIT 1",
[$prefix . '%']
);
if ($lastRow) {
$lastSeq = (int) substr($lastRow['contract_number'], strlen($prefix));
$nextSeq = $lastSeq + 1;
} else {
$nextSeq = 1;
}
return $prefix . str_pad((string) $nextSeq, 4, '0', STR_PAD_LEFT);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\AcademyContracts\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\AcademyContracts\Models\AcademyContract;
use App\Modules\AcademyContracts\Models\AcademySettlement;
final class SettlementService
{
/**
* Calculate and create a settlement for a contract for a given month.
*
* @param int $contractId
* @param string $month YYYY-MM format
* @return array The settlement data
*/
public static function calculateSettlement(int $contractId, string $month): array
{
$db = App::getInstance()->db();
$contract = AcademyContract::findOrFail($contractId);
$academyId = (int) $contract->academy_id;
// Query activity_subscriptions where the enrollment links to this academy and the month matches
$subscriptionRevenue = $db->selectOne(
"SELECT COALESCE(SUM(asub.total_amount), 0) as total
FROM activity_subscriptions asub
INNER JOIN academy_enrollments ae ON ae.id = asub.enrollment_id
WHERE ae.academy_id = ?
AND asub.subscription_month = ?
AND asub.status IN ('paid', 'pending')
AND asub.is_archived = 0",
[$academyId, $month]
);
$totalRevenue = (float) ($subscriptionRevenue['total'] ?? 0);
$minimumGuarantee = (float) $contract->minimum_revenue_guarantee;
$clubCommissionPct = (float) $contract->club_commission_pct;
$academySharePct = (float) $contract->academy_share_pct;
$penaltyRatePct = (float) $contract->penalty_rate_pct;
$revenueSurplus = 0.0;
$revenueShortfall = 0.0;
$clubCommission = 0.0;
$academyShare = 0.0;
$netAmount = 0.0;
$penaltyAmount = 0.0;
$direction = 'balanced';
if ($totalRevenue >= $minimumGuarantee) {
// Revenue meets or exceeds minimum guarantee
$revenueSurplus = $totalRevenue - $minimumGuarantee;
$academyShare = $revenueSurplus * ($academySharePct / 100);
$clubCommission = $revenueSurplus * ($clubCommissionPct / 100);
$direction = 'club_pays_academy';
$netAmount = $academyShare;
} else {
// Revenue below minimum guarantee
$revenueShortfall = $minimumGuarantee - $totalRevenue;
$direction = 'academy_pays_club';
$netAmount = $revenueShortfall;
// Apply penalty if configured
if ($penaltyRatePct > 0) {
$penaltyAmount = $revenueShortfall * ($penaltyRatePct / 100);
}
}
// If revenue equals minimum exactly and no surplus
if ($totalRevenue == $minimumGuarantee) {
$direction = 'balanced';
$netAmount = 0.0;
}
// Count enrolled and active players for the revenue record
$playerCounts = $db->selectOne(
"SELECT
COUNT(*) as enrolled_count,
SUM(CASE WHEN ae.status = 'active' THEN 1 ELSE 0 END) as active_count
FROM academy_enrollments ae
WHERE ae.academy_id = ?",
[$academyId]
);
// Create or update academy_revenue_records
$existingRecord = $db->selectOne(
"SELECT id FROM academy_revenue_records
WHERE academy_id = ? AND contract_id = ? AND period_month = ?",
[$academyId, $contractId, $month]
);
$revenueData = [
'academy_id' => $academyId,
'contract_id' => $contractId,
'period_month' => $month,
'subscription_revenue' => $totalRevenue,
'registration_revenue' => 0.00,
'other_revenue' => 0.00,
'total_revenue' => $totalRevenue,
'enrolled_count' => (int) ($playerCounts['enrolled_count'] ?? 0),
'active_players_count' => (int) ($playerCounts['active_count'] ?? 0),
'is_finalized' => 1,
'calculated_at' => date('Y-m-d H:i:s'),
];
if ($existingRecord) {
$db->update('academy_revenue_records', $revenueData, 'id = ?', [(int) $existingRecord['id']]);
} else {
$db->insert('academy_revenue_records', $revenueData);
}
// Generate settlement number
$settlementNumber = self::generateSettlementNumber();
// Create academy_settlements record
$settlementData = [
'settlement_number' => $settlementNumber,
'academy_id' => $academyId,
'contract_id' => $contractId,
'period_month' => $month,
'total_revenue' => $totalRevenue,
'minimum_guarantee' => $minimumGuarantee,
'revenue_surplus' => $revenueSurplus,
'revenue_shortfall' => $revenueShortfall,
'club_commission' => $clubCommission,
'academy_share' => $academyShare,
'net_amount' => $netAmount,
'penalty_amount' => $penaltyAmount,
'direction' => $direction,
'status' => 'pending_approval',
];
$settlement = AcademySettlement::create($settlementData);
// Dispatch event
EventBus::dispatch('academy.settlement.created', [
'settlement_id' => $settlement->id,
'contract_id' => $contractId,
'academy_id' => $academyId,
'month' => $month,
'direction' => $direction,
'net_amount' => $netAmount,
]);
return array_merge($settlementData, ['id' => $settlement->id]);
}
/**
* Generate monthly settlements for all active contracts.
*
* @param string $month YYYY-MM format
* @return int Number of settlements generated
*/
public static function generateMonthlySettlements(string $month): int
{
$db = App::getInstance()->db();
// Get all active contracts
$contracts = $db->select(
"SELECT id FROM academy_contracts WHERE status = 'active' AND is_archived = 0"
);
$count = 0;
foreach ($contracts as $contract) {
// Check if settlement already exists for this contract and month
$existing = $db->selectOne(
"SELECT id FROM academy_settlements WHERE contract_id = ? AND period_month = ?",
[(int) $contract['id'], $month]
);
if (!$existing) {
self::calculateSettlement((int) $contract['id'], $month);
$count++;
}
}
return $count;
}
/**
* Approve a settlement.
*/
public static function approveSettlement(int $settlementId, int $approvedBy): void
{
$settlement = AcademySettlement::findOrFail($settlementId);
$settlement->update([
'status' => 'approved',
'approved_by' => $approvedBy,
'approved_at' => date('Y-m-d H:i:s'),
]);
EventBus::dispatch('academy.settlement.approved', [
'settlement_id' => $settlementId,
'approved_by' => $approvedBy,
'contract_id' => (int) $settlement->contract_id,
'academy_id' => (int) $settlement->academy_id,
'direction' => $settlement->direction,
'net_amount' => (float) $settlement->net_amount,
]);
}
/**
* Generate a unique settlement number in the format STL-YYYY-XXXX.
*/
private static function generateSettlementNumber(): string
{
$db = App::getInstance()->db();
$year = date('Y');
$prefix = 'STL-' . $year . '-';
$lastRow = $db->selectOne(
"SELECT settlement_number FROM academy_settlements
WHERE settlement_number LIKE ?
ORDER BY settlement_number DESC LIMIT 1",
[$prefix . '%']
);
if ($lastRow) {
$lastSeq = (int) substr($lastRow['settlement_number'], strlen($prefix));
$nextSeq = $lastSeq + 1;
} else {
$nextSeq = 1;
}
return $prefix . str_pad((string) $nextSeq, 4, '0', STR_PAD_LEFT);
}
}
This diff is collapsed.
This diff is collapsed.
<?php
use App\Modules\AcademyContracts\Models\AcademyContract;
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>عقود الأكاديميات<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/academy-settlements" class="btn btn-outline"><i data-lucide="calculator" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> التسويات</a>
<a href="/academy-contracts/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> عقد جديد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/academy-contracts" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="flex:1;min-width:180px;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="q" value="<?= e($filters['q'] ?? '') ?>" placeholder="رقم العقد أو اسم الأكاديمية..." class="form-input">
</div>
<div style="min-width:150px;">
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-input">
<option value="">الكل</option>
<?php foreach ($statuses as $key => $label): ?>
<option value="<?= e($key) ?>" <?= ($filters['status'] ?? '') === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:150px;">
<label class="form-label" style="font-size:12px;">الأكاديمية</label>
<select name="academy_id" class="form-input">
<option value="">الكل</option>
<?php foreach ($academies as $acad): ?>
<option value="<?= (int) $acad['id'] ?>" <?= ($filters['academy_id'] ?? '') == $acad['id'] ? 'selected' : '' ?>><?= e($acad['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:150px;">
<label class="form-label" style="font-size:12px;">نوع العقد</label>
<select name="contract_type" class="form-input">
<option value="">الكل</option>
<?php foreach ($contractTypes as $key => $label): ?>
<option value="<?= e($key) ?>" <?= ($filters['contract_type'] ?? '') === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> بحث</button>
<a href="/academy-contracts" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<!-- Contracts Table -->
<?php if (!empty($contracts)): ?>
<div class="card" style="overflow:hidden;">
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:14px;">
<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:center;font-weight:600;color:#374151;">الحالة</th>
<th style="padding:12px 15px;text-align:center;font-weight:600;color:#374151;">إجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($contracts as $c):
$statusColor = AcademyContract::getStatusColor($c['status'] ?? '');
$statusLabel = AcademyContract::getStatusLabel($c['status'] ?? '');
$typeLabel = AcademyContract::getContractTypeLabel($c['contract_type'] ?? '');
?>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:12px 15px;">
<code style="font-size:12px;background:#F3F4F6;padding:2px 8px;border-radius:4px;"><?= e($c['contract_number'] ?? '') ?></code>
</td>
<td style="padding:12px 15px;font-weight:500;"><?= e($c['academy_name'] ?? '') ?></td>
<td style="padding:12px 15px;"><?= e($typeLabel) ?></td>
<td style="padding:12px 15px;font-size:12px;color:#6B7280;">
<?= e($c['start_date'] ?? '') ?> - <?= e($c['end_date'] ?? '') ?>
</td>
<td style="padding:12px 15px;direction:ltr;text-align:right;">
<?= number_format((float) ($c['minimum_revenue_guarantee'] ?? 0), 2) ?> ج.م
</td>
<td style="padding:12px 15px;text-align:center;">
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:600;background:<?= $statusColor ?>15;color:<?= $statusColor ?>;">
<?= e($statusLabel) ?>
</span>
</td>
<td style="padding:12px 15px;text-align:center;">
<div style="display:flex;gap:6px;justify-content:center;">
<a href="/academy-contracts/<?= (int) $c['id'] ?>" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="eye" style="width:13px;height:13px;vertical-align:middle;"></i> عرض
</a>
<a href="/academy-contracts/<?= (int) $c['id'] ?>/edit" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="edit-3" style="width:13px;height:13px;vertical-align:middle;"></i> تعديل
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php if (isset($pagination) && ($pagination['last_page'] ?? 1) > 1): ?>
<div style="padding:15px;">
<?php $__template->include('Shared.Components.pagination', ['pagination' => $pagination]); ?>
</div>
<?php endif; ?>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="file-text" style="width:48px;height:48px;color:#D1D5DB;"></i>
</div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا توجد عقود</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0 0 20px;">
<?php if (!empty($filters['q']) || !empty($filters['status']) || !empty($filters['academy_id'])): ?>
لا توجد نتائج مطابقة لبحثك. جرب تغيير معايير البحث.
<?php else: ?>
ابدأ بإنشاء عقد جديد لأكاديمية.
<?php endif; ?>
</p>
<a href="/academy-contracts/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> عقد جديد</a>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});
</script>
<?php $__template->endSection(); ?>
<?php
use App\Modules\AcademyContracts\Models\AcademySettlement;
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>تسويات الأكاديميات<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/academy-contracts" class="btn btn-outline"><i data-lucide="file-text" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العقود</a>
<button type="button" class="btn btn-primary" onclick="document.getElementById('generateModal').style.display='flex'">
<i data-lucide="play" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إنشاء تسويات شهرية
</button>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/academy-settlements" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="flex:1;min-width:180px;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="q" value="<?= e($filters['q'] ?? '') ?>" placeholder="رقم التسوية أو اسم الأكاديمية..." class="form-input">
</div>
<div style="min-width:140px;">
<label class="form-label" style="font-size:12px;">الشهر</label>
<input type="month" name="period_month" value="<?= e($filters['period_month'] ?? '') ?>" class="form-input" style="direction:ltr;text-align:left;">
</div>
<div style="min-width:140px;">
<label class="form-label" style="font-size:12px;">الأكاديمية</label>
<select name="academy_id" class="form-input">
<option value="">الكل</option>
<?php foreach ($academies as $acad): ?>
<option value="<?= (int) $acad['id'] ?>" <?= ($filters['academy_id'] ?? '') == $acad['id'] ? 'selected' : '' ?>><?= e($acad['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:120px;">
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-input">
<option value="">الكل</option>
<?php foreach ($statuses as $key => $label): ?>
<option value="<?= e($key) ?>" <?= ($filters['status'] ?? '') === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:140px;">
<label class="form-label" style="font-size:12px;">الاتجاه</label>
<select name="direction" class="form-input">
<option value="">الكل</option>
<?php foreach ($directions as $key => $label): ?>
<option value="<?= e($key) ?>" <?= ($filters['direction'] ?? '') === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> بحث</button>
<a href="/academy-settlements" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<!-- Settlements Table -->
<?php if (!empty($settlements)): ?>
<div class="card" style="overflow:hidden;">
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:14px;">
<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:center;font-weight:600;color:#374151;">الاتجاه</th>
<th style="padding:12px 15px;text-align:center;font-weight:600;color:#374151;">الحالة</th>
<th style="padding:12px 15px;text-align:center;font-weight:600;color:#374151;">إجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($settlements as $s):
$statusColor = AcademySettlement::getStatusColor($s['status'] ?? '');
$statusLabel = AcademySettlement::getStatusLabel($s['status'] ?? '');
$dirColor = AcademySettlement::getDirectionColor($s['direction'] ?? '');
$dirLabel = AcademySettlement::getDirectionLabel($s['direction'] ?? '');
?>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:12px 15px;">
<code style="font-size:12px;background:#F3F4F6;padding:2px 8px;border-radius:4px;"><?= e($s['settlement_number'] ?? '') ?></code>
</td>
<td style="padding:12px 15px;font-weight:500;"><?= e($s['academy_name'] ?? '') ?></td>
<td style="padding:12px 15px;font-size:13px;"><?= e($s['period_month'] ?? '') ?></td>
<td style="padding:12px 15px;direction:ltr;text-align:right;"><?= number_format((float) ($s['total_revenue'] ?? 0), 2) ?> ج.م</td>
<td style="padding:12px 15px;direction:ltr;text-align:right;font-weight:600;"><?= number_format((float) ($s['net_amount'] ?? 0), 2) ?> ج.م</td>
<td style="padding:12px 15px;text-align:center;">
<span style="font-size:11px;font-weight:600;color:<?= $dirColor ?>;"><?= e($dirLabel) ?></span>
</td>
<td style="padding:12px 15px;text-align:center;">
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:11px;font-weight:600;background:<?= $statusColor ?>15;color:<?= $statusColor ?>;">
<?= e($statusLabel) ?>
</span>
</td>
<td style="padding:12px 15px;text-align:center;">
<a href="/academy-settlements/<?= (int) $s['id'] ?>" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="eye" style="width:13px;height:13px;vertical-align:middle;"></i> عرض
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php if (isset($pagination) && ($pagination['last_page'] ?? 1) > 1): ?>
<div style="padding:15px;">
<?php $__template->include('Shared.Components.pagination', ['pagination' => $pagination]); ?>
</div>
<?php endif; ?>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="calculator" style="width:48px;height:48px;color:#D1D5DB;"></i>
</div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا توجد تسويات</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0 0 20px;">
<?php if (!empty($filters['q']) || !empty($filters['status']) || !empty($filters['academy_id']) || !empty($filters['period_month'])): ?>
لا توجد نتائج مطابقة لبحثك.
<?php else: ?>
لم يتم إنشاء أي تسويات بعد. قم بإنشاء تسويات شهرية للعقود النشطة.
<?php endif; ?>
</p>
</div>
<?php endif; ?>
<!-- Generate Modal -->
<div id="generateModal" style="display:none;position:fixed;inset:0;z-index:9999;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);">
<div class="card" style="width:100%;max-width:450px;margin:20px;">
<div style="padding:20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#0D7377;font-size:16px;"><i data-lucide="calculator" style="width:18px;height:18px;vertical-align:middle;margin-left:4px;"></i> إنشاء تسويات شهرية</h3>
</div>
<form method="POST" action="/academy-settlements/generate">
<?= csrf_field() ?>
<div style="padding:20px;">
<p style="color:#6B7280;font-size:14px;margin:0 0 15px;">سيتم حساب التسويات لجميع العقود النشطة للشهر المحدد.</p>
<div class="form-group">
<label class="form-label">الشهر <span style="color:#DC2626;">*</span></label>
<input type="month" name="period_month" class="form-input" required style="direction:ltr;text-align:left;" value="<?= date('Y-m') ?>">
</div>
</div>
<div style="padding:15px 20px;border-top:1px solid #E5E7EB;display:flex;gap:10px;justify-content:flex-end;">
<button type="button" class="btn btn-outline" onclick="document.getElementById('generateModal').style.display='none'">إلغاء</button>
<button type="submit" class="btn btn-primary">إنشاء التسويات</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});
</script>
<?php $__template->endSection(); ?>
This diff is collapsed.
This diff is collapsed.
<?php
declare(strict_types=1);
use App\Core\Registries\PermissionRegistry;
use App\Core\Registries\MenuRegistry;
// ────────────────────────────────────────────────────────────
// Academy Contracts — Permissions & Sidebar Menu
// ────────────────────────────────────────────────────────────
PermissionRegistry::register('academy_contracts', [
'academy_contract.view' => ['ar' => 'عرض عقود الأكاديميات', 'en' => 'View Academy Contracts'],
'academy_contract.manage' => ['ar' => 'إدارة عقود الأكاديميات', 'en' => 'Manage Academy Contracts'],
'academy_contract.settlement' => ['ar' => 'تسويات عقود الأكاديميات', 'en' => 'Academy Contract Settlements'],
]);
MenuRegistry::register('academy_contracts', [
'label_ar' => 'عقود الأكاديميات',
'label_en' => 'Academy Contracts',
'icon' => 'file-signature',
'route' => '/academy-contracts',
'permission' => 'academy_contract.view',
'order' => 398,
'parent' => 'sports_activities',
]);
<?php
declare(strict_types=1);
namespace App\Modules\Alerts\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Alerts\Models\AlertRule;
use App\Modules\Alerts\Models\AlertLog;
class AlertController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('alert.view');
$rules = AlertRule::query()
->orderBy('category', 'ASC')
->orderBy('name_ar', 'ASC')
->get();
$categories = AlertRule::getCategories();
$triggerTypes = AlertRule::getTriggerTypes();
return $this->view('Alerts.Views.index', [
'rules' => $rules,
'categories' => $categories,
'triggerTypes' => $triggerTypes,
]);
}
public function log(Request $request): Response
{
$this->authorize('alert.view');
$db = App::getInstance()->db();
$logs = $db->select(
"SELECT al.*, ar.name_ar AS rule_name, ar.category
FROM alert_log al
LEFT JOIN alert_rules ar ON ar.id = al.alert_rule_id
ORDER BY al.triggered_at DESC
LIMIT 100"
);
return $this->view('Alerts.Views.log', [
'logs' => $logs,
'categories' => AlertRule::getCategories(),
]);
}
public function toggle(Request $request, int $id): Response
{
$this->authorize('alert.manage');
$rule = AlertRule::find($id);
if (!$rule) {
return $this->redirect('/alerts')->withError('القاعدة غير موجودة');
}
$db = App::getInstance()->db();
$newStatus = ((int) $rule->is_active === 1) ? 0 : 1;
$db->update('alert_rules', ['is_active' => $newStatus], 'id = ?', [$id]);
$msg = $newStatus === 1 ? 'تم تفعيل التنبيه' : 'تم إيقاف التنبيه';
return $this->redirect('/alerts')->withSuccess($msg);
}
public function acknowledge(Request $request, int $id): Response
{
$this->authorize('alert.view');
$db = App::getInstance()->db();
$log = $db->selectOne("SELECT * FROM alert_log WHERE id = ?", [$id]);
if (!$log) {
return $this->redirect('/alerts/log')->withError('السجل غير موجود');
}
$session = App::getInstance()->session();
$db->update('alert_log', [
'status' => 'acknowledged',
'acknowledged_by' => $session->get('employee_id'),
'acknowledged_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$id]);
return $this->redirect('/alerts/log')->withSuccess('تم تأكيد الإطلاع على التنبيه');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Alerts\Models;
use App\Core\Model;
class AlertLog extends Model
{
protected static string $table = 'alert_log';
protected static string $primaryKey = 'id';
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static array $fillable = [
'alert_rule_id',
'triggered_at',
'context_json',
'recipients_json',
'status',
'acknowledged_by',
'acknowledged_at',
];
}
<?php
declare(strict_types=1);
namespace App\Modules\Alerts\Models;
use App\Core\Model;
class AlertRule extends Model
{
protected static string $table = 'alert_rules';
protected static string $primaryKey = 'id';
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static array $fillable = [
'code',
'name_ar',
'category',
'trigger_type',
'trigger_config_json',
'notification_channels',
'recipient_roles_json',
'is_active',
'last_triggered_at',
];
public static function getCategories(): array
{
return [
'medical' => 'طبي',
'contracts' => 'عقود',
'financial' => 'مالي',
'attendance' => 'حضور',
'capacity' => 'سعة',
'subscriptions' => 'اشتراكات',
'safety' => 'سلامة',
];
}
public static function getTriggerTypes(): array
{
return [
'threshold' => 'عتبة / حد',
'expiry' => 'انتهاء صلاحية',
'schedule' => 'مجدول',
];
}
public static function allActive(): array
{
return static::query()
->where('is_active', '=', 1)
->orderBy('category', 'ASC')
->orderBy('name_ar', 'ASC')
->get();
}
}
<?php
declare(strict_types=1);
return [
['GET', '/alerts', 'Alerts\Controllers\AlertController@index', ['auth'], 'alert.view'],
['GET', '/alerts/log', 'Alerts\Controllers\AlertController@log', ['auth'], 'alert.view'],
['POST', '/alerts/{id:\d+}/toggle', 'Alerts\Controllers\AlertController@toggle', ['auth', 'csrf'], 'alert.manage'],
['POST', '/alerts/log/{id:\d+}/acknowledge', 'Alerts\Controllers\AlertController@acknowledge', ['auth', 'csrf'], 'alert.view'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Alerts\Services;
use App\Core\App;
use App\Modules\Alerts\Models\AlertRule;
final class AlertProcessorService
{
/**
* Process all active alert rules and return triggered alerts.
*/
public static function processAll(): array
{
$rules = AlertRule::allActive();
$triggered = [];
foreach ($rules as $rule) {
$results = [];
switch ($rule['category']) {
case 'medical':
$results = self::checkMedicalCertExpiry($rule);
break;
case 'contracts':
$results = self::checkContractExpiry($rule);
break;
case 'attendance':
$results = self::checkAttendanceDropping($rule);
break;
case 'capacity':
$results = self::checkGroupNearCapacity($rule);
break;
}
if (!empty($results)) {
foreach ($results as $context) {
self::logAlert((int) $rule['id'], $context);
}
$triggered[] = [
'rule' => $rule,
'count' => count($results),
];
// Update last_triggered_at
$db = App::getInstance()->db();
$db->update('alert_rules', [
'last_triggered_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $rule['id']]);
}
}
return $triggered;
}
/**
* Check for player medical records expiring within configured days.
*/
public static function checkMedicalCertExpiry(array $rule): array
{
$db = App::getInstance()->db();
$config = json_decode($rule['trigger_config_json'] ?? '{}', true);
$days = (int) ($config['days_before'] ?? 30);
$rows = $db->select(
"SELECT pmr.id, pmr.player_id, pmr.expiry_date, p.full_name_ar AS player_name
FROM player_medical_records pmr
LEFT JOIN players p ON p.id = pmr.player_id
WHERE pmr.expiry_date IS NOT NULL
AND pmr.expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY)
AND pmr.is_current = 1",
[$days]
);
$results = [];
foreach ($rows as $row) {
$results[] = [
'type' => 'medical_expiry',
'player_id' => $row['player_id'],
'player_name' => $row['player_name'],
'expiry_date' => $row['expiry_date'],
'message' => "الشهادة الطبية للاعب {$row['player_name']} تنتهي في {$row['expiry_date']}",
];
}
return $results;
}
/**
* Check for academy contracts expiring soon.
*/
public static function checkContractExpiry(array $rule): array
{
$db = App::getInstance()->db();
$config = json_decode($rule['trigger_config_json'] ?? '{}', true);
$days = (int) ($config['days_before'] ?? 30);
$rows = $db->select(
"SELECT ac.id, ac.academy_id, ac.end_date, a.name_ar AS academy_name
FROM academy_contracts ac
LEFT JOIN academies a ON a.id = ac.academy_id
WHERE ac.end_date IS NOT NULL
AND ac.end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY)
AND ac.status = 'active'",
[$days]
);
$results = [];
foreach ($rows as $row) {
$results[] = [
'type' => 'contract_expiry',
'academy_id' => $row['academy_id'],
'academy_name' => $row['academy_name'],
'end_date' => $row['end_date'],
'message' => "عقد أكاديمية {$row['academy_name']} ينتهي في {$row['end_date']}",
];
}
return $results;
}
/**
* Check for players with 3+ consecutive absences.
*/
public static function checkAttendanceDropping(array $rule): array
{
$db = App::getInstance()->db();
$config = json_decode($rule['trigger_config_json'] ?? '{}', true);
$threshold = (int) ($config['consecutive_absences'] ?? 3);
$rows = $db->select(
"SELECT sa.player_id, p.full_name_ar AS player_name, COUNT(*) AS absence_count
FROM session_attendance sa
LEFT JOIN players p ON p.id = sa.player_id
WHERE sa.status = 'absent'
AND sa.session_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY sa.player_id
HAVING absence_count >= ?
ORDER BY absence_count DESC",
[$threshold]
);
$results = [];
foreach ($rows as $row) {
$results[] = [
'type' => 'attendance_dropping',
'player_id' => $row['player_id'],
'player_name' => $row['player_name'],
'absence_count' => $row['absence_count'],
'message' => "اللاعب {$row['player_name']} لديه {$row['absence_count']} غيابات متتالية",
];
}
return $results;
}
/**
* Check for training groups at 90%+ capacity.
*/
public static function checkGroupNearCapacity(array $rule): array
{
$db = App::getInstance()->db();
$config = json_decode($rule['trigger_config_json'] ?? '{}', true);
$threshold = (int) ($config['capacity_percent'] ?? 90);
$rows = $db->select(
"SELECT tg.id, tg.name_ar, tg.max_capacity,
COUNT(tgp.id) AS current_count
FROM training_groups tg
LEFT JOIN training_group_players tgp ON tgp.group_id = tg.id AND tgp.is_active = 1
WHERE tg.is_active = 1 AND tg.max_capacity > 0
GROUP BY tg.id
HAVING (current_count / tg.max_capacity * 100) >= ?",
[$threshold]
);
$results = [];
foreach ($rows as $row) {
$pct = $row['max_capacity'] > 0
? round(($row['current_count'] / $row['max_capacity']) * 100)
: 0;
$results[] = [
'type' => 'capacity_near_full',
'group_id' => $row['id'],
'group_name' => $row['name_ar'],
'current_count' => $row['current_count'],
'max_capacity' => $row['max_capacity'],
'percent' => $pct,
'message' => "المجموعة {$row['name_ar']} وصلت {$pct}% من السعة ({$row['current_count']}/{$row['max_capacity']})",
];
}
return $results;
}
/**
* Log a triggered alert to the alert_log table.
*/
public static function logAlert(int $ruleId, array $context): void
{
$db = App::getInstance()->db();
$db->insert('alert_log', [
'alert_rule_id' => $ruleId,
'triggered_at' => date('Y-m-d H:i:s'),
'context_json' => json_encode($context, JSON_UNESCAPED_UNICODE),
'recipients_json' => null,
'status' => 'sent',
]);
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>التنبيهات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="margin:0;color:#0D7377;">قواعد التنبيهات</h2>
<a href="/alerts/log" class="btn btn-outline" style="display:inline-flex;align-items:center;gap:6px;">
<i data-lucide="list" style="width:16px;height:16px;"></i>
سجل التنبيهات
</a>
</div>
<?php if (empty($rules)): ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">
<i data-lucide="bell-off" style="width:48px;height:48px;margin-bottom:12px;opacity:0.5;"></i>
<p>لا توجد قواعد تنبيهات مسجلة بعد.</p>
</div>
<?php else: ?>
<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>
</tr>
</thead>
<tbody>
<?php foreach ($rules as $rule): ?>
<tr>
<td style="font-weight:600;"><?= e($rule['name_ar']) ?></td>
<td>
<span style="background:#E0F2FE;color:#0369A1;padding:3px 10px;border-radius:12px;font-size:12px;">
<?= e($categories[$rule['category']] ?? $rule['category']) ?>
</span>
</td>
<td>
<span style="background:#F3F4F6;padding:2px 8px;border-radius:4px;font-size:12px;">
<?= e($triggerTypes[$rule['trigger_type']] ?? $rule['trigger_type']) ?>
</span>
</td>
<td style="font-size:13px;"><?= e($rule['notification_channels']) ?></td>
<td>
<?php if ((int)$rule['is_active'] === 1): ?>
<span style="background:#D1FAE5;color:#065F46;padding:3px 10px;border-radius:12px;font-size:12px;">مفعّل</span>
<?php else: ?>
<span style="background:#FEE2E2;color:#991B1B;padding:3px 10px;border-radius:12px;font-size:12px;">متوقف</span>
<?php endif; ?>
</td>
<td style="font-size:13px;color:#6B7280;">
<?= $rule['last_triggered_at'] ? e($rule['last_triggered_at']) : '—' ?>
</td>
<td>
<form method="POST" action="/alerts/<?= (int)$rule['id'] ?>/toggle" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm <?= (int)$rule['is_active'] === 1 ? 'btn-outline-danger' : 'btn-outline-success' ?>" style="font-size:12px;">
<?= (int)$rule['is_active'] === 1 ? 'إيقاف' : 'تفعيل' ?>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>سجل التنبيهات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="margin:0;color:#0D7377;">سجل التنبيهات</h2>
<a href="/alerts" class="btn btn-outline" style="display:inline-flex;align-items:center;gap:6px;">
<i data-lucide="settings" style="width:16px;height:16px;"></i>
قواعد التنبيهات
</a>
</div>
<?php if (empty($logs)): ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">
<i data-lucide="inbox" style="width:48px;height:48px;margin-bottom:12px;opacity:0.5;"></i>
<p>لا توجد تنبيهات مسجلة بعد.</p>
</div>
<?php else: ?>
<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>
</tr>
</thead>
<tbody>
<?php foreach ($logs as $log): ?>
<?php
$context = json_decode($log['context_json'] ?? '{}', true);
$statusColors = [
'sent' => 'background:#FEF3C7;color:#92400E;',
'failed' => 'background:#FEE2E2;color:#991B1B;',
'acknowledged' => 'background:#D1FAE5;color:#065F46;',
];
$statusLabels = [
'sent' => 'مرسل',
'failed' => 'فشل',
'acknowledged' => 'تم الإطلاع',
];
?>
<tr>
<td style="font-weight:600;"><?= e($log['rule_name'] ?? '—') ?></td>
<td>
<span style="background:#E0F2FE;color:#0369A1;padding:3px 10px;border-radius:12px;font-size:12px;">
<?= e($categories[$log['category'] ?? ''] ?? ($log['category'] ?? '—')) ?>
</span>
</td>
<td style="font-size:13px;"><?= e($log['triggered_at']) ?></td>
<td style="font-size:13px;max-width:300px;overflow:hidden;text-overflow:ellipsis;">
<?= e($context['message'] ?? '—') ?>
</td>
<td>
<span style="<?= $statusColors[$log['status']] ?? '' ?>padding:3px 10px;border-radius:12px;font-size:12px;">
<?= e($statusLabels[$log['status']] ?? $log['status']) ?>
</span>
</td>
<td>
<?php if ($log['status'] === 'sent'): ?>
<form method="POST" action="/alerts/log/<?= (int)$log['id'] ?>/acknowledge" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-outline" style="font-size:12px;">
تأكيد الإطلاع
</button>
</form>
<?php elseif ($log['status'] === 'acknowledged'): ?>
<span style="color:#6B7280;font-size:12px;">✓ تم</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php
declare(strict_types=1);
use App\Core\Registries\PermissionRegistry;
use App\Core\Registries\MenuRegistry;
PermissionRegistry::register('alerts', [
'alert.view' => ['ar' => 'عرض التنبيهات', 'en' => 'View Alerts'],
'alert.manage' => ['ar' => 'إدارة التنبيهات', 'en' => 'Manage Alerts'],
]);
MenuRegistry::register('alerts', [
'label_ar' => 'التنبيهات',
'icon' => 'bell',
'route' => '/alerts',
'permission' => 'alert.view',
'order' => 950,
'parent' => 'settings',
]);
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\Coaches\Models;
use App\Core\Model;
use App\Core\App;
class Coach extends Model
{
protected static string $table = 'coaches';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'code',
'full_name_ar',
'full_name_en',
'national_id',
'phone',
'email',
'date_of_birth',
'gender',
'photo_path',
'bio_ar',
'certifications_json',
'employment_type',
'employee_id',
'max_players_default',
'hourly_rate',
'session_rate',
'monthly_rate',
'payment_model',
'is_active',
];
public static function getEmploymentTypes(): array
{
return [
'staff' => 'موظف (عقد دائم)',
'contract' => 'متعاقد',
'freelance' => 'حر (فريلانس)',
];
}
public static function getPaymentModels(): array
{
return [
'per_session' => 'بالحصة',
'per_player' => 'بعدد اللاعبين',
'monthly_fixed' => 'راتب شهري ثابت',
'hybrid' => 'مختلط (ثابت + حوافز)',
];
}
public static function getRoles(): array
{
return [
'head_coach' => 'مدرب أول',
'coach' => 'مدرب',
'assistant' => 'مدرب مساعد',
];
}
public static function getSessionTypes(): array
{
return [
'private' => 'خاص (فردي)',
'small_group' => 'مجموعة صغيرة (2-5)',
'group' => 'مجموعة (6-15)',
'team' => 'فريق (16+)',
];
}
public function getCertifications(): array
{
$raw = $this->certifications_json;
if (empty($raw)) {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
public function getDisciplines(): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT cd.*, sd.name_ar AS discipline_name, sd.icon AS discipline_icon
FROM coach_disciplines cd
LEFT JOIN sport_disciplines sd ON sd.id = cd.discipline_id
WHERE cd.coach_id = ?
ORDER BY cd.is_primary DESC, sd.name_ar ASC",
[(int) $this->id]
);
}
public function getAcademyAssignments(): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT caa.*, a.name_ar AS academy_name, al.name_ar AS level_name
FROM coach_academy_assignments caa
LEFT JOIN academies a ON a.id = caa.academy_id
LEFT JOIN academy_levels al ON al.id = caa.level_id
WHERE caa.coach_id = ? AND caa.is_active = 1
ORDER BY caa.assigned_from DESC",
[(int) $this->id]
);
}
public function getAvailability(): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM coach_availability
WHERE coach_id = ? AND is_available = 1
AND (effective_from IS NULL OR effective_from <= CURDATE())
AND (effective_to IS NULL OR effective_to >= CURDATE())
ORDER BY day_of_week ASC, start_time ASC",
[(int) $this->id]
);
}
public static function allActive(): array
{
return static::query()
->where('is_active', '=', 1)
->orderBy('full_name_ar', 'ASC')
->get();
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$query = static::query();
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$query = $query->whereRaw(
'(`full_name_ar` LIKE ? OR `full_name_en` LIKE ? OR `code` LIKE ? OR `phone` LIKE ?)',
[$search, $search, $search, $search]
);
}
if (!empty($filters['employment_type'])) {
$query = $query->where('employment_type', '=', $filters['employment_type']);
}
if (!empty($filters['payment_model'])) {
$query = $query->where('payment_model', '=', $filters['payment_model']);
}
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query = $query->where('is_active', '=', (int) $filters['is_active']);
}
$query = $query->orderBy('full_name_ar', 'ASC');
return $query->paginate($perPage, $page);
}
public static function getByDiscipline(int $disciplineId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT c.* FROM coaches c
INNER JOIN coach_disciplines cd ON cd.coach_id = c.id
WHERE cd.discipline_id = ? AND c.is_active = 1 AND c.is_archived = 0
ORDER BY c.full_name_ar ASC",
[$disciplineId]
);
}
}
<?php
declare(strict_types=1);
return [
['GET', '/coaches', 'Coaches\Controllers\CoachController@index', ['auth'], 'coach.view'],
['GET', '/coaches/create', 'Coaches\Controllers\CoachController@create', ['auth'], 'coach.manage'],
['POST', '/coaches', 'Coaches\Controllers\CoachController@store', ['auth', 'csrf'], 'coach.manage'],
['GET', '/coaches/{id:\d+}', 'Coaches\Controllers\CoachController@show', ['auth'], 'coach.view'],
['GET', '/coaches/{id:\d+}/edit', 'Coaches\Controllers\CoachController@edit', ['auth'], 'coach.manage'],
['POST', '/coaches/{id:\d+}', 'Coaches\Controllers\CoachController@update', ['auth', 'csrf'], 'coach.manage'],
['POST', '/coaches/{id:\d+}/toggle', 'Coaches\Controllers\CoachController@toggle', ['auth', 'csrf'], 'coach.manage'],
['POST', '/coaches/{id:\d+}/assign', 'Coaches\Controllers\CoachController@assign', ['auth', 'csrf'], 'coach.manage'],
['POST', '/coaches/{id:\d+}/unassign', 'Coaches\Controllers\CoachController@unassign', ['auth', 'csrf'], 'coach.manage'],
['POST', '/coaches/{id:\d+}/availability', 'Coaches\Controllers\CoachController@saveAvailability', ['auth', 'csrf'], 'coach.manage'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Coaches\Services;
use App\Core\App;
use App\Core\EventBus;
final class CoachPaymentService
{
/**
* Calculate payment for a specific coach and period.
*
* @param int $coachId Coach ID
* @param string $period YYYY-MM format
* @return array Payment calculation details
*/
public static function calculatePayment(int $coachId, string $period): array
{
$db = App::getInstance()->db();
// Get coach details
$coach = $db->selectOne("SELECT * FROM coaches WHERE id = ? AND is_archived = 0", [$coachId]);
if (!$coach) {
return [];
}
$paymentModel = $coach['payment_model'] ?? 'per_session';
$sessionRate = (float) ($coach['session_rate'] ?? 0);
$monthlyRate = (float) ($coach['monthly_rate'] ?? 0);
$hourlyRate = (float) ($coach['hourly_rate'] ?? 0);
// Get sessions for the period
$periodStart = $period . '-01';
$periodEnd = date('Y-m-t', strtotime($periodStart));
$sessions = $db->select(
"SELECT ts.id, ts.session_date, ts.status,
(SELECT COUNT(*) FROM session_attendance sa WHERE sa.session_id = ts.id AND sa.status = 'present') AS players_attended
FROM training_sessions ts
WHERE ts.coach_id = ?
AND ts.session_date BETWEEN ? AND ?
AND ts.status = 'completed'
ORDER BY ts.session_date ASC",
[$coachId, $periodStart, $periodEnd]
);
$sessionsConducted = count($sessions);
$totalPlayersServed = 0;
foreach ($sessions as $s) {
$totalPlayersServed += (int) $s['players_attended'];
}
$baseAmount = 0.0;
$bonus = 0.0;
$deductions = 0.0;
$calculationDetails = [];
switch ($paymentModel) {
case 'per_session':
$baseAmount = $sessionsConducted * $sessionRate;
$calculationDetails = [
'model' => 'per_session',
'sessions' => $sessionsConducted,
'rate_per_session' => $sessionRate,
'formula' => "{$sessionsConducted} sessions x {$sessionRate} EGP",
];
break;
case 'per_player':
$perPlayerRate = $hourlyRate > 0 ? $hourlyRate : $sessionRate;
$baseAmount = $totalPlayersServed * $perPlayerRate;
$calculationDetails = [
'model' => 'per_player',
'total_players' => $totalPlayersServed,
'rate_per_player' => $perPlayerRate,
'formula' => "{$totalPlayersServed} players x {$perPlayerRate} EGP",
];
break;
case 'monthly_fixed':
$baseAmount = $monthlyRate;
$calculationDetails = [
'model' => 'monthly_fixed',
'monthly_rate' => $monthlyRate,
'formula' => "Fixed monthly: {$monthlyRate} EGP",
];
break;
case 'hybrid':
$baseAmount = $monthlyRate;
// Bonus for sessions over a threshold (e.g., 20 sessions/month)
$sessionThreshold = 20;
$extraSessions = max(0, $sessionsConducted - $sessionThreshold);
$bonus = $extraSessions * $sessionRate;
$calculationDetails = [
'model' => 'hybrid',
'base_monthly' => $monthlyRate,
'session_threshold' => $sessionThreshold,
'extra_sessions' => $extraSessions,
'bonus_rate' => $sessionRate,
'formula' => "Base {$monthlyRate} + ({$extraSessions} extra sessions x {$sessionRate})",
];
break;
}
$netAmount = $baseAmount + $bonus - $deductions;
return [
'sessions_conducted' => $sessionsConducted,
'total_players_served' => $totalPlayersServed,
'base_amount' => round($baseAmount, 2),
'bonus' => round($bonus, 2),
'deductions' => round($deductions, 2),
'net_amount' => round($netAmount, 2),
'calculation_json' => $calculationDetails,
];
}
/**
* Generate monthly payments for all active coaches.
*
* @param string $period YYYY-MM format
* @return int Number of payments generated
*/
public static function generateMonthlyPayments(string $period): int
{
$db = App::getInstance()->db();
$coaches = $db->select(
"SELECT id, payment_model FROM coaches WHERE is_active = 1 AND is_archived = 0"
);
$count = 0;
foreach ($coaches as $coach) {
$coachId = (int) $coach['id'];
// Skip if already generated for this period
$existing = $db->selectOne(
"SELECT id FROM coach_payments WHERE coach_id = ? AND payment_period = ?",
[$coachId, $period]
);
if ($existing) {
continue;
}
$calc = self::calculatePayment($coachId, $period);
if (empty($calc)) {
continue;
}
$db->insert('coach_payments', [
'coach_id' => $coachId,
'payment_period' => $period,
'payment_model' => $coach['payment_model'] ?? 'per_session',
'sessions_conducted' => $calc['sessions_conducted'],
'total_players_served' => $calc['total_players_served'],
'base_amount' => $calc['base_amount'],
'bonus' => $calc['bonus'],
'deductions' => $calc['deductions'],
'net_amount' => $calc['net_amount'],
'calculation_json' => json_encode($calc['calculation_json'], JSON_UNESCAPED_UNICODE),
'status' => 'draft',
]);
$count++;
}
return $count;
}
/**
* Approve a coach payment.
*/
public static function approvePayment(int $paymentId, int $approvedBy): void
{
$db = App::getInstance()->db();
$payment = $db->selectOne("SELECT * FROM coach_payments WHERE id = ?", [$paymentId]);
if (!$payment || $payment['status'] !== 'draft') {
return;
}
$db->update('coach_payments', [
'status' => 'approved',
'approved_by' => $approvedBy,
'approved_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$paymentId]);
EventBus::dispatch('coach.payment.approved', [
'payment_id' => $paymentId,
'coach_id' => (int) $payment['coach_id'],
'period' => $payment['payment_period'],
'net_amount' => (float) $payment['net_amount'],
'approved_by' => $approvedBy,
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Coaches\Services;
use App\Core\App;
final class CoachSchedulingService
{
public static function isAvailable(int $coachId, int $dayOfWeek, string $startTime, string $endTime, ?string $date = null): bool
{
$db = App::getInstance()->db();
$availability = $db->selectOne(
"SELECT id FROM coach_availability
WHERE coach_id = ? AND day_of_week = ? AND is_available = 1
AND start_time <= ? AND end_time >= ?
AND (effective_from IS NULL OR effective_from <= COALESCE(?, CURDATE()))
AND (effective_to IS NULL OR effective_to >= COALESCE(?, CURDATE()))",
[$coachId, $dayOfWeek, $startTime, $endTime, $date, $date]
);
if (!$availability) {
return false;
}
$conflicts = self::getConflicts($coachId, $dayOfWeek, $startTime, $endTime);
return empty($conflicts);
}
public static function findAvailableCoaches(int $dayOfWeek, string $startTime, string $endTime, ?int $disciplineId = null): array
{
$db = App::getInstance()->db();
$sql = "SELECT DISTINCT c.id, c.full_name_ar, c.code, c.phone, c.max_players_default
FROM coaches c
INNER JOIN coach_availability ca ON ca.coach_id = c.id
WHERE c.is_active = 1 AND c.is_archived = 0
AND ca.day_of_week = ? AND ca.is_available = 1
AND ca.start_time <= ? AND ca.end_time >= ?
AND (ca.effective_from IS NULL OR ca.effective_from <= CURDATE())
AND (ca.effective_to IS NULL OR ca.effective_to >= CURDATE())";
$params = [$dayOfWeek, $startTime, $endTime];
if ($disciplineId !== null) {
$sql .= " AND EXISTS (
SELECT 1 FROM coach_disciplines cd WHERE cd.coach_id = c.id AND cd.discipline_id = ?
)";
$params[] = $disciplineId;
}
$sql .= " ORDER BY c.full_name_ar ASC";
$coaches = $db->select($sql, $params);
return array_filter($coaches, function ($coach) use ($dayOfWeek, $startTime, $endTime) {
$conflicts = self::getConflicts((int) $coach['id'], $dayOfWeek, $startTime, $endTime);
return empty($conflicts);
});
}
public static function getConflicts(int $coachId, int $dayOfWeek, string $startTime, string $endTime, ?int $excludeScheduleId = null): array
{
$db = App::getInstance()->db();
$sql = "SELECT asched.id, asched.start_time, asched.end_time, a.name_ar AS academy_name
FROM academy_schedules asched
LEFT JOIN academies a ON a.id = asched.academy_id
WHERE asched.coach_id = ? AND asched.day_of_week = ? AND asched.is_active = 1
AND asched.start_time < ? AND asched.end_time > ?";
$params = [$coachId, $dayOfWeek, $endTime, $startTime];
if ($excludeScheduleId !== null) {
$sql .= " AND asched.id != ?";
$params[] = $excludeScheduleId;
}
return $db->select($sql, $params);
}
public static function getUtilization(int $coachId, string $monthStart, string $monthEnd): array
{
$db = App::getInstance()->db();
$schedules = $db->selectOne(
"SELECT COUNT(*) AS total_schedules,
SUM(TIMESTAMPDIFF(MINUTE, start_time, end_time)) AS total_minutes
FROM academy_schedules
WHERE coach_id = ? AND is_active = 1",
[$coachId]
);
$assignments = $db->selectOne(
"SELECT COUNT(*) AS total_assignments
FROM coach_academy_assignments
WHERE coach_id = ? AND is_active = 1",
[$coachId]
);
return [
'total_schedules' => (int) ($schedules['total_schedules'] ?? 0),
'total_minutes' => (int) ($schedules['total_minutes'] ?? 0),
'total_hours' => round(((int) ($schedules['total_minutes'] ?? 0)) / 60, 1),
'total_assignments' => (int) ($assignments['total_assignments'] ?? 0),
];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Coaches\Services;
use App\Core\App;
use App\Modules\Coaches\Models\Coach;
final class CoachService
{
public static function createCoach(array $data, array $disciplineIds = []): Coach
{
if (empty($data['code'])) {
$base = $data['full_name_en'] ?? $data['full_name_ar'] ?? 'COACH';
$code = strtoupper(preg_replace('/[^A-Z0-9]/i', '_', $base));
$data['code'] = 'C_' . substr($code, 0, 20) . '_' . time() % 10000;
}
$data['code'] = strtoupper($data['code']);
$existing = Coach::query()->where('code', '=', $data['code'])->first();
if ($existing) {
$data['code'] = $data['code'] . '_' . bin2hex(random_bytes(2));
}
$coach = Coach::create($data);
if (!empty($disciplineIds)) {
self::syncDisciplines((int) $coach->id, $disciplineIds);
}
return $coach;
}
public static function syncDisciplines(int $coachId, array $disciplineIds, ?int $primaryId = null): void
{
$db = App::getInstance()->db();
$db->delete('coach_disciplines', 'coach_id = ?', [$coachId]);
foreach ($disciplineIds as $discId) {
$db->insert('coach_disciplines', [
'coach_id' => $coachId,
'discipline_id' => (int) $discId,
'is_primary' => ($primaryId !== null && (int) $discId === $primaryId) ? 1 : 0,
'specialization_level' => 'general',
]);
}
}
public static function assignToAcademy(int $coachId, int $academyId, ?int $levelId, string $role, ?int $maxPlayers, string $from, ?string $to = null): void
{
$db = App::getInstance()->db();
$db->insert('coach_academy_assignments', [
'coach_id' => $coachId,
'academy_id' => $academyId,
'level_id' => $levelId,
'role' => $role,
'max_players' => $maxPlayers,
'assigned_from' => $from,
'assigned_to' => $to,
'is_active' => 1,
]);
}
public static function unassignFromAcademy(int $assignmentId): void
{
$db = App::getInstance()->db();
$db->update('coach_academy_assignments', [
'is_active' => 0,
'assigned_to' => date('Y-m-d'),
], 'id = ?', [$assignmentId]);
}
public static function setAvailability(int $coachId, array $slots): void
{
$db = App::getInstance()->db();
$db->delete('coach_availability', 'coach_id = ?', [$coachId]);
foreach ($slots as $slot) {
$db->insert('coach_availability', [
'coach_id' => $coachId,
'day_of_week' => (int) $slot['day_of_week'],
'start_time' => $slot['start_time'],
'end_time' => $slot['end_time'],
'is_available' => 1,
'effective_from' => $slot['effective_from'] ?? null,
'effective_to' => $slot['effective_to'] ?? null,
]);
}
}
}
This diff is collapsed.
This diff is collapsed.
<?php
use App\Modules\Coaches\Models\Coach;
$__template->layout('Layout.main');
?>
<?php $__template->section('title'); ?>المدربين<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/coaches/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إضافة مدرب</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php $currentType = $filters['employment_type'] ?? ''; ?>
<!-- Employment Type Filter Tabs -->
<div class="card" style="margin-bottom:20px;padding:0;">
<div style="display:flex;align-items:center;gap:0;overflow-x:auto;border-bottom:2px solid #E5E7EB;">
<a href="/coaches"
style="padding:12px 20px;font-size:14px;font-weight:600;text-decoration:none;border-bottom:3px solid <?= $currentType === '' ? '#0D7377' : 'transparent' ?>;color:<?= $currentType === '' ? '#0D7377' : '#6B7280' ?>;white-space:nowrap;">
الكل
</a>
<?php foreach ($employmentTypes as $typeKey => $typeLabel): ?>
<a href="/coaches?employment_type=<?= e($typeKey) ?><?= ($filters['q'] ?? '') !== '' ? '&q=' . urlencode($filters['q']) : '' ?>"
style="padding:12px 20px;font-size:14px;font-weight:600;text-decoration:none;border-bottom:3px solid <?= $currentType === $typeKey ? '#0D7377' : 'transparent' ?>;color:<?= $currentType === $typeKey ? '#0D7377' : '#6B7280' ?>;white-space:nowrap;">
<?= e($typeLabel) ?>
</a>
<?php endforeach; ?>
</div>
</div>
<!-- Search Bar -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/coaches" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="flex:1;min-width:200px;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="q" value="<?= e($filters['q'] ?? '') ?>" placeholder="ابحث بالاسم أو الكود أو الهاتف..." class="form-input" style="min-width:200px;">
</div>
<div style="min-width:160px;">
<label class="form-label" style="font-size:12px;">نموذج الدفع</label>
<select name="payment_model" class="form-input">
<option value="">الكل</option>
<?php foreach ($paymentModels as $key => $label): ?>
<option value="<?= e($key) ?>" <?= ($filters['payment_model'] ?? '') === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php if ($currentType !== ''): ?>
<input type="hidden" name="employment_type" value="<?= e($currentType) ?>">
<?php endif; ?>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> بحث</button>
<a href="/coaches" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<!-- Coaches Grid -->
<?php if (!empty($coaches)): ?>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(320px, 1fr));gap:20px;margin-bottom:20px;">
<?php foreach ($coaches as $c):
$isActive = (int) ($c['is_active'] ?? 0);
$empType = $c['employment_type'] ?? 'contract';
$empLabel = $employmentTypes[$empType] ?? $empType;
$payModel = $c['payment_model'] ?? 'per_session';
$payLabel = $paymentModels[$payModel] ?? $payModel;
$empColors = ['staff' => '#059669', 'contract' => '#2563EB', 'freelance' => '#D97706'];
$empColor = $empColors[$empType] ?? '#6B7280';
?>
<div class="card" style="transition:box-shadow 0.2s;position:relative;overflow:hidden;<?= !$isActive ? 'opacity:0.65;' : '' ?>">
<?php if (!$isActive): ?>
<div style="position:absolute;top:12px;left:12px;z-index:2;">
<span class="badge" style="background:#FEE2E2;color:#DC2626;font-size:11px;padding:3px 10px;border-radius:10px;">معطّل</span>
</div>
<?php endif; ?>
<a href="/coaches/<?= (int) $c['id'] ?>" style="text-decoration:none;color:inherit;display:block;">
<div style="padding:20px 20px 15px;display:flex;align-items:start;gap:15px;">
<div style="width:52px;height:52px;border-radius:50%;background:linear-gradient(135deg, <?= $empColor ?>15, <?= $empColor ?>30);display:flex;align-items:center;justify-content:center;flex-shrink:0;overflow:hidden;">
<?php if (!empty($c['photo_path'])): ?>
<img src="<?= e($c['photo_path']) ?>" style="width:52px;height:52px;object-fit:cover;border-radius:50%;" alt="">
<?php else: ?>
<i data-lucide="user" style="width:26px;height:26px;color:<?= $empColor ?>;"></i>
<?php endif; ?>
</div>
<div style="flex:1;min-width:0;">
<h3 style="margin:0 0 4px;font-size:16px;font-weight:700;color:#1A1A2E;"><?= e($c['full_name_ar']) ?></h3>
<?php if (!empty($c['full_name_en'])): ?>
<div style="font-size:12px;color:#9CA3AF;margin-bottom:6px;"><?= e($c['full_name_en']) ?></div>
<?php endif; ?>
<span style="display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600;background:<?= $empColor ?>15;color:<?= $empColor ?>;"><?= e($empLabel) ?></span>
</div>
</div>
<div style="padding:0 20px 15px;display:flex;gap:15px;font-size:12px;color:#6B7280;">
<?php if (!empty($c['phone'])): ?>
<span style="display:flex;align-items:center;gap:4px;">
<i data-lucide="phone" style="width:13px;height:13px;"></i>
<?= e($c['phone']) ?>
</span>
<?php endif; ?>
<span style="display:flex;align-items:center;gap:4px;">
<i data-lucide="banknote" style="width:13px;height:13px;"></i>
<?= e($payLabel) ?>
</span>
<span style="display:flex;align-items:center;gap:4px;">
<i data-lucide="users" style="width:13px;height:13px;"></i>
حد أقصى: <?= (int) ($c['max_players_default'] ?? 20) ?>
</span>
</div>
</a>
<div style="padding:10px 20px;border-top:1px solid #F3F4F6;display:flex;justify-content:space-between;align-items:center;">
<code style="font-size:11px;color:#9CA3AF;background:#F9FAFB;padding:2px 6px;border-radius:4px;"><?= e($c['code'] ?? '') ?></code>
<div style="display:flex;gap:6px;">
<a href="/coaches/<?= (int) $c['id'] ?>" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="eye" style="width:13px;height:13px;vertical-align:middle;"></i> عرض
</a>
<a href="/coaches/<?= (int) $c['id'] ?>/edit" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="edit-3" style="width:13px;height:13px;vertical-align:middle;"></i> تعديل
</a>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php if (isset($pagination) && ($pagination['last_page'] ?? 1) > 1): ?>
<div style="padding:15px;">
<?php $__template->include('Shared.Components.pagination', ['pagination' => $pagination]); ?>
</div>
<?php endif; ?>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="user" style="width:48px;height:48px;color:#D1D5DB;"></i>
</div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا يوجد مدربين</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0 0 20px;">
<?php if (!empty($filters['q']) || !empty($filters['employment_type']) || !empty($filters['payment_model'])): ?>
لا توجد نتائج مطابقة. جرب تغيير معايير البحث.
<?php else: ?>
ابدأ بإضافة مدرب جديد.
<?php endif; ?>
</p>
<a href="/coaches/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إضافة مدرب</a>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});
</script>
<?php $__template->endSection(); ?>
This diff is collapsed.
<?php
declare(strict_types=1);
use App\Core\Registries\PermissionRegistry;
use App\Core\Registries\MenuRegistry;
PermissionRegistry::register('coaches', [
'coach.view' => ['ar' => 'عرض المدربين', 'en' => 'View Coaches'],
'coach.manage' => ['ar' => 'إدارة المدربين', 'en' => 'Manage Coaches'],
]);
MenuRegistry::register('coaches', [
'label_ar' => 'المدربين',
'icon' => 'users',
'route' => '/coaches',
'permission' => 'coach.view',
'order' => 397,
'parent' => 'sports_activities',
]);
......@@ -102,6 +102,56 @@ class SportsDashboardController extends Controller
"SELECT COUNT(*) AS total FROM sport_disciplines WHERE is_active = 1 AND is_archived = 0"
)['total'] ?? 0;
// Coaches stats
$coachStats = $db->selectOne("
SELECT
COUNT(*) AS total,
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS active
FROM coaches WHERE is_archived = 0
") ?: ['total' => 0, 'active' => 0];
// Training sessions this week
$weekStart = date('Y-m-d', strtotime('monday this week'));
$weekEnd = date('Y-m-d', strtotime('sunday this week'));
$sessionStats = $db->selectOne("
SELECT
COUNT(*) AS total,
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(CASE WHEN status = 'scheduled' THEN 1 ELSE 0 END) AS scheduled
FROM training_sessions
WHERE session_date BETWEEN ? AND ?
", [$weekStart, $weekEnd]) ?: ['total' => 0, 'completed' => 0, 'cancelled' => 0, 'scheduled' => 0];
// Active contracts
$contractStats = $db->selectOne("
SELECT
COUNT(*) AS total,
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN status = 'active' AND end_date <= DATE_ADD(CURDATE(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) AS expiring_soon
FROM academy_contracts
") ?: ['total' => 0, 'active' => 0, 'expiring_soon' => 0];
// Training groups capacity
$groupStats = $db->selectOne("
SELECT
COUNT(*) AS total,
SUM(CASE WHEN is_full = 1 THEN 1 ELSE 0 END) AS full_groups,
COALESCE(SUM(current_count), 0) AS total_players
FROM training_groups
WHERE status = 'active'
") ?: ['total' => 0, 'full_groups' => 0, 'total_players' => 0];
// Pool utilization today
$poolStats = $db->selectOne("
SELECT
COUNT(*) AS total_bookings,
SUM(CASE WHEN status = 'confirmed' THEN 1 ELSE 0 END) AS confirmed,
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) AS in_progress
FROM pool_bookings
WHERE booking_date = CURDATE()
") ?: ['total_bookings' => 0, 'confirmed' => 0, 'in_progress' => 0];
return $this->view('Disciplines.Views.sports_dashboard', [
'playerStats' => $playerStats,
'enrollmentCount' => $enrollmentCount,
......@@ -114,6 +164,11 @@ class SportsDashboardController extends Controller
'academyCount' => $academyCount,
'disciplineCount' => $disciplineCount,
'currentMonth' => $currentMonth,
'coachStats' => $coachStats,
'sessionStats' => $sessionStats,
'contractStats' => $contractStats,
'groupStats' => $groupStats,
'poolStats' => $poolStats,
]);
}
}
......@@ -76,6 +76,87 @@ $monthLabel = date('Y/m', strtotime($currentMonth . '-01'));
</div>
</div>
<!-- Operations Row: Coaches, Sessions, Groups, Pool -->
<div style="display:grid;grid-template-columns:repeat(4, 1fr);gap:15px;margin-bottom:20px;">
<div class="card" style="padding:20px;border-right:4px solid #7C3AED;">
<div style="display:flex;justify-content:space-between;align-items:start;">
<div>
<div style="font-size:12px;color:#6B7280;margin-bottom:4px;">المدربين</div>
<div style="font-size:28px;font-weight:700;color:#1A1A2E;"><?= (int) $coachStats['active'] ?></div>
<div style="font-size:11px;color:#6B7280;margin-top:4px;">من <?= (int) $coachStats['total'] ?> مسجل</div>
</div>
<div style="width:42px;height:42px;border-radius:10px;background:#7C3AED15;display:flex;align-items:center;justify-content:center;">
<i data-lucide="user-check" style="width:22px;height:22px;color:#7C3AED;"></i>
</div>
</div>
</div>
<div class="card" style="padding:20px;border-right:4px solid #2563EB;">
<div style="display:flex;justify-content:space-between;align-items:start;">
<div>
<div style="font-size:12px;color:#6B7280;margin-bottom:4px;">حصص الأسبوع</div>
<div style="font-size:28px;font-weight:700;color:#1A1A2E;"><?= (int) $sessionStats['total'] ?></div>
<div style="font-size:11px;margin-top:4px;">
<span style="color:#10B981;"><?= (int) $sessionStats['completed'] ?> مكتملة</span>
<?php if ((int) $sessionStats['cancelled'] > 0): ?>
&middot; <span style="color:#EF4444;"><?= (int) $sessionStats['cancelled'] ?> ملغاة</span>
<?php endif; ?>
</div>
</div>
<div style="width:42px;height:42px;border-radius:10px;background:#2563EB15;display:flex;align-items:center;justify-content:center;">
<i data-lucide="calendar-days" style="width:22px;height:22px;color:#2563EB;"></i>
</div>
</div>
</div>
<div class="card" style="padding:20px;border-right:4px solid #0891B2;">
<div style="display:flex;justify-content:space-between;align-items:start;">
<div>
<div style="font-size:12px;color:#6B7280;margin-bottom:4px;">المجموعات التدريبية</div>
<div style="font-size:28px;font-weight:700;color:#1A1A2E;"><?= (int) $groupStats['total'] ?></div>
<div style="font-size:11px;margin-top:4px;">
<span style="color:#059669;"><?= (int) $groupStats['total_players'] ?> لاعب</span>
<?php if ((int) $groupStats['full_groups'] > 0): ?>
&middot; <span style="color:#D97706;"><?= (int) $groupStats['full_groups'] ?> ممتلئة</span>
<?php endif; ?>
</div>
</div>
<div style="width:42px;height:42px;border-radius:10px;background:#0891B215;display:flex;align-items:center;justify-content:center;">
<i data-lucide="users" style="width:22px;height:22px;color:#0891B2;"></i>
</div>
</div>
</div>
<div class="card" style="padding:20px;border-right:4px solid #0369A1;">
<div style="display:flex;justify-content:space-between;align-items:start;">
<div>
<div style="font-size:12px;color:#6B7280;margin-bottom:4px;">حمام السباحة اليوم</div>
<div style="font-size:28px;font-weight:700;color:#1A1A2E;"><?= (int) $poolStats['total_bookings'] ?></div>
<div style="font-size:11px;margin-top:4px;">
<?php if ((int) $poolStats['in_progress'] > 0): ?>
<span style="color:#F59E0B;"><?= (int) $poolStats['in_progress'] ?> جاري</span> &middot;
<?php endif; ?>
<span style="color:#3B82F6;"><?= (int) $poolStats['confirmed'] ?> مؤكد</span>
</div>
</div>
<div style="width:42px;height:42px;border-radius:10px;background:#0369A115;display:flex;align-items:center;justify-content:center;">
<i data-lucide="waves" style="width:22px;height:22px;color:#0369A1;"></i>
</div>
</div>
</div>
</div>
<!-- Contracts Alert -->
<?php if ((int) $contractStats['expiring_soon'] > 0): ?>
<div style="background:#FEF3C7;border:1px solid #F59E0B;border-radius:8px;padding:12px 18px;margin-bottom:20px;display:flex;align-items:center;gap:10px;">
<i data-lucide="alert-triangle" style="width:18px;height:18px;color:#D97706;flex-shrink:0;"></i>
<span style="font-size:13px;color:#92400E;">
<strong><?= (int) $contractStats['expiring_soon'] ?></strong> عقد أكاديمية ينتهي خلال 30 يوم —
<a href="/academy-contracts" style="color:#D97706;font-weight:600;">مراجعة العقود</a>
</span>
</div>
<?php endif; ?>
<!-- Facility Stats + Collection Summary -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:20px;">
<div class="card" style="padding:20px;">
......@@ -269,14 +350,20 @@ $monthLabel = date('Y/m', strtotime($currentMonth . '-01'));
<a href="/reservations/create" class="btn btn-outline" style="font-size:13px;">
<i data-lucide="calendar-plus" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> حجز ملعب
</a>
<a href="/sessions" class="btn btn-outline" style="font-size:13px;">
<i data-lucide="calendar-days" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> الحصص التدريبية
</a>
<a href="/activity-subscriptions" class="btn btn-outline" style="font-size:13px;">
<i data-lucide="credit-card" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> إدارة الاشتراكات
</a>
<a href="/academies" class="btn btn-outline" style="font-size:13px;">
<i data-lucide="school" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> الأكاديميات
<a href="/academy-contracts/settlements" class="btn btn-outline" style="font-size:13px;">
<i data-lucide="receipt" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> التسويات المالية
</a>
<a href="/mirror" class="btn btn-outline" style="font-size:13px;">
<i data-lucide="monitor" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> المراية
</a>
<a href="/disciplines" class="btn btn-outline" style="font-size:13px;">
<i data-lucide="activity" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> الأنشطة الرياضية
<a href="/pool" class="btn btn-outline" style="font-size:13px;">
<i data-lucide="waves" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> حمام السباحة
</a>
</div>
</div>
......
......@@ -21,13 +21,20 @@ MenuRegistry::register('sports_activities', [
['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' => 'Mirror Display', 'route' => '/mirror', 'permission' => 'facility.mirror', 'order' => 2.5],
['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],
['label_ar' => 'تسعير الأنشطة', 'label_en' => 'Activity Pricing', 'route' => '/activity-subscriptions/pricing', 'permission' => 'activity_sub.manage_pricing', 'order' => 8],
['label_ar' => 'عقود الأكاديميات', 'label_en' => 'Academy Contracts', 'route' => '/academy-contracts', 'permission' => 'academy_contract.view', 'order' => 3.5],
['label_ar' => 'المدربين', 'label_en' => 'Coaches', 'route' => '/coaches', 'permission' => 'coach.view', 'order' => 4],
['label_ar' => 'المجموعات التدريبية','label_en' => 'Training Groups', 'route' => '/training-groups', 'permission' => 'training_group.view', 'order' => 4.2],
['label_ar' => 'شئون اللاعبين', 'label_en' => 'Players', 'route' => '/players', 'permission' => 'player.view', 'order' => 4.5],
['label_ar' => 'الحصص التدريبية', 'label_en' => 'Training Sessions', 'route' => '/sessions', 'permission' => 'session.view', 'order' => 4.7],
['label_ar' => 'الحضور والغياب', 'label_en' => 'Attendance', 'route' => '/attendance', 'permission' => 'player.view', 'order' => 5],
['label_ar' => 'الحجوزات', 'label_en' => 'Reservations', 'route' => '/reservations', 'permission' => 'reservation.view', 'order' => 6],
['label_ar' => 'حمام السباحة', 'label_en' => 'Pool Management', 'route' => '/pool', 'permission' => 'pool.view', 'order' => 6.5],
['label_ar' => 'التأجير المؤسسي', 'label_en' => 'Corporate Rentals', 'route' => '/rentals', 'permission' => 'rental.view', 'order' => 7],
['label_ar' => 'اشتراكات الأنشطة', 'label_en' => 'Activity Subscriptions','route' => '/activity-subscriptions', 'permission' => 'activity_sub.view', 'order' => 8],
['label_ar' => 'تسعير الأنشطة', 'label_en' => 'Activity Pricing', 'route' => '/activity-subscriptions/pricing', 'permission' => 'activity_sub.manage_pricing', 'order' => 9],
['label_ar' => 'التسويات المالية', 'label_en' => 'Settlements', 'route' => '/academy-contracts/settlements', 'permission' => 'academy_contract.view', 'order' => 10],
],
]);
......
<?php
declare(strict_types=1);
namespace App\Modules\FacilityDashboards\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
class FacilityDashboardController extends Controller
{
public function show(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$facility = $db->selectOne("SELECT * FROM facilities WHERE id = ? AND is_archived = 0", [(int) $id]);
if (!$facility) {
return $this->redirect('/facilities')->withError('المرفق غير موجود');
}
$today = date('Y-m-d');
$monthStart = date('Y-m-01');
$weekStart = date('Y-m-d', strtotime('monday this week'));
$todayBookings = $db->select(
"SELECT * FROM reservations WHERE facility_id = ? AND reservation_date = ? AND status NOT IN ('cancelled') ORDER BY start_time ASC",
[(int) $id, $today]
);
$todayRevenue = $db->selectOne(
"SELECT COALESCE(SUM(final_amount), 0) AS total FROM reservations WHERE facility_id = ? AND reservation_date = ? AND payment_status = 'paid'",
[(int) $id, $today]
);
$weekRevenue = $db->selectOne(
"SELECT COALESCE(SUM(final_amount), 0) AS total FROM reservations WHERE facility_id = ? AND reservation_date >= ? AND payment_status = 'paid'",
[(int) $id, $weekStart]
);
$monthRevenue = $db->selectOne(
"SELECT COALESCE(SUM(final_amount), 0) AS total FROM reservations WHERE facility_id = ? AND reservation_date >= ? AND payment_status = 'paid'",
[(int) $id, $monthStart]
);
$monthBookingCount = $db->selectOne(
"SELECT COUNT(*) AS cnt FROM reservations WHERE facility_id = ? AND reservation_date >= ? AND status NOT IN ('cancelled')",
[(int) $id, $monthStart]
);
$upcomingBookings = $db->select(
"SELECT * FROM reservations WHERE facility_id = ? AND reservation_date >= ? AND status = 'confirmed' ORDER BY reservation_date ASC, start_time ASC LIMIT 5",
[(int) $id, $today]
);
return $this->view('FacilityDashboards.Views.dashboard', [
'facility' => $facility,
'todayBookings' => $todayBookings,
'todayRevenue' => (float) ($todayRevenue['total'] ?? 0),
'weekRevenue' => (float) ($weekRevenue['total'] ?? 0),
'monthRevenue' => (float) ($monthRevenue['total'] ?? 0),
'monthBookingCount' => (int) ($monthBookingCount['cnt'] ?? 0),
'upcomingBookings' => $upcomingBookings,
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\FacilityDashboards\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\FacilityDashboards\Services\MirrorDisplayService;
class MirrorDisplayController extends Controller
{
public function index(Request $request): Response
{
$facilityType = trim((string) $request->get('type', ''));
$disciplineId = $request->get('discipline_id', '') !== '' ? (int) $request->get('discipline_id') : null;
$states = MirrorDisplayService::getFacilityStates(
$facilityType ?: null,
$disciplineId
);
$db = App::getInstance()->db();
$facilityTypes = $db->select(
"SELECT DISTINCT facility_type FROM facilities WHERE is_active = 1 AND is_archived = 0 ORDER BY facility_type"
);
$disciplines = $db->select(
"SELECT id, name_ar FROM sport_disciplines WHERE is_active = 1 AND is_archived = 0 ORDER BY name_ar"
);
return $this->view('FacilityDashboards.Views.mirror', [
'states' => $states,
'facilityTypes' => $facilityTypes,
'disciplines' => $disciplines,
'currentType' => $facilityType,
'currentDisc' => $disciplineId,
]);
}
public function apiState(Request $request): Response
{
$facilityType = trim((string) $request->get('type', ''));
$disciplineId = $request->get('discipline_id', '') !== '' ? (int) $request->get('discipline_id') : null;
$states = MirrorDisplayService::getFacilityStates(
$facilityType ?: null,
$disciplineId
);
return $this->json([
'states' => $states,
'timestamp' => date('Y-m-d H:i:s'),
'time' => date('H:i'),
]);
}
}
<?php
declare(strict_types=1);
return [
['GET', '/mirror', 'FacilityDashboards\Controllers\MirrorDisplayController@index', ['auth'], 'facility.mirror'],
['GET', '/api/mirror/state', 'FacilityDashboards\Controllers\MirrorDisplayController@apiState', ['auth'], 'facility.mirror'],
['GET', '/facilities/{id:\d+}/dashboard', 'FacilityDashboards\Controllers\FacilityDashboardController@show', ['auth'], 'facility.dashboard'],
];
This diff is collapsed.
This diff is collapsed.
<?php
declare(strict_types=1);
use App\Core\Registries\PermissionRegistry;
use App\Core\Registries\MenuRegistry;
PermissionRegistry::register('facility_dashboards', [
'facility.dashboard' => ['ar' => 'لوحة المرفق', 'en' => 'Facility Dashboard'],
'facility.mirror' => ['ar' => 'المراية', 'en' => 'Mirror Display'],
]);
MenuRegistry::register('facility_mirror', [
'label_ar' => 'المراية (عرض مباشر)',
'icon' => 'monitor',
'route' => '/mirror',
'permission' => 'facility.mirror',
'order' => 396,
'parent' => 'sports_activities',
]);
<?php
declare(strict_types=1);
namespace App\Modules\PoolManagement\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\PoolManagement\Models\PoolConfiguration;
use App\Modules\PoolManagement\Services\PoolGridService;
use App\Modules\PoolManagement\Services\PoolOccupancyOptimizer;
class PoolGridController extends Controller
{
public function grid(Request $request, string $id): Response
{
$pool = PoolConfiguration::find((int) $id);
if (!$pool) {
return $this->redirect('/pool')->withError('حمام السباحة غير موجود');
}
$date = trim((string) $request->get('date', date('Y-m-d')));
$gridState = PoolGridService::getGridState((int) $id, $date);
$utilization = PoolOccupancyOptimizer::getUtilizationMetrics((int) $id, $date, $date);
return $this->view('PoolManagement.Views.grid', [
'pool' => $pool,
'gridState' => $gridState,
'utilization' => $utilization,
'date' => $date,
'bookingTypes'=> PoolConfiguration::getBookingTypes(),
]);
}
public function apiState(Request $request, string $id): Response
{
$date = trim((string) $request->get('date', date('Y-m-d')));
$gridState = PoolGridService::getGridState((int) $id, $date);
return $this->json($gridState);
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
<?php
declare(strict_types=1);
return [
['GET', '/sessions', 'Sessions\Controllers\SessionController@index', ['auth'], 'session.view'],
['GET', '/sessions/calendar', 'Sessions\Controllers\SessionController@calendar', ['auth'], 'session.view'],
['GET', '/sessions/{id:\d+}', 'Sessions\Controllers\SessionController@show', ['auth'], 'session.view'],
['GET', '/sessions/{id:\d+}/attendance', 'Sessions\Controllers\SessionAttendanceController@form', ['auth'], 'session.attendance'],
['POST', '/sessions/{id:\d+}/attendance', 'Sessions\Controllers\SessionAttendanceController@save', ['auth', 'csrf'], 'session.attendance'],
['POST', '/sessions/{id:\d+}/cancel', 'Sessions\Controllers\SessionController@cancel', ['auth', 'csrf'], 'session.manage'],
['POST', '/sessions/{id:\d+}/complete', 'Sessions\Controllers\SessionController@complete', ['auth', 'csrf'], 'session.manage'],
['POST', '/sessions/generate', 'Sessions\Controllers\SessionController@generate', ['auth', 'csrf'], 'session.manage'],
];
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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