Commit c95ccac3 authored by Mahmoud Aglan's avatar Mahmoud Aglan

testing

parent e740ea76
...@@ -24,7 +24,7 @@ final class PaymentRequestService ...@@ -24,7 +24,7 @@ final class PaymentRequestService
$notes = $data['notes'] ?? null; $notes = $data['notes'] ?? null;
$currency = $data['currency'] ?? 'EGP'; $currency = $data['currency'] ?? 'EGP';
if ($memberId <= 0 && !in_array($paymentType, ['sports_registration', 'hourly_booking', 'sa_form_fee', 'sa_subscription'], true)) { if ($memberId <= 0 && !in_array($paymentType, ['sports_registration', 'hourly_booking', 'sa_form_fee', 'sa_subscription', 'sports_subscription', 'activity_subscription'], true)) {
return ['success' => false, 'error' => 'العضو مطلوب']; return ['success' => false, 'error' => 'العضو مطلوب'];
} }
if ($paymentType === '') { if ($paymentType === '') {
......
...@@ -9,6 +9,7 @@ use App\Core\Response; ...@@ -9,6 +9,7 @@ use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Core\EventBus; use App\Core\EventBus;
use App\Modules\Payments\Services\PaymentService; use App\Modules\Payments\Services\PaymentService;
use App\Modules\Installments\Services\InstallmentCalculator;
class InstallmentController extends Controller class InstallmentController extends Controller
{ {
...@@ -91,6 +92,82 @@ class InstallmentController extends Controller ...@@ -91,6 +92,82 @@ class InstallmentController extends Controller
]); ]);
} }
public function store(Request $request, string $memberId): Response
{
$this->authorize('installment.create_plan');
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]);
if (!$member) {
return $this->redirect('/members')->withError('العضو غير موجود');
}
$totalAmount = trim((string) $request->post('total_amount', '0'));
$downPayment = trim((string) $request->post('down_payment', '0'));
$months = (int) $request->post('number_of_months', 0);
$startDate = trim((string) $request->post('start_date', date('Y-m-d')));
$notes = trim((string) $request->post('notes', ''));
$calc = InstallmentCalculator::calculate($totalAmount, $downPayment, $months, $startDate);
if (!($calc['success'] ?? false)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $calc['errors'] ?? []));
$session->flash('_old_input', $request->all());
return $this->redirect("/installments/create/{$memberId}");
}
$employee = App::getInstance()->currentEmployee();
$db->beginTransaction();
try {
$planId = $db->insert('installment_plans', [
'member_id' => (int) $memberId,
'total_amount' => $totalAmount,
'down_payment' => $downPayment,
'remaining_balance' => $calc['remaining_balance'],
'interest_rate' => $calc['interest_rate'],
'total_interest' => $calc['total_interest'],
'total_with_interest' => $calc['total_with_interest'],
'number_of_months' => $months,
'monthly_payment' => $calc['monthly_payment'],
'start_date' => $startDate,
'cash_settlement_date'=> $calc['cash_settlement_date'],
'status' => 'active',
'notes' => $notes ?: null,
'created_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
foreach ($calc['schedule'] as $item) {
$db->insert('installment_schedule', [
'installment_plan_id' => $planId,
'installment_number' => $item['number'],
'due_date' => $item['due_date'],
'amount' => $item['amount'],
'principal' => $item['principal'],
'interest' => $item['interest'],
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
]);
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
return $this->redirect("/installments/create/{$memberId}")->withError('خطأ: ' . $e->getMessage());
}
EventBus::dispatch('installment_plan.created', [
'plan_id' => $planId,
'member_id' => (int) $memberId,
]);
return $this->redirect("/installments/{$planId}")->withSuccess(
"تم إنشاء خطة التقسيط — {$months} قسط بإجمالي " . money($calc['total_with_interest'])
);
}
public function payInstallment(Request $request, string $planId, string $scheduleId): Response public function payInstallment(Request $request, string $planId, string $scheduleId): Response
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
......
...@@ -4,6 +4,7 @@ declare(strict_types=1); ...@@ -4,6 +4,7 @@ declare(strict_types=1);
return [ return [
['GET', '/installments', 'Installments\Controllers\InstallmentController@index', ['auth'], 'installment.view'], ['GET', '/installments', 'Installments\Controllers\InstallmentController@index', ['auth'], 'installment.view'],
['GET', '/installments/create/{memberId}', 'Installments\Controllers\InstallmentController@create', ['auth'], 'installment.create'], ['GET', '/installments/create/{memberId}', 'Installments\Controllers\InstallmentController@create', ['auth'], 'installment.create'],
['POST', '/installments/store/{memberId}', 'Installments\Controllers\InstallmentController@store', ['auth', 'csrf'], 'installment.create_plan'],
['GET', '/installments/{id}', 'Installments\Controllers\InstallmentController@show', ['auth'], 'installment.view'], ['GET', '/installments/{id}', 'Installments\Controllers\InstallmentController@show', ['auth'], 'installment.view'],
['POST', '/installments/{planId}/pay/{scheduleId}', 'Installments\Controllers\InstallmentController@payInstallment', ['auth', 'csrf'], 'installment.pay'], ['POST', '/installments/{planId}/pay/{scheduleId}', 'Installments\Controllers\InstallmentController@payInstallment', ['auth', 'csrf'], 'installment.pay'],
......
...@@ -69,17 +69,15 @@ class OpeningBalanceController extends Controller ...@@ -69,17 +69,15 @@ class OpeningBalanceController extends Controller
$balanceDate = $request->post('balance_date', date('Y-m-d')); $balanceDate = $request->post('balance_date', date('Y-m-d'));
$batchReference = 'OB-' . date('Ymd-His'); $batchReference = 'OB-' . date('Ymd-His');
$itemIds = $request->post('item_ids', []); $items = $request->post('items', []);
$quantities = $request->post('quantities', []);
$unitCosts = $request->post('unit_costs', []);
$count = 0; $count = 0;
$db->beginTransaction(); $db->beginTransaction();
try { try {
for ($i = 0; $i < count($itemIds); $i++) { foreach ($items as $row) {
$itemId = (int) ($itemIds[$i] ?? 0); $itemId = (int) ($row['item_id'] ?? 0);
$qty = (string) ($quantities[$i] ?? '0'); $qty = (string) ($row['quantity'] ?? '0');
$unitCost = (string) ($unitCosts[$i] ?? '0'); $unitCost = (string) ($row['unit_cost'] ?? '0');
if ($itemId <= 0 || bccomp($qty, '0', 3) <= 0) continue; if ($itemId <= 0 || bccomp($qty, '0', 3) <= 0) continue;
......
...@@ -223,7 +223,7 @@ final class StockService ...@@ -223,7 +223,7 @@ final class StockService
if ($existing) { if ($existing) {
$op = $direction === 'in' ? '+' : '-'; $op = $direction === 'in' ? '+' : '-';
$db->statement( $db->query(
"UPDATE `item_warehouse_stock` SET `quantity` = `quantity` {$op} ?, `updated_at` = NOW() WHERE `item_id` = ? AND `warehouse_id` = ?", "UPDATE `item_warehouse_stock` SET `quantity` = `quantity` {$op} ?, `updated_at` = NOW() WHERE `item_id` = ? AND `warehouse_id` = ?",
[$quantity, $itemId, $warehouseId] [$quantity, $itemId, $warehouseId]
); );
...@@ -241,12 +241,11 @@ final class StockService ...@@ -241,12 +241,11 @@ final class StockService
private static function deductBatch(int $batchId, string $quantity): void private static function deductBatch(int $batchId, string $quantity): void
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$db->statement( $db->query(
"UPDATE `item_batches` SET `quantity` = `quantity` - ? WHERE `id` = ?", "UPDATE `item_batches` SET `quantity` = `quantity` - ? WHERE `id` = ?",
[$quantity, $batchId] [$quantity, $batchId]
); );
// Mark depleted if quantity reaches zero $db->query(
$db->statement(
"UPDATE `item_batches` SET `status` = 'depleted' WHERE `id` = ? AND `quantity` <= 0", "UPDATE `item_batches` SET `status` = 'depleted' WHERE `id` = ? AND `quantity` <= 0",
[$batchId] [$batchId]
); );
......
<?php <?php
declare(strict_types=1); declare(strict_types=1);
use App\Core\PermissionRegistry; use App\Core\Registries\MenuRegistry;
use App\Core\MenuRegistry; use App\Core\Registries\PermissionRegistry;
PermissionRegistry::register('medical.board.view', 'عرض لجنة الشهادات الطبية'); PermissionRegistry::register('medical_board', [
PermissionRegistry::register('medical.board.approve', 'اعتماد/رفض الشهادات الطبية'); 'medical.board.view' => ['ar' => 'عرض لجنة الشهادات الطبية', 'en' => 'View Medical Board'],
'medical.board.approve' => ['ar' => 'اعتماد/رفض الشهادات الطبية', 'en' => 'Approve/Reject Medical Certificates'],
]);
MenuRegistry::register('medical-board', [ MenuRegistry::register('medical-board', [
'label' => 'اللجنة الطبية', 'label_ar' => 'اللجنة الطبية',
'icon' => 'heartbeat', 'label_en' => 'Medical Board',
'url' => '/medical-board', 'icon' => 'heart-pulse',
'route' => '/medical-board',
'permission' => 'medical.board.view', 'permission' => 'medical.board.view',
'group' => 'الرياضة', 'parent' => null,
'order' => 710, 'order' => 710,
'children' => [],
]); ]);
<?php
declare(strict_types=1);
namespace App\Modules\Members\Services;
use App\Core\App;
final class MembershipValidationService
{
private static array $statusLabels = [
'potential' => 'عضوية محتملة',
'under_review' => 'تحت المراجعة',
'interview_scheduled' => 'في انتظار المقابلة',
'accepted' => 'مقبول — لم يكمل الإجراءات',
'rejected' => 'عضوية مرفوضة',
'payment_pending' => 'في انتظار السداد',
'pending_cheques' => 'في انتظار الشيكات',
'frozen' => 'عضوية مجمدة',
'suspended' => 'عضوية موقوفة',
'dropped' => 'عضوية مسقطة',
'expired' => 'عضوية منتهية',
'terminated' => 'عضوية منتهية بقرار',
];
public static function checkByNationalId(string $nationalId): array
{
$db = App::getInstance()->db();
$member = $db->selectOne(
"SELECT id, membership_number, full_name_ar, full_name_en, national_id,
date_of_birth, gender, phone, status, photo_path
FROM members WHERE national_id = ? AND is_archived = 0",
[$nationalId]
);
if (!$member) {
return [
'found' => false,
'member_id' => null,
'membership_number' => null,
'member' => null,
'is_active_member' => false,
'effective_type' => 'non_member',
'reason' => null,
];
}
$base = [
'found' => true,
'member_id' => (int) $member['id'],
'membership_number' => $member['membership_number'],
'member' => $member,
];
if ($member['status'] !== 'active') {
$reason = self::$statusLabels[$member['status']] ?? ('حالة العضوية: ' . $member['status']);
return $base + [
'is_active_member' => false,
'effective_type' => 'non_member',
'reason' => $reason,
];
}
$fy = financial_year();
$subscription = $db->selectOne(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND status = 'paid' LIMIT 1",
[(int) $member['id'], $fy]
);
if (!$subscription) {
return $base + [
'is_active_member' => false,
'effective_type' => 'non_member',
'reason' => 'لم يسدد الاشتراك السنوي (' . $fy . ')',
];
}
return $base + [
'is_active_member' => true,
'effective_type' => 'member',
'reason' => null,
];
}
}
...@@ -151,7 +151,7 @@ ...@@ -151,7 +151,7 @@
<small style="color:#6B7280;">صورة واضحة للوجه — مطلوبة لإصدار كارنيه العضوية</small> <small style="color:#6B7280;">صورة واضحة للوجه — مطلوبة لإصدار كارنيه العضوية</small>
</div> </div>
<div style="padding:20px;"> <div style="padding:20px;">
<?php $currentPhoto = null; $required = true; $fieldName = 'photo'; $label = 'الصورة الشخصية'; include App::getInstance()->basePath() . '/app/Shared/Components/photo-upload-field.php'; ?> <?php $currentPhoto = null; $required = true; $fieldName = 'photo'; $label = 'الصورة الشخصية'; include \App\Core\App::getInstance()->basePath() . '/app/Shared/Components/photo-upload-field.php'; ?>
</div> </div>
</div> </div>
......
...@@ -3,6 +3,6 @@ declare(strict_types=1); ...@@ -3,6 +3,6 @@ declare(strict_types=1);
use App\Core\Registries\PermissionRegistry; use App\Core\Registries\PermissionRegistry;
PermissionRegistry::register('player_auth', 'إدارة مصادقة اللاعبين', [ PermissionRegistry::register('player_auth', [
'player_auth.manage_tokens' => 'إدارة توكنات اللاعبين', 'player_auth.manage_tokens' => ['ar' => 'إدارة توكنات اللاعبين', 'en' => 'Manage Player Tokens'],
]); ]);
This diff is collapsed.
...@@ -9,6 +9,7 @@ use App\Core\Response; ...@@ -9,6 +9,7 @@ use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Core\Pagination; use App\Core\Pagination;
use App\Modules\Members\Services\NationalIdParser; use App\Modules\Members\Services\NationalIdParser;
use App\Modules\Members\Services\MembershipValidationService;
use App\Modules\SportsActivity\Services\RegistrationWizardService; use App\Modules\SportsActivity\Services\RegistrationWizardService;
use App\Modules\Carnets\Services\QRCodeGenerator; use App\Modules\Carnets\Services\QRCodeGenerator;
use App\Modules\Settings\Services\BrandingService; use App\Modules\Settings\Services\BrandingService;
...@@ -37,8 +38,6 @@ class RegistrationWizardController extends Controller ...@@ -37,8 +38,6 @@ class RegistrationWizardController extends Controller
public function lookupPlayer(Request $request): Response public function lookupPlayer(Request $request): Response
{ {
$nationalId = trim((string) $request->post('national_id', '')); $nationalId = trim((string) $request->post('national_id', ''));
$playerType = trim((string) $request->post('player_type', 'non_member'));
$memberId = (int) $request->post('member_id', 0);
$fullNameAr = trim((string) $request->post('full_name_ar', '')); $fullNameAr = trim((string) $request->post('full_name_ar', ''));
$fullNameEn = trim((string) $request->post('full_name_en', '')); $fullNameEn = trim((string) $request->post('full_name_en', ''));
$phone = trim((string) $request->post('phone', '')); $phone = trim((string) $request->post('phone', ''));
...@@ -52,30 +51,20 @@ class RegistrationWizardController extends Controller ...@@ -52,30 +51,20 @@ class RegistrationWizardController extends Controller
$nidParsed = NationalIdParser::parse($nationalId); $nidParsed = NationalIdParser::parse($nationalId);
} }
if ($playerType === 'member' && $memberId > 0) { $playerType = 'non_member';
$db = App::getInstance()->db(); $memberId = 0;
$member = $db->selectOne( $membershipReason = null;
"SELECT full_name_ar, full_name_en, national_id, date_of_birth, gender, phone
FROM members WHERE membership_number = ? AND is_archived = 0", if ($nationalId !== '' && strlen($nationalId) === 14) {
[$memberId] $membership = MembershipValidationService::checkByNationalId($nationalId);
); $playerType = $membership['effective_type'];
if (!$member) { $membershipReason = $membership['reason'];
$member = $db->selectOne( if ($membership['found'] && $membership['member']) {
"SELECT full_name_ar, full_name_en, national_id, date_of_birth, gender, phone $memberId = (int) $membership['member_id'];
FROM members WHERE id = ? AND is_archived = 0", $m = $membership['member'];
[$memberId] $fullNameAr = $fullNameAr ?: ($m['full_name_ar'] ?? '');
); $fullNameEn = $fullNameEn ?: ($m['full_name_en'] ?? '');
} $phone = $phone ?: ($m['phone'] ?? '');
if ($member) {
$fullNameAr = $fullNameAr ?: ($member['full_name_ar'] ?? '');
$fullNameEn = $fullNameEn ?: ($member['full_name_en'] ?? '');
$nationalId = $nationalId ?: ($member['national_id'] ?? '');
$phone = $phone ?: ($member['phone'] ?? '');
if ($nationalId !== '' && strlen($nationalId) === 14 && !$nidParsed) {
$nidParsed = NationalIdParser::parse($nationalId);
}
} elseif ($fullNameAr === '') {
return $this->json(['success' => false, 'error' => 'العضو غير موجود']);
} }
} }
...@@ -103,8 +92,10 @@ class RegistrationWizardController extends Controller ...@@ -103,8 +92,10 @@ class RegistrationWizardController extends Controller
} }
return $this->json(array_merge($result, [ return $this->json(array_merge($result, [
'nid_parsed' => $nidParsed, 'nid_parsed' => $nidParsed,
'redirect' => '/sa/registration/' . $result['registration_id'], 'effective_type' => $playerType,
'membership_reason' => $membershipReason,
'redirect' => '/sa/registration/' . $result['registration_id'],
])); ]));
} }
......
...@@ -103,6 +103,13 @@ return [ ...@@ -103,6 +103,13 @@ return [
['GET', '/sa/schedule/weekly', 'SportsActivity\Controllers\ScheduleController@weekly', ['auth'], 'sa.schedule.view'], ['GET', '/sa/schedule/weekly', 'SportsActivity\Controllers\ScheduleController@weekly', ['auth'], 'sa.schedule.view'],
['POST', '/sa/schedule/generate', 'SportsActivity\Controllers\ScheduleController@generate', ['auth', 'csrf'], 'sa.schedule.manage'], ['POST', '/sa/schedule/generate', 'SportsActivity\Controllers\ScheduleController@generate', ['auth', 'csrf'], 'sa.schedule.manage'],
// Booking Wizard
['GET', '/sa/booking-wizard', 'SportsActivity\Controllers\BookingWizardController@index', ['auth'], 'sa.booking_wizard.use'],
['POST', '/sa/booking-wizard/lookup', 'SportsActivity\Controllers\BookingWizardController@lookup', ['auth', 'csrf'], 'sa.booking_wizard.use'],
['GET', '/sa/booking-wizard/units', 'SportsActivity\Controllers\BookingWizardController@units', ['auth'], 'sa.booking_wizard.use'],
['GET', '/sa/booking-wizard/slots', 'SportsActivity\Controllers\BookingWizardController@slots', ['auth'], 'sa.booking_wizard.use'],
['POST', '/sa/booking-wizard/book', 'SportsActivity\Controllers\BookingWizardController@book', ['auth', 'csrf'], 'sa.booking_wizard.use'],
// Bookings // Bookings
['GET', '/sa/bookings', 'SportsActivity\Controllers\BookingController@index', ['auth'], 'sa.booking.view'], ['GET', '/sa/bookings', 'SportsActivity\Controllers\BookingController@index', ['auth'], 'sa.booking.view'],
['GET', '/sa/bookings/create', 'SportsActivity\Controllers\BookingController@create', ['auth'], 'sa.booking.create'], ['GET', '/sa/bookings/create', 'SportsActivity\Controllers\BookingController@create', ['auth'], 'sa.booking.create'],
...@@ -171,7 +178,7 @@ return [ ...@@ -171,7 +178,7 @@ return [
// JSON APIs (AJAX) // JSON APIs (AJAX)
['GET', '/api/sa/schedule/availability', 'SportsActivity\Controllers\Api\ScheduleApiController@availability', ['auth'], 'sa.schedule.view'], ['GET', '/api/sa/schedule/availability', 'SportsActivity\Controllers\Api\ScheduleApiController@availability', ['auth'], 'sa.schedule.view'],
['GET', '/api/sa/schedule/conflicts', 'SportsActivity\Controllers\Api\ScheduleApiController@conflicts', ['auth'], 'sa.schedule.view'], ['GET', '/api/sa/schedule/conflicts', 'SportsActivity\Controllers\Api\ScheduleApiController@conflicts', ['auth'], 'sa.schedule.view'],
['GET', '/api/sa/bookings/price-preview', 'SportsActivity\Controllers\Api\BookingApiController@pricePreview', ['auth'], 'sa.booking.create'], ['GET', '/api/sa/bookings/price-preview', 'SportsActivity\Controllers\Api\BookingApiController@pricePreview', ['auth'], 'sa.booking.view'],
['GET', '/api/sa/players/search', 'SportsActivity\Controllers\Api\PlayerSearchApiController@search', ['auth'], 'sa.player.view'], ['GET', '/api/sa/players/search', 'SportsActivity\Controllers\Api\PlayerSearchApiController@search', ['auth'], 'sa.player.view'],
['GET', '/api/sa/mirror/{id:\d+}/state', 'SportsActivity\Controllers\Api\MirrorApiController@state', ['auth'], 'sa.mirror.view'], ['GET', '/api/sa/mirror/{id:\d+}/state', 'SportsActivity\Controllers\Api\MirrorApiController@state', ['auth'], 'sa.mirror.view'],
['GET', '/api/sa/pool-grid/{id:\d+}/state', 'SportsActivity\Controllers\Api\PoolGridApiController@state', ['auth'], 'sa.pool-grid.manage'], ['GET', '/api/sa/pool-grid/{id:\d+}/state', 'SportsActivity\Controllers\Api\PoolGridApiController@state', ['auth'], 'sa.pool-grid.manage'],
......
...@@ -174,6 +174,17 @@ final class BookingService ...@@ -174,6 +174,17 @@ final class BookingService
private static function generateNumber(): string private static function generateNumber(): string
{ {
return 'BK-' . date('Ymd') . '-' . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT); $db = App::getInstance()->db();
$datePrefix = 'BK-' . date('Ymd') . '-';
for ($attempt = 0; $attempt < 10; $attempt++) {
$number = $datePrefix . str_pad((string) random_int(1, 9999), 4, '0', STR_PAD_LEFT);
$exists = $db->selectOne("SELECT id FROM sa_bookings WHERE booking_number = ?", [$number]);
if (!$exists) {
return $number;
}
}
return $datePrefix . str_pad((string) random_int(10000, 99999), 5, '0', STR_PAD_LEFT);
} }
} }
This diff is collapsed.
...@@ -280,23 +280,10 @@ ...@@ -280,23 +280,10 @@
<div style="padding:20px;"> <div style="padding:20px;">
<form id="startForm"> <form id="startForm">
<div style="display:grid;grid-template-columns:1fr;gap:14px;"> <div style="display:grid;grid-template-columns:1fr;gap:14px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<label class="form-label">نوع اللاعب</label>
<select name="player_type" id="regPlayerType" class="form-select" style="padding:14px;font-size:15px;border-radius:10px;" required>
<option value="non_member">غير عضو</option>
<option value="member">عضو</option>
</select>
</div>
<div id="memberIdWrap" style="display:none;">
<label class="form-label">رقم العضوية</label>
<input type="number" name="member_id" class="form-input" style="padding:14px;font-size:15px;border-radius:10px;" placeholder="رقم العضوية">
</div>
</div>
<div> <div>
<label class="form-label">الرقم القومي <span style="color:#DC2626;">*</span></label> <label class="form-label">الرقم القومي <span style="color:#DC2626;">*</span></label>
<input type="text" name="national_id" class="form-input" style="padding:14px;font-size:18px;border-radius:10px;direction:ltr;text-align:right;letter-spacing:1px;" maxlength="14" inputmode="numeric" pattern="[0-9]*" placeholder="أدخل 14 رقم" id="regNid"> <input type="text" name="national_id" class="form-input" style="padding:14px;font-size:18px;border-radius:10px;direction:ltr;text-align:right;letter-spacing:1px;" maxlength="14" inputmode="numeric" pattern="[0-9]*" placeholder="أدخل 14 رقم" id="regNid">
<small id="regNidInfo" style="display:none;margin-top:6px;font-size:12px;color:#059669;"></small> <div id="regMemberBadge" style="display:none;margin-top:8px;padding:10px 12px;border-radius:8px;font-size:13px;"></div>
</div> </div>
<div> <div>
<label class="form-label">الاسم بالعربي <span style="color:#DC2626;">*</span></label> <label class="form-label">الاسم بالعربي <span style="color:#DC2626;">*</span></label>
...@@ -372,35 +359,44 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -372,35 +359,44 @@ document.addEventListener('DOMContentLoaded', function() {
// Start form // Start form
var startForm = document.getElementById('startForm'); var startForm = document.getElementById('startForm');
if (startForm) { if (startForm) {
var playerTypeSelect = document.getElementById('regPlayerType');
var memberIdWrap = document.getElementById('memberIdWrap');
playerTypeSelect.addEventListener('change', function() {
memberIdWrap.style.display = this.value === 'member' ? '' : 'none';
});
var regNid = document.getElementById('regNid'); var regNid = document.getElementById('regNid');
var regNidInfo = document.getElementById('regNidInfo'); var memberBadge = document.getElementById('regMemberBadge');
if (regNid) { if (regNid) {
regNid.addEventListener('input', function() { regNid.addEventListener('input', function() {
this.value = this.value.replace(/\D/g, ''); this.value = this.value.replace(/\D/g, '');
if (this.value.length === 14) { if (this.value.length === 14) {
fetch('/api/members/parse-nid', { fetch('/sa/booking-wizard/lookup', {
method: 'POST', method: 'POST',
headers: {'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'}, headers: {'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest','X-CSRF-TOKEN': csrfToken},
body: JSON.stringify({national_id: this.value}) body: JSON.stringify({national_id: this.value, _csrf_token: csrfToken})
}).then(function(r){return r.json();}).then(function(data) { }).then(function(r){return r.json();}).then(function(data) {
if (data.parsed && data.parsed.is_valid) { if (data.success) {
regNidInfo.style.display = 'block'; if (data.is_member) {
regNidInfo.style.color = '#059669'; memberBadge.style.background = '#ECFDF5';
regNidInfo.textContent = '✓ ' + data.parsed.governorate_name_ar + ' — ' + data.parsed.age_years + ' سنة — ' + (data.parsed.gender === 'male' ? 'ذكر' : 'أنثى'); memberBadge.style.color = '#059669';
} else { memberBadge.innerHTML = '<strong>عضو فعال</strong>' + (data.membership_number ? ' — رقم ' + data.membership_number : '');
regNidInfo.style.display = 'block'; } else if (data.reason) {
regNidInfo.style.color = '#DC2626'; memberBadge.style.background = '#FEF3C7';
regNidInfo.textContent = 'رقم قومي غير صالح'; memberBadge.style.color = '#92400E';
memberBadge.innerHTML = '<strong>غير عضو</strong> — ' + data.reason;
} else {
memberBadge.style.background = '#F3F4F6';
memberBadge.style.color = '#374151';
memberBadge.innerHTML = 'غير عضو';
}
memberBadge.style.display = '';
if (data.name && !document.getElementById('regNameAr').value.trim()) {
document.getElementById('regNameAr').value = data.name;
}
if (data.phone) {
var phoneInput = startForm.querySelector('[name="phone"]');
if (phoneInput && !phoneInput.value.trim()) phoneInput.value = data.phone;
}
} }
}); });
} else { } else {
regNidInfo.style.display = 'none'; memberBadge.style.display = 'none';
} }
}); });
} }
......
...@@ -30,6 +30,7 @@ MenuRegistry::register('sports_activity', [ ...@@ -30,6 +30,7 @@ MenuRegistry::register('sports_activity', [
['label_ar' => 'البرامج', 'label_en' => 'Programs', 'route' => '/sa/programs', 'permission' => 'sa.program.view', 'order' => 12], ['label_ar' => 'البرامج', 'label_en' => 'Programs', 'route' => '/sa/programs', 'permission' => 'sa.program.view', 'order' => 12],
['label_ar' => 'المجموعات', 'label_en' => 'Groups', 'route' => '/sa/groups', 'permission' => 'sa.group.view', 'order' => 13], ['label_ar' => 'المجموعات', 'label_en' => 'Groups', 'route' => '/sa/groups', 'permission' => 'sa.group.view', 'order' => 13],
['label_ar' => 'الجدول', 'label_en' => 'Schedule', 'route' => '/sa/schedule', 'permission' => 'sa.schedule.view', 'order' => 14], ['label_ar' => 'الجدول', 'label_en' => 'Schedule', 'route' => '/sa/schedule', 'permission' => 'sa.schedule.view', 'order' => 14],
['label_ar' => 'معالج الحجز', 'label_en' => 'Booking Wizard', 'route' => '/sa/booking-wizard', 'permission' => 'sa.booking_wizard.use','order' => 14.5],
['label_ar' => 'الحجوزات', 'label_en' => 'Bookings', 'route' => '/sa/bookings', 'permission' => 'sa.booking.view', 'order' => 15], ['label_ar' => 'الحجوزات', 'label_en' => 'Bookings', 'route' => '/sa/bookings', 'permission' => 'sa.booking.view', 'order' => 15],
['label_ar' => 'التسعير', 'label_en' => 'Pricing', 'route' => '/sa/pricing', 'permission' => 'sa.pricing.view', 'order' => 16], ['label_ar' => 'التسعير', 'label_en' => 'Pricing', 'route' => '/sa/pricing', 'permission' => 'sa.pricing.view', 'order' => 16],
['label_ar' => 'أسعار الأكاديميات', 'label_en' => 'Academy Pricing', 'route' => '/sa/academy-pricing','permission' => 'sa.pricing.view', 'order' => 16.5], ['label_ar' => 'أسعار الأكاديميات', 'label_en' => 'Academy Pricing', 'route' => '/sa/academy-pricing','permission' => 'sa.pricing.view', 'order' => 16.5],
...@@ -64,6 +65,7 @@ PermissionRegistry::register('sports_activity', [ ...@@ -64,6 +65,7 @@ PermissionRegistry::register('sports_activity', [
'sa.group.enroll' => ['ar' => 'تسجيل لاعب في مجموعة', 'en' => 'Enroll Player in Group'], 'sa.group.enroll' => ['ar' => 'تسجيل لاعب في مجموعة', 'en' => 'Enroll Player in Group'],
'sa.schedule.view' => ['ar' => 'عرض الجدول', 'en' => 'View Schedule'], 'sa.schedule.view' => ['ar' => 'عرض الجدول', 'en' => 'View Schedule'],
'sa.schedule.manage' => ['ar' => 'إدارة الجدول', 'en' => 'Manage Schedule'], 'sa.schedule.manage' => ['ar' => 'إدارة الجدول', 'en' => 'Manage Schedule'],
'sa.booking_wizard.use' => ['ar' => 'استخدام معالج الحجز', 'en' => 'Use Booking Wizard'],
'sa.booking.view' => ['ar' => 'عرض الحجوزات', 'en' => 'View Bookings'], 'sa.booking.view' => ['ar' => 'عرض الحجوزات', 'en' => 'View Bookings'],
'sa.booking.create' => ['ar' => 'إنشاء حجز', 'en' => 'Create Booking'], 'sa.booking.create' => ['ar' => 'إنشاء حجز', 'en' => 'Create Booking'],
'sa.booking.manage' => ['ar' => 'إدارة الحجوزات', 'en' => 'Manage Bookings'], 'sa.booking.manage' => ['ar' => 'إدارة الحجوزات', 'en' => 'Manage Bookings'],
......
...@@ -177,7 +177,7 @@ final class TreasuryService ...@@ -177,7 +177,7 @@ final class TreasuryService
} }
if ($treasury['code'] === 'SUB_SA') { if ($treasury['code'] === 'SUB_SA') {
$where .= " AND pr.payment_type IN ('activity_subscription','hourly_booking','sports_registration','sa_form_fee','sa_subscription')"; $where .= " AND pr.payment_type IN ('activity_subscription','hourly_booking','sports_registration','sa_form_fee','sa_subscription','sports_subscription')";
} elseif ($treasury['code'] === 'SUB_MEM') { } elseif ($treasury['code'] === 'SUB_MEM') {
$where .= " AND pr.payment_type IN ('form_fee','membership_fee','down_payment','addition_fee','annual_subscription','divorce_fee','death_fee','waiver_fee','separation_fee','fine','carnet_replacement','seasonal_fee')"; $where .= " AND pr.payment_type IN ('form_fee','membership_fee','down_payment','addition_fee','annual_subscription','divorce_fee','death_fee','waiver_fee','separation_fee','fine','carnet_replacement','seasonal_fee')";
} }
......
...@@ -1256,7 +1256,7 @@ final class TutorialRegistry ...@@ -1256,7 +1256,7 @@ final class TutorialRegistry
// ── SA REGISTRATION ── // ── SA REGISTRATION ──
'sa-registration.registration-wizard' => [ 'sa-registration.registration-wizard' => [
['title' => 'فتح مكتب التسجيل', 'body' => 'من القائمة: <span class="field">النشاط الرياضي</span> > <span class="field">مكتب التسجيل</span>.'], ['title' => 'فتح مكتب التسجيل', 'body' => 'من القائمة: <span class="field">النشاط الرياضي</span> > <span class="field">مكتب التسجيل</span>.'],
['title' => 'إدخال الرقم القومي', 'body' => 'أدخل الرقم القومي (14 رقم). النظام يستخرج تلقائياً: تاريخ الميلاد، العمر، النوع، المحافظة.<span class="info">إذا كان اللاعب مسجلاً مسبقاً — يتم استرجاع بياناته مباشرة.</span>'], ['title' => 'إدخال الرقم القومي', 'body' => 'أدخل الرقم القومي (14 رقم). النظام يستخرج تلقائياً: تاريخ الميلاد، العمر، النوع، المحافظة.<span class="info">النظام يكتشف تلقائياً إذا كان الشخص عضو فعال أم لا — لا حاجة لاختيار يدوي. العضو غير المسدد يُعامل كغير عضو.</span>'],
['title' => 'دفع استمارة الاشتراك', 'body' => 'الخطوة الأولى: سداد رسوم الاستمارة:<ul><li><span style="font-weight:bold;">100 ج.م</span> لغير الأعضاء</li><li><span style="font-weight:bold;">50 ج.م</span> للأعضاء</li></ul>يتم إرسال طلب دفع للخزينة. بعد السداد يمكن إكمال التسجيل.<span class="warn">لا يمكن التقاط الصورة أو اختيار النشاط قبل سداد الاستمارة.</span>'], ['title' => 'دفع استمارة الاشتراك', 'body' => 'الخطوة الأولى: سداد رسوم الاستمارة:<ul><li><span style="font-weight:bold;">100 ج.م</span> لغير الأعضاء</li><li><span style="font-weight:bold;">50 ج.م</span> للأعضاء</li></ul>يتم إرسال طلب دفع للخزينة. بعد السداد يمكن إكمال التسجيل.<span class="warn">لا يمكن التقاط الصورة أو اختيار النشاط قبل سداد الاستمارة.</span>'],
['title' => 'التقاط الصورة', 'body' => 'بعد سداد الاستمارة: التقط صورة عبر <span class="field">كاميرا الويب</span> أو ارفع ملف صورة. الصورة إلزامية.<span class="warn">الصورة تظهر على الاستمارة والكارت — تأكد من جودتها.</span>'], ['title' => 'التقاط الصورة', 'body' => 'بعد سداد الاستمارة: التقط صورة عبر <span class="field">كاميرا الويب</span> أو ارفع ملف صورة. الصورة إلزامية.<span class="warn">الصورة تظهر على الاستمارة والكارت — تأكد من جودتها.</span>'],
['title' => 'اختيار النشاط والمجموعة', 'body' => 'اختر المجموعة المناسبة (حسب السن والنشاط). يمكنك أيضاً:<ul><li>اختيار <span class="field">3 أشهر</span> للحصول على خصم 15%</li><li>تفعيل <span class="field">خصم الأشقاء</span> إن وُجد أخ/أخت بنفس النشاط</li></ul>يظهر تلقائياً: رسوم الكارت + الاستمارة + الاشتراك مع أي خصومات مطبقة.'], ['title' => 'اختيار النشاط والمجموعة', 'body' => 'اختر المجموعة المناسبة (حسب السن والنشاط). يمكنك أيضاً:<ul><li>اختيار <span class="field">3 أشهر</span> للحصول على خصم 15%</li><li>تفعيل <span class="field">خصم الأشقاء</span> إن وُجد أخ/أخت بنفس النشاط</li></ul>يظهر تلقائياً: رسوم الكارت + الاستمارة + الاشتراك مع أي خصومات مطبقة.'],
...@@ -1288,6 +1288,13 @@ final class TutorialRegistry ...@@ -1288,6 +1288,13 @@ final class TutorialRegistry
['title' => 'الدخول خلال المدة', 'body' => 'اللاعب يستخدم الكارت المؤقت للدخول من البوابة بشكل طبيعي خلال فترة الصلاحية.'], ['title' => 'الدخول خلال المدة', 'body' => 'اللاعب يستخدم الكارت المؤقت للدخول من البوابة بشكل طبيعي خلال فترة الصلاحية.'],
['title' => 'انتهاء الصلاحية', 'body' => 'بعد 7 أيام يتحول الكارت تلقائياً إلى حالة <span style="color:#6B7280;font-weight:bold;">منتهي</span> ويُرفض عند البوابة.<span class="warn">يجب إصدار كارت دائم قبل انتهاء المؤقت.</span>'], ['title' => 'انتهاء الصلاحية', 'body' => 'بعد 7 أيام يتحول الكارت تلقائياً إلى حالة <span style="color:#6B7280;font-weight:bold;">منتهي</span> ويُرفض عند البوابة.<span class="warn">يجب إصدار كارت دائم قبل انتهاء المؤقت.</span>'],
], ],
'sa-registration.booking-wizard' => [
['title' => 'فتح معالج الحجز', 'body' => 'من القائمة: <span class="field">النشاط الرياضي</span> > <span class="field">معالج الحجز</span>. هذا المعالج مصمم للعمل باللمس على شاشة الاستقبال.'],
['title' => 'تحديد الشخص', 'body' => 'أدخل الرقم القومي (14 رقم) — النظام يكتشف تلقائياً إذا كان عضو فعال (مسدد الاشتراك) أو غير عضو. يُطبق التسعير المناسب تلقائياً.<span class="info">يمكن إدخال الاسم فقط بدون رقم قومي للزوار — يُعامل كغير عضو.</span>'],
['title' => 'اختيار المرفق', 'body' => 'تظهر كروت المرافق المتاحة (ملاعب، أحواض سباحة، كورتات). اضغط على الوحدة المطلوبة.<span class="info">الوحدات المغلقة أو المحجوبة لا تظهر.</span>'],
['title' => 'اختيار الموعد', 'body' => 'يعرض شبكة المواعيد:<ul><li><span style="color:#059669;">أخضر</span> = متاح</li><li><span style="color:#DC2626;">أحمر</span> = محجوز</li><li><span style="color:#D97706;">أصفر</span> = متاح جزئياً (وحدات مشتركة)</li></ul>اضغط الموعد المطلوب. حدد عدد المشاركين. يظهر السعر تلقائياً.'],
['title' => 'تأكيد وإرسال للخزينة', 'body' => 'يعرض ملخص: الشخص، المرفق، الموعد، والسعر الإجمالي. اضغط <span class="field">تأكيد وإرسال للخزينة</span>.<span class="success">يتم إنشاء الحجز + طلب دفع. بعد التحصيل من الخزينة يصبح الحجز مؤكداً.</span>'],
],
'sa-registration.academy-pricing' => [ 'sa-registration.academy-pricing' => [
['title' => 'فتح أسعار الأكاديميات', 'body' => 'من القائمة: <span class="field">النشاط الرياضي</span> > <span class="field">أسعار الأكاديميات</span>. تعرض قائمة كل الأكاديميات مع نطاق الأسعار.'], ['title' => 'فتح أسعار الأكاديميات', 'body' => 'من القائمة: <span class="field">النشاط الرياضي</span> > <span class="field">أسعار الأكاديميات</span>. تعرض قائمة كل الأكاديميات مع نطاق الأسعار.'],
['title' => 'عرض تفاصيل أكاديمية', 'body' => 'اضغط <span class="field">عرض</span> بجانب أي أكاديمية. يظهر:<ul><li>أسعار الاشتراكات (عضو/غير عضو)</li><li>أسعار الفرق</li><li>التدريب الخاص</li><li>كروت الحصص</li></ul>'], ['title' => 'عرض تفاصيل أكاديمية', 'body' => 'اضغط <span class="field">عرض</span> بجانب أي أكاديمية. يظهر:<ul><li>أسعار الاشتراكات (عضو/غير عضو)</li><li>أسعار الفرق</li><li>التدريب الخاص</li><li>كروت الحصص</li></ul>'],
...@@ -3375,13 +3382,21 @@ final class TutorialRegistry ...@@ -3375,13 +3382,21 @@ final class TutorialRegistry
'category' => 'cards', 'category' => 'cards',
'order' => 5, 'order' => 5,
], ],
'booking-wizard' => [
'title' => 'معالج الحجز بالساعة',
'subtitle' => 'حجز ملاعب ومرافق بالساعة — اكتشاف العضوية التلقائي',
'icon' => 'calendar-clock',
'color' => '#0D7377',
'category' => 'booking',
'order' => 6,
],
'academy-pricing' => [ 'academy-pricing' => [
'title' => 'أسعار الأكاديميات', 'title' => 'أسعار الأكاديميات',
'subtitle' => 'عرض وحساب أسعار الاشتراكات والخصومات', 'subtitle' => 'عرض وحساب أسعار الاشتراكات والخصومات',
'icon' => 'calculator', 'icon' => 'calculator',
'color' => '#D97706', 'color' => '#D97706',
'category' => 'registration', 'category' => 'registration',
'order' => 6, 'order' => 7,
], ],
]; ];
} }
...@@ -3392,6 +3407,7 @@ final class TutorialRegistry ...@@ -3392,6 +3407,7 @@ final class TutorialRegistry
'registration' => ['label' => 'التسجيل', 'icon' => 'clipboard-check', 'color' => '#0891B2'], 'registration' => ['label' => 'التسجيل', 'icon' => 'clipboard-check', 'color' => '#0891B2'],
'cards' => ['label' => 'الكروت', 'icon' => 'id-card', 'color' => '#2563EB'], 'cards' => ['label' => 'الكروت', 'icon' => 'id-card', 'color' => '#2563EB'],
'gate' => ['label' => 'البوابة', 'icon' => 'scan', 'color' => '#059669'], 'gate' => ['label' => 'البوابة', 'icon' => 'scan', 'color' => '#059669'],
'booking' => ['label' => 'الحجز', 'icon' => 'calendar-clock', 'color' => '#0D7377'],
'reports' => ['label' => 'التقارير', 'icon' => 'bar-chart-3', 'color' => '#7C3AED'], 'reports' => ['label' => 'التقارير', 'icon' => 'bar-chart-3', 'color' => '#7C3AED'],
]; ];
} }
......
...@@ -124,7 +124,7 @@ class UserPermissionController extends Controller ...@@ -124,7 +124,7 @@ class UserPermissionController extends Controller
$allRolesMap = []; $allRolesMap = [];
foreach (Role::allActive() as $r) { foreach (Role::allActive() as $r) {
$allRolesMap[(int) $r->id] = $r->role_code; $allRolesMap[(int) $r['id']] = $r['role_code'];
} }
$addedRoles = array_diff($newRoleIds, $oldRoleIds); $addedRoles = array_diff($newRoleIds, $oldRoleIds);
......
<?php
declare(strict_types=1);
return [
'up' => "ALTER TABLE `sa_bookings` ADD COLUMN `booker_national_id` VARCHAR(14) NULL AFTER `booker_name`;\nALTER TABLE `sa_bookings` ADD INDEX `idx_sa_bk_nid` (`booker_national_id`);",
'down' => "ALTER TABLE `sa_bookings` DROP INDEX `idx_sa_bk_nid`;\nALTER TABLE `sa_bookings` DROP COLUMN `booker_national_id`;"
];
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment