Commit c95ccac3 authored by Mahmoud Aglan's avatar Mahmoud Aglan

testing

parent e740ea76
......@@ -24,7 +24,7 @@ final class PaymentRequestService
$notes = $data['notes'] ?? null;
$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' => 'العضو مطلوب'];
}
if ($paymentType === '') {
......
......@@ -9,6 +9,7 @@ use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\Payments\Services\PaymentService;
use App\Modules\Installments\Services\InstallmentCalculator;
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
{
$db = App::getInstance()->db();
......
......@@ -4,6 +4,7 @@ declare(strict_types=1);
return [
['GET', '/installments', 'Installments\Controllers\InstallmentController@index', ['auth'], 'installment.view'],
['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'],
['POST', '/installments/{planId}/pay/{scheduleId}', 'Installments\Controllers\InstallmentController@payInstallment', ['auth', 'csrf'], 'installment.pay'],
......
......@@ -69,17 +69,15 @@ class OpeningBalanceController extends Controller
$balanceDate = $request->post('balance_date', date('Y-m-d'));
$batchReference = 'OB-' . date('Ymd-His');
$itemIds = $request->post('item_ids', []);
$quantities = $request->post('quantities', []);
$unitCosts = $request->post('unit_costs', []);
$items = $request->post('items', []);
$count = 0;
$db->beginTransaction();
try {
for ($i = 0; $i < count($itemIds); $i++) {
$itemId = (int) ($itemIds[$i] ?? 0);
$qty = (string) ($quantities[$i] ?? '0');
$unitCost = (string) ($unitCosts[$i] ?? '0');
foreach ($items as $row) {
$itemId = (int) ($row['item_id'] ?? 0);
$qty = (string) ($row['quantity'] ?? '0');
$unitCost = (string) ($row['unit_cost'] ?? '0');
if ($itemId <= 0 || bccomp($qty, '0', 3) <= 0) continue;
......
......@@ -223,7 +223,7 @@ final class StockService
if ($existing) {
$op = $direction === 'in' ? '+' : '-';
$db->statement(
$db->query(
"UPDATE `item_warehouse_stock` SET `quantity` = `quantity` {$op} ?, `updated_at` = NOW() WHERE `item_id` = ? AND `warehouse_id` = ?",
[$quantity, $itemId, $warehouseId]
);
......@@ -241,12 +241,11 @@ final class StockService
private static function deductBatch(int $batchId, string $quantity): void
{
$db = App::getInstance()->db();
$db->statement(
$db->query(
"UPDATE `item_batches` SET `quantity` = `quantity` - ? WHERE `id` = ?",
[$quantity, $batchId]
);
// Mark depleted if quantity reaches zero
$db->statement(
$db->query(
"UPDATE `item_batches` SET `status` = 'depleted' WHERE `id` = ? AND `quantity` <= 0",
[$batchId]
);
......
<?php
declare(strict_types=1);
use App\Core\PermissionRegistry;
use App\Core\MenuRegistry;
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
PermissionRegistry::register('medical.board.view', 'عرض لجنة الشهادات الطبية');
PermissionRegistry::register('medical.board.approve', 'اعتماد/رفض الشهادات الطبية');
PermissionRegistry::register('medical_board', [
'medical.board.view' => ['ar' => 'عرض لجنة الشهادات الطبية', 'en' => 'View Medical Board'],
'medical.board.approve' => ['ar' => 'اعتماد/رفض الشهادات الطبية', 'en' => 'Approve/Reject Medical Certificates'],
]);
MenuRegistry::register('medical-board', [
'label' => 'اللجنة الطبية',
'icon' => 'heartbeat',
'url' => '/medical-board',
'label_ar' => 'اللجنة الطبية',
'label_en' => 'Medical Board',
'icon' => 'heart-pulse',
'route' => '/medical-board',
'permission' => 'medical.board.view',
'group' => 'الرياضة',
'order' => 710,
'parent' => null,
'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 @@
<small style="color:#6B7280;">صورة واضحة للوجه — مطلوبة لإصدار كارنيه العضوية</small>
</div>
<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>
......
......@@ -3,6 +3,6 @@ declare(strict_types=1);
use App\Core\Registries\PermissionRegistry;
PermissionRegistry::register('player_auth', 'إدارة مصادقة اللاعبين', [
'player_auth.manage_tokens' => 'إدارة توكنات اللاعبين',
PermissionRegistry::register('player_auth', [
'player_auth.manage_tokens' => ['ar' => 'إدارة توكنات اللاعبين', 'en' => 'Manage Player Tokens'],
]);
<?php
declare(strict_types=1);
use App\Modules\Procurement\Controllers\ProcurementDashboardController;
use App\Modules\Procurement\Controllers\RequisitionController;
use App\Modules\Procurement\Controllers\GoodsReceivedNoteController;
use App\Modules\Procurement\Controllers\VendorInvoiceController;
use App\Modules\Procurement\Controllers\VendorPaymentController;
use App\Modules\Procurement\Controllers\ReturnToVendorController;
use App\Modules\Procurement\Controllers\ProcurementReportController;
return [
// ── Dashboard ──
['GET', '/procurement', ProcurementDashboardController::class . '@index', ['auth'], 'procurement.dashboard'],
['GET', '/procurement', 'Procurement\Controllers\ProcurementDashboardController@index', ['auth'], 'procurement.dashboard'],
// ── Purchase Requisitions ──
['GET', '/procurement/requisitions', RequisitionController::class . '@index', ['auth'], 'procurement.pr.view'],
['GET', '/procurement/requisitions/create', RequisitionController::class . '@create', ['auth'], 'procurement.pr.create'],
['POST', '/procurement/requisitions', RequisitionController::class . '@store', ['auth', 'csrf'], 'procurement.pr.create'],
['GET', '/procurement/requisitions/{id}', RequisitionController::class . '@show', ['auth'], 'procurement.pr.view'],
['GET', '/procurement/requisitions/{id}/edit', RequisitionController::class . '@edit', ['auth'], 'procurement.pr.create'],
['POST', '/procurement/requisitions/{id}/update', RequisitionController::class . '@update', ['auth', 'csrf'], 'procurement.pr.create'],
['POST', '/procurement/requisitions/{id}/submit', RequisitionController::class . '@submit', ['auth', 'csrf'], 'procurement.pr.create'],
['POST', '/procurement/requisitions/{id}/approve', RequisitionController::class . '@approve', ['auth', 'csrf'], 'procurement.pr.approve'],
['POST', '/procurement/requisitions/{id}/reject', RequisitionController::class . '@reject', ['auth', 'csrf'], 'procurement.pr.approve'],
['POST', '/procurement/requisitions/{id}/convert', RequisitionController::class . '@convert', ['auth', 'csrf'], 'procurement.pr.convert'],
['POST', '/procurement/requisitions/{id}/cancel', RequisitionController::class . '@cancel', ['auth', 'csrf'], 'procurement.pr.create'],
['GET', '/procurement/requisitions', 'Procurement\Controllers\RequisitionController@index', ['auth'], 'procurement.pr.view'],
['GET', '/procurement/requisitions/create', 'Procurement\Controllers\RequisitionController@create', ['auth'], 'procurement.pr.create'],
['POST', '/procurement/requisitions', 'Procurement\Controllers\RequisitionController@store', ['auth', 'csrf'], 'procurement.pr.create'],
['GET', '/procurement/requisitions/{id}', 'Procurement\Controllers\RequisitionController@show', ['auth'], 'procurement.pr.view'],
['GET', '/procurement/requisitions/{id}/edit', 'Procurement\Controllers\RequisitionController@edit', ['auth'], 'procurement.pr.create'],
['POST', '/procurement/requisitions/{id}/update', 'Procurement\Controllers\RequisitionController@update', ['auth', 'csrf'], 'procurement.pr.create'],
['POST', '/procurement/requisitions/{id}/submit', 'Procurement\Controllers\RequisitionController@submit', ['auth', 'csrf'], 'procurement.pr.create'],
['POST', '/procurement/requisitions/{id}/approve', 'Procurement\Controllers\RequisitionController@approve', ['auth', 'csrf'], 'procurement.pr.approve'],
['POST', '/procurement/requisitions/{id}/reject', 'Procurement\Controllers\RequisitionController@reject', ['auth', 'csrf'], 'procurement.pr.approve'],
['POST', '/procurement/requisitions/{id}/convert', 'Procurement\Controllers\RequisitionController@convert', ['auth', 'csrf'], 'procurement.pr.convert'],
['POST', '/procurement/requisitions/{id}/cancel', 'Procurement\Controllers\RequisitionController@cancel', ['auth', 'csrf'], 'procurement.pr.create'],
// ── Goods Received Notes ──
['GET', '/procurement/grn', GoodsReceivedNoteController::class . '@index', ['auth'], 'procurement.grn.view'],
['GET', '/procurement/grn/create', GoodsReceivedNoteController::class . '@create', ['auth'], 'procurement.grn.create'],
['POST', '/procurement/grn', GoodsReceivedNoteController::class . '@store', ['auth', 'csrf'], 'procurement.grn.create'],
['GET', '/procurement/grn/{id}', GoodsReceivedNoteController::class . '@show', ['auth'], 'procurement.grn.view'],
['GET', '/procurement/grn/{id}/inspect', GoodsReceivedNoteController::class . '@inspectForm', ['auth'], 'procurement.grn.inspect'],
['POST', '/procurement/grn/{id}/inspect', GoodsReceivedNoteController::class . '@inspect', ['auth', 'csrf'], 'procurement.grn.inspect'],
['POST', '/procurement/grn/{id}/cancel', GoodsReceivedNoteController::class . '@cancel', ['auth', 'csrf'], 'procurement.grn.create'],
['GET', '/procurement/grn', 'Procurement\Controllers\GoodsReceivedNoteController@index', ['auth'], 'procurement.grn.view'],
['GET', '/procurement/grn/create', 'Procurement\Controllers\GoodsReceivedNoteController@create', ['auth'], 'procurement.grn.create'],
['POST', '/procurement/grn', 'Procurement\Controllers\GoodsReceivedNoteController@store', ['auth', 'csrf'], 'procurement.grn.create'],
['GET', '/procurement/grn/{id}', 'Procurement\Controllers\GoodsReceivedNoteController@show', ['auth'], 'procurement.grn.view'],
['GET', '/procurement/grn/{id}/inspect', 'Procurement\Controllers\GoodsReceivedNoteController@inspectForm', ['auth'], 'procurement.grn.inspect'],
['POST', '/procurement/grn/{id}/inspect', 'Procurement\Controllers\GoodsReceivedNoteController@inspect', ['auth', 'csrf'], 'procurement.grn.inspect'],
['POST', '/procurement/grn/{id}/cancel', 'Procurement\Controllers\GoodsReceivedNoteController@cancel', ['auth', 'csrf'], 'procurement.grn.create'],
// ── Vendor Invoices ──
['GET', '/procurement/invoices', VendorInvoiceController::class . '@index', ['auth'], 'procurement.invoice.view'],
['GET', '/procurement/invoices/create', VendorInvoiceController::class . '@create', ['auth'], 'procurement.invoice.create'],
['POST', '/procurement/invoices', VendorInvoiceController::class . '@store', ['auth', 'csrf'], 'procurement.invoice.create'],
['GET', '/procurement/invoices/{id}', VendorInvoiceController::class . '@show', ['auth'], 'procurement.invoice.view'],
['GET', '/procurement/invoices/{id}/edit', VendorInvoiceController::class . '@edit', ['auth'], 'procurement.invoice.create'],
['POST', '/procurement/invoices/{id}/update', VendorInvoiceController::class . '@update', ['auth', 'csrf'], 'procurement.invoice.create'],
['POST', '/procurement/invoices/{id}/verify', VendorInvoiceController::class . '@verify', ['auth', 'csrf'], 'procurement.invoice.create'],
['POST', '/procurement/invoices/{id}/approve', VendorInvoiceController::class . '@approve', ['auth', 'csrf'], 'procurement.invoice.approve'],
['POST', '/procurement/invoices/{id}/cancel', VendorInvoiceController::class . '@cancel', ['auth', 'csrf'], 'procurement.invoice.approve'],
['GET', '/procurement/invoices/{id}/match', VendorInvoiceController::class . '@matchView', ['auth'], 'procurement.invoice.view'],
['GET', '/procurement/invoices', 'Procurement\Controllers\VendorInvoiceController@index', ['auth'], 'procurement.invoice.view'],
['GET', '/procurement/invoices/create', 'Procurement\Controllers\VendorInvoiceController@create', ['auth'], 'procurement.invoice.create'],
['POST', '/procurement/invoices', 'Procurement\Controllers\VendorInvoiceController@store', ['auth', 'csrf'], 'procurement.invoice.create'],
['GET', '/procurement/invoices/{id}', 'Procurement\Controllers\VendorInvoiceController@show', ['auth'], 'procurement.invoice.view'],
['GET', '/procurement/invoices/{id}/edit', 'Procurement\Controllers\VendorInvoiceController@edit', ['auth'], 'procurement.invoice.create'],
['POST', '/procurement/invoices/{id}/update', 'Procurement\Controllers\VendorInvoiceController@update', ['auth', 'csrf'], 'procurement.invoice.create'],
['POST', '/procurement/invoices/{id}/verify', 'Procurement\Controllers\VendorInvoiceController@verify', ['auth', 'csrf'], 'procurement.invoice.create'],
['POST', '/procurement/invoices/{id}/approve', 'Procurement\Controllers\VendorInvoiceController@approve', ['auth', 'csrf'], 'procurement.invoice.approve'],
['POST', '/procurement/invoices/{id}/cancel', 'Procurement\Controllers\VendorInvoiceController@cancel', ['auth', 'csrf'], 'procurement.invoice.approve'],
['GET', '/procurement/invoices/{id}/match', 'Procurement\Controllers\VendorInvoiceController@matchView', ['auth'], 'procurement.invoice.view'],
// ── Vendor Payments ──
['GET', '/procurement/payments', VendorPaymentController::class . '@index', ['auth'], 'procurement.payment.view'],
['GET', '/procurement/payments/create', VendorPaymentController::class . '@create', ['auth'], 'procurement.payment.create'],
['POST', '/procurement/payments', VendorPaymentController::class . '@store', ['auth', 'csrf'], 'procurement.payment.create'],
['GET', '/procurement/payments/{id}', VendorPaymentController::class . '@show', ['auth'], 'procurement.payment.view'],
['POST', '/procurement/payments/{id}/approve', VendorPaymentController::class . '@approve', ['auth', 'csrf'], 'procurement.payment.approve'],
['POST', '/procurement/payments/{id}/complete', VendorPaymentController::class . '@complete', ['auth', 'csrf'], 'procurement.payment.approve'],
['POST', '/procurement/payments/{id}/void', VendorPaymentController::class . '@void', ['auth', 'csrf'], 'procurement.payment.approve'],
['GET', '/procurement/payments', 'Procurement\Controllers\VendorPaymentController@index', ['auth'], 'procurement.payment.view'],
['GET', '/procurement/payments/create', 'Procurement\Controllers\VendorPaymentController@create', ['auth'], 'procurement.payment.create'],
['POST', '/procurement/payments', 'Procurement\Controllers\VendorPaymentController@store', ['auth', 'csrf'], 'procurement.payment.create'],
['GET', '/procurement/payments/{id}', 'Procurement\Controllers\VendorPaymentController@show', ['auth'], 'procurement.payment.view'],
['POST', '/procurement/payments/{id}/approve', 'Procurement\Controllers\VendorPaymentController@approve', ['auth', 'csrf'], 'procurement.payment.approve'],
['POST', '/procurement/payments/{id}/complete', 'Procurement\Controllers\VendorPaymentController@complete', ['auth', 'csrf'], 'procurement.payment.approve'],
['POST', '/procurement/payments/{id}/void', 'Procurement\Controllers\VendorPaymentController@void', ['auth', 'csrf'], 'procurement.payment.approve'],
// ── Return to Vendor ──
['GET', '/procurement/rtv', ReturnToVendorController::class . '@index', ['auth'], 'procurement.rtv.view'],
['GET', '/procurement/rtv/create', ReturnToVendorController::class . '@create', ['auth'], 'procurement.rtv.create'],
['POST', '/procurement/rtv', ReturnToVendorController::class . '@store', ['auth', 'csrf'], 'procurement.rtv.create'],
['GET', '/procurement/rtv/{id}', ReturnToVendorController::class . '@show', ['auth'], 'procurement.rtv.view'],
['GET', '/procurement/rtv/{id}/edit', ReturnToVendorController::class . '@edit', ['auth'], 'procurement.rtv.create'],
['POST', '/procurement/rtv/{id}/update', ReturnToVendorController::class . '@update', ['auth', 'csrf'], 'procurement.rtv.create'],
['POST', '/procurement/rtv/{id}/submit', ReturnToVendorController::class . '@submit', ['auth', 'csrf'], 'procurement.rtv.create'],
['POST', '/procurement/rtv/{id}/approve', ReturnToVendorController::class . '@approve', ['auth', 'csrf'], 'procurement.rtv.approve'],
['POST', '/procurement/rtv/{id}/ship', ReturnToVendorController::class . '@ship', ['auth', 'csrf'], 'procurement.rtv.manage'],
['POST', '/procurement/rtv/{id}/complete', ReturnToVendorController::class . '@complete', ['auth', 'csrf'], 'procurement.rtv.manage'],
['POST', '/procurement/rtv/{id}/cancel', ReturnToVendorController::class . '@cancel', ['auth', 'csrf'], 'procurement.rtv.create'],
['GET', '/procurement/rtv', 'Procurement\Controllers\ReturnToVendorController@index', ['auth'], 'procurement.rtv.view'],
['GET', '/procurement/rtv/create', 'Procurement\Controllers\ReturnToVendorController@create', ['auth'], 'procurement.rtv.create'],
['POST', '/procurement/rtv', 'Procurement\Controllers\ReturnToVendorController@store', ['auth', 'csrf'], 'procurement.rtv.create'],
['GET', '/procurement/rtv/{id}', 'Procurement\Controllers\ReturnToVendorController@show', ['auth'], 'procurement.rtv.view'],
['GET', '/procurement/rtv/{id}/edit', 'Procurement\Controllers\ReturnToVendorController@edit', ['auth'], 'procurement.rtv.create'],
['POST', '/procurement/rtv/{id}/update', 'Procurement\Controllers\ReturnToVendorController@update', ['auth', 'csrf'], 'procurement.rtv.create'],
['POST', '/procurement/rtv/{id}/submit', 'Procurement\Controllers\ReturnToVendorController@submit', ['auth', 'csrf'], 'procurement.rtv.create'],
['POST', '/procurement/rtv/{id}/approve', 'Procurement\Controllers\ReturnToVendorController@approve', ['auth', 'csrf'], 'procurement.rtv.approve'],
['POST', '/procurement/rtv/{id}/ship', 'Procurement\Controllers\ReturnToVendorController@ship', ['auth', 'csrf'], 'procurement.rtv.manage'],
['POST', '/procurement/rtv/{id}/complete', 'Procurement\Controllers\ReturnToVendorController@complete', ['auth', 'csrf'], 'procurement.rtv.manage'],
['POST', '/procurement/rtv/{id}/cancel', 'Procurement\Controllers\ReturnToVendorController@cancel', ['auth', 'csrf'], 'procurement.rtv.create'],
// ── Supplier Price Quotes ──
['GET', '/procurement/quotes', 'Procurement\Controllers\QuoteController@index', ['auth'], 'procurement.quote.view'],
......@@ -85,8 +77,8 @@ return [
['GET', '/procurement/reports/price-deviation', 'Procurement\Controllers\DeliveryTrackingController@priceDeviation', ['auth'], 'procurement.report'],
// ── Reports ──
['GET', '/procurement/reports/purchase-volume', ProcurementReportController::class . '@purchaseVolume', ['auth'], 'procurement.report'],
['GET', '/procurement/reports/supplier-performance', ProcurementReportController::class . '@supplierPerformance', ['auth'], 'procurement.report'],
['GET', '/procurement/reports/match-status', ProcurementReportController::class . '@matchStatus', ['auth'], 'procurement.report'],
['GET', '/procurement/reports/overdue-invoices', ProcurementReportController::class . '@overdueInvoices', ['auth'], 'procurement.report'],
['GET', '/procurement/reports/purchase-volume', 'Procurement\Controllers\ProcurementReportController@purchaseVolume', ['auth'], 'procurement.report'],
['GET', '/procurement/reports/supplier-performance', 'Procurement\Controllers\ProcurementReportController@supplierPerformance', ['auth'], 'procurement.report'],
['GET', '/procurement/reports/match-status', 'Procurement\Controllers\ProcurementReportController@matchStatus', ['auth'], 'procurement.report'],
['GET', '/procurement/reports/overdue-invoices', 'Procurement\Controllers\ProcurementReportController@overdueInvoices', ['auth'], 'procurement.report'],
];
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Controllers;
use App\Core\App;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\Members\Services\MembershipValidationService;
use App\Modules\Members\Services\NationalIdParser;
use App\Modules\SportsActivity\Services\BookingService;
use App\Modules\SportsActivity\Services\PricingCalculatorService;
use App\Modules\SportsActivity\Services\SlotAvailabilityService;
use App\Modules\Cashier\Services\PaymentRequestService;
class BookingWizardController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('sa.booking_wizard.use');
$db = App::getInstance()->db();
$recentBookings = $db->select(
"SELECT b.id, b.booking_number, b.booker_name, b.booker_type, b.booker_national_id,
b.booking_date, b.start_time, b.end_time, b.total_amount, b.payment_status, b.status,
fu.name_ar as unit_name, f.name_ar as facility_name
FROM sa_bookings b
LEFT JOIN sa_facility_units fu ON fu.id = b.facility_unit_id
LEFT JOIN sa_facilities f ON f.id = fu.facility_id
WHERE b.booking_type = 'hourly'
ORDER BY b.id DESC LIMIT 20"
);
return $this->view('SportsActivity.Views.bookings.wizard', [
'recentBookings' => $recentBookings,
]);
}
public function lookup(Request $request): Response
{
$this->authorize('sa.booking_wizard.use');
$nationalId = trim((string) $request->post('national_id', ''));
$name = trim((string) $request->post('name', ''));
if ($nationalId === '' && $name === '') {
return $this->json(['success' => false, 'error' => 'أدخل الرقم القومي أو الاسم']);
}
$result = [
'success' => true,
'national_id' => $nationalId,
'effective_type' => 'non_member',
'is_member' => false,
'member_id' => null,
'reason' => null,
'name' => $name,
'phone' => null,
'gender' => null,
'date_of_birth' => null,
'age' => null,
];
if ($nationalId !== '' && strlen($nationalId) === 14) {
$nidParsed = NationalIdParser::parse($nationalId);
if ($nidParsed && ($nidParsed['is_valid'] ?? false)) {
$result['date_of_birth'] = $nidParsed['dob'] ?? null;
$result['age'] = $nidParsed['age_years'] ?? null;
$result['gender'] = $nidParsed['gender'] ?? null;
}
$membership = MembershipValidationService::checkByNationalId($nationalId);
$result['effective_type'] = $membership['effective_type'];
$result['is_member'] = $membership['is_active_member'];
$result['member_id'] = $membership['member_id'];
$result['reason'] = $membership['reason'];
if ($membership['found'] && $membership['member']) {
$m = $membership['member'];
$result['name'] = $result['name'] ?: ($m['full_name_ar'] ?? '');
$result['phone'] = $m['phone'] ?? null;
$result['membership_number'] = $m['membership_number'] ?? null;
if (!$result['date_of_birth'] && !empty($m['date_of_birth'])) {
$result['date_of_birth'] = $m['date_of_birth'];
}
if (!$result['gender'] && !empty($m['gender'])) {
$result['gender'] = $m['gender'];
}
}
}
return $this->json($result);
}
public function units(Request $request): Response
{
$this->authorize('sa.booking_wizard.use');
$db = App::getInstance()->db();
$units = $db->select(
"SELECT fu.id, fu.code, fu.name_ar, fu.unit_type, fu.booking_mode, fu.max_capacity,
f.id as facility_id, f.name_ar as facility_name, f.facility_type, f.operating_hours_json
FROM sa_facility_units fu
JOIN sa_facilities f ON f.id = fu.facility_id
WHERE fu.is_active = 1 AND f.is_archived = 0
ORDER BY f.name_ar, fu.name_ar"
);
$grouped = [];
foreach ($units as $u) {
$fId = (int) $u['facility_id'];
if (!isset($grouped[$fId])) {
$grouped[$fId] = [
'id' => $fId,
'name' => $u['facility_name'],
'type' => $u['facility_type'],
'operating_hours' => json_decode($u['operating_hours_json'] ?: '{}', true),
'units' => [],
];
}
$grouped[$fId]['units'][] = [
'id' => (int) $u['id'],
'code' => $u['code'],
'name' => $u['name_ar'],
'unit_type' => $u['unit_type'],
'booking_mode' => $u['booking_mode'],
'max_capacity' => (int) $u['max_capacity'],
];
}
return $this->json(['success' => true, 'facilities' => array_values($grouped)]);
}
public function slots(Request $request): Response
{
$this->authorize('sa.booking_wizard.use');
$unitId = (int) $request->get('unit_id', 0);
$date = trim((string) $request->get('date', date('Y-m-d')));
if ($unitId <= 0) {
return $this->json(['success' => false, 'error' => 'اختر الوحدة']);
}
$db = App::getInstance()->db();
$unit = $db->selectOne(
"SELECT fu.*, f.operating_hours_json, f.id as fac_id
FROM sa_facility_units fu
JOIN sa_facilities f ON f.id = fu.facility_id
WHERE fu.id = ? AND fu.is_active = 1",
[$unitId]
);
if (!$unit) {
return $this->json(['success' => false, 'error' => 'الوحدة غير موجودة']);
}
$hours = json_decode($unit['operating_hours_json'] ?: '{}', true);
$start = $hours['start'] ?? '06:00';
$end = $hours['end'] ?? '22:00';
$slotMinutes = (int) ($hours['slot_minutes'] ?? 60);
$existingBookings = SlotAvailabilityService::getUnitScheduleForDate($unitId, $date);
$blackouts = $db->select(
"SELECT start_time, end_time FROM sa_blackout_dates
WHERE (facility_id = ? OR facility_unit_id = ?) AND blackout_date = ?",
[(int) $unit['fac_id'], $unitId, $date]
);
$slots = [];
$current = strtotime($date . ' ' . $start);
$endTs = strtotime($date . ' ' . $end);
while ($current < $endTs) {
$slotStart = date('H:i', $current);
$slotEnd = date('H:i', $current + $slotMinutes * 60);
$status = 'free';
$remaining = (int) $unit['max_capacity'];
$bookerInfo = null;
foreach ($blackouts as $bo) {
if ($bo['start_time'] === null || ($slotStart >= $bo['start_time'] && $slotStart < $bo['end_time'])) {
$status = 'blackout';
$remaining = 0;
break;
}
}
if ($status === 'free') {
$occupiedSpots = 0;
foreach ($existingBookings as $bk) {
if ($bk['start_time'] < $slotEnd && $bk['end_time'] > $slotStart) {
$occupiedSpots += (int) ($bk['spots_reserved'] ?? 1);
$bookerInfo = $bk['booker_name'] ?? $bk['group_name'] ?? null;
}
}
if ($unit['booking_mode'] === 'exclusive') {
if ($occupiedSpots > 0) {
$status = 'occupied';
$remaining = 0;
}
} else {
$remaining = (int) $unit['max_capacity'] - $occupiedSpots;
if ($remaining <= 0) {
$status = 'occupied';
$remaining = 0;
} elseif ($occupiedSpots > 0) {
$status = 'partial';
}
}
}
$slots[] = [
'start_time' => $slotStart,
'end_time' => $slotEnd,
'status' => $status,
'remaining_capacity' => $remaining,
'booker' => $status !== 'free' ? $bookerInfo : null,
];
$current += $slotMinutes * 60;
}
return $this->json([
'success' => true,
'unit_name' => $unit['name_ar'],
'booking_mode' => $unit['booking_mode'],
'max_capacity' => (int) $unit['max_capacity'],
'slots' => $slots,
]);
}
public function book(Request $request): Response
{
$this->authorize('sa.booking_wizard.use');
$unitId = (int) $request->post('unit_id', 0);
$date = trim((string) $request->post('date', ''));
$startTime = trim((string) $request->post('start_time', ''));
$endTime = trim((string) $request->post('end_time', ''));
$participants = (int) $request->post('participants', 1);
$bookerName = trim((string) $request->post('booker_name', ''));
$nationalId = trim((string) $request->post('national_id', ''));
$isMember = (bool) $request->post('is_member', false);
$memberId = (int) $request->post('member_id', 0);
if ($unitId <= 0 || $date === '' || $startTime === '' || $endTime === '') {
return $this->json(['success' => false, 'error' => 'بيانات الحجز غير مكتملة']);
}
if ($bookerName === '') {
return $this->json(['success' => false, 'error' => 'اسم الحاجز مطلوب']);
}
$branch = App::getInstance()->currentBranch();
$bookerType = $isMember ? 'member' : 'guest';
$bookingResult = BookingService::createHourlyBooking([
'facility_unit_id' => $unitId,
'booking_date' => $date,
'start_time' => $startTime,
'end_time' => $endTime,
'participant_count' => $participants,
'spots_reserved' => $participants,
'booker_type' => $bookerType,
'booker_id' => $memberId > 0 ? $memberId : null,
'booker_name' => $bookerName,
'notes' => null,
'branch_id' => $branch ? (int) $branch['id'] : null,
]);
if (!$bookingResult['success']) {
return $this->json($bookingResult);
}
$bookingId = $bookingResult['booking_id'];
if ($nationalId !== '') {
$db = App::getInstance()->db();
$db->update('sa_bookings', ['booker_national_id' => $nationalId], 'id = ?', [$bookingId]);
}
$totalAmount = (float) $bookingResult['total_amount'];
if ($totalAmount > 0) {
$payResult = PaymentRequestService::createRequest([
'member_id' => $memberId,
'payment_type' => 'hourly_booking',
'amount' => (string) $totalAmount,
'description_ar' => 'حجز ساعي — ' . $bookerName,
'related_entity_type' => 'sa_bookings',
'related_entity_id' => $bookingId,
]);
if ($payResult['success']) {
$db = App::getInstance()->db();
$db->update('sa_bookings', [
'payment_status' => 'pending',
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$bookingId]);
}
}
return $this->json([
'success' => true,
'booking_id' => $bookingId,
'booking_number' => $bookingResult['booking_number'],
'total_amount' => $totalAmount,
'pricing' => $bookingResult['pricing'] ?? null,
]);
}
}
......@@ -9,6 +9,7 @@ use App\Core\Response;
use App\Core\App;
use App\Core\Pagination;
use App\Modules\Members\Services\NationalIdParser;
use App\Modules\Members\Services\MembershipValidationService;
use App\Modules\SportsActivity\Services\RegistrationWizardService;
use App\Modules\Carnets\Services\QRCodeGenerator;
use App\Modules\Settings\Services\BrandingService;
......@@ -37,8 +38,6 @@ class RegistrationWizardController extends Controller
public function lookupPlayer(Request $request): Response
{
$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', ''));
$fullNameEn = trim((string) $request->post('full_name_en', ''));
$phone = trim((string) $request->post('phone', ''));
......@@ -52,30 +51,20 @@ class RegistrationWizardController extends Controller
$nidParsed = NationalIdParser::parse($nationalId);
}
if ($playerType === 'member' && $memberId > 0) {
$db = App::getInstance()->db();
$member = $db->selectOne(
"SELECT full_name_ar, full_name_en, national_id, date_of_birth, gender, phone
FROM members WHERE membership_number = ? AND is_archived = 0",
[$memberId]
);
if (!$member) {
$member = $db->selectOne(
"SELECT full_name_ar, full_name_en, national_id, date_of_birth, gender, phone
FROM members WHERE id = ? AND is_archived = 0",
[$memberId]
);
}
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' => 'العضو غير موجود']);
$playerType = 'non_member';
$memberId = 0;
$membershipReason = null;
if ($nationalId !== '' && strlen($nationalId) === 14) {
$membership = MembershipValidationService::checkByNationalId($nationalId);
$playerType = $membership['effective_type'];
$membershipReason = $membership['reason'];
if ($membership['found'] && $membership['member']) {
$memberId = (int) $membership['member_id'];
$m = $membership['member'];
$fullNameAr = $fullNameAr ?: ($m['full_name_ar'] ?? '');
$fullNameEn = $fullNameEn ?: ($m['full_name_en'] ?? '');
$phone = $phone ?: ($m['phone'] ?? '');
}
}
......@@ -103,8 +92,10 @@ class RegistrationWizardController extends Controller
}
return $this->json(array_merge($result, [
'nid_parsed' => $nidParsed,
'redirect' => '/sa/registration/' . $result['registration_id'],
'nid_parsed' => $nidParsed,
'effective_type' => $playerType,
'membership_reason' => $membershipReason,
'redirect' => '/sa/registration/' . $result['registration_id'],
]));
}
......
......@@ -103,6 +103,13 @@ return [
['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'],
// 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
['GET', '/sa/bookings', 'SportsActivity\Controllers\BookingController@index', ['auth'], 'sa.booking.view'],
['GET', '/sa/bookings/create', 'SportsActivity\Controllers\BookingController@create', ['auth'], 'sa.booking.create'],
......@@ -171,7 +178,7 @@ return [
// JSON APIs (AJAX)
['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/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/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'],
......
......@@ -174,6 +174,17 @@ final class BookingService
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);
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>معالج الحجز بالساعة<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/bookings" class="btn btn-outline"><i data-lucide="list" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> قائمة الحجوزات</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div id="bookingWizard">
<!-- Progress Bar -->
<div class="card" style="margin-bottom:16px;padding:16px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:4px;">
<?php
$steps = ['الشخص', 'المرفق', 'الموعد', 'التأكيد'];
foreach ($steps as $i => $label):
$num = $i + 1;
?>
<div style="display:flex;align-items:center;flex:1;<?= $i < count($steps) - 1 ? '' : 'flex:0;' ?>">
<div id="stepCircle<?= $num ?>" style="width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;background:#E5E7EB;color:#6B7280;flex-shrink:0;"><?= $num ?></div>
<div style="font-size:11px;margin-right:4px;margin-left:4px;color:#6B7280;white-space:nowrap;"><?= $label ?></div>
<?php if ($i < count($steps) - 1): ?>
<div style="flex:1;height:2px;background:#E5E7EB;margin:0 4px;"></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- Step 1: Identity -->
<div id="step1" class="card" style="margin-bottom:16px;">
<div style="padding:16px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:16px;font-weight:600;"><i data-lucide="user" style="width:18px;height:18px;vertical-align:middle;margin-left:6px;"></i> بيانات الحاجز</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr;gap:14px;">
<div>
<label class="form-label">الرقم القومي</label>
<input type="text" id="bkNid" 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 رقم (اختياري للزوار)">
<div id="nidResult" style="display:none;margin-top:10px;padding:12px;border-radius:8px;font-size:13px;"></div>
</div>
<div>
<label class="form-label">الاسم <span style="color:#DC2626;">*</span></label>
<input type="text" id="bkName" class="form-input" style="padding:14px;font-size:15px;border-radius:10px;" placeholder="اسم الحاجز">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<label class="form-label">النوع</label>
<div id="bkGender" style="padding:14px;font-size:14px;color:#6B7280;"></div>
</div>
<div>
<label class="form-label">السن</label>
<div id="bkAge" style="padding:14px;font-size:14px;color:#6B7280;"></div>
</div>
</div>
</div>
<button type="button" id="btnStep1Next" class="btn btn-primary" style="width:100%;margin-top:20px;padding:16px;font-size:16px;font-weight:700;border-radius:12px;min-height:56px;" disabled>
<i data-lucide="arrow-left" style="width:18px;height:18px;vertical-align:middle;margin-left:6px;"></i> التالي — اختيار المرفق
</button>
<div id="step1Error" style="display:none;margin-top:12px;padding:12px;background:#FEF2F2;border-radius:8px;color:#DC2626;font-size:13px;"></div>
</div>
</div>
<!-- Step 2: Facility -->
<div id="step2" class="card" style="margin-bottom:16px;display:none;">
<div style="padding:16px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:16px;font-weight:600;"><i data-lucide="map-pin" style="width:18px;height:18px;vertical-align:middle;margin-left:6px;"></i> اختيار المرفق</h3>
</div>
<div style="padding:20px;">
<div id="facilitiesList" style="display:grid;grid-template-columns:1fr;gap:12px;">
<div style="text-align:center;padding:40px;color:#9CA3AF;">جاري التحميل...</div>
</div>
<button type="button" id="btnStep2Back" class="btn btn-outline" style="width:100%;margin-top:16px;padding:14px;font-size:14px;border-radius:10px;min-height:50px;">
<i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> رجوع
</button>
</div>
</div>
<!-- Step 3: Date & Time -->
<div id="step3" class="card" style="margin-bottom:16px;display:none;">
<div style="padding:16px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:16px;font-weight:600;"><i data-lucide="clock" style="width:18px;height:18px;vertical-align:middle;margin-left:6px;"></i> اختيار الموعد</h3>
<div id="selectedUnitLabel" style="font-size:12px;color:#6B7280;margin-top:4px;"></div>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;">
<div>
<label class="form-label">التاريخ</label>
<input type="date" id="bkDate" class="form-input" style="padding:12px;font-size:14px;border-radius:10px;direction:ltr;text-align:left;" value="<?= date('Y-m-d') ?>">
</div>
<div>
<label class="form-label">عدد المشاركين</label>
<input type="number" id="bkParticipants" class="form-input" style="padding:12px;font-size:14px;border-radius:10px;direction:ltr;text-align:left;" value="1" min="1">
</div>
</div>
<div id="slotsGrid" style="display:grid;grid-template-columns:repeat(auto-fill, minmax(100px, 1fr));gap:8px;margin-bottom:16px;">
<div style="text-align:center;padding:20px;color:#9CA3AF;grid-column:1/-1;">اختر التاريخ لعرض المواعيد</div>
</div>
<div id="selectedSlotInfo" style="display:none;padding:12px;background:#EFF6FF;border-radius:8px;margin-bottom:16px;">
<div style="font-size:13px;font-weight:600;color:#1D4ED8;">الموعد المختار:</div>
<div id="slotSummary" style="font-size:14px;margin-top:4px;"></div>
<div id="pricePreview" style="font-size:18px;font-weight:800;color:#059669;margin-top:8px;"></div>
</div>
<button type="button" id="btnStep3Next" class="btn btn-primary" style="width:100%;padding:16px;font-size:16px;font-weight:700;border-radius:12px;min-height:56px;" disabled>
<i data-lucide="arrow-left" style="width:18px;height:18px;vertical-align:middle;margin-left:6px;"></i> التالي — تأكيد الحجز
</button>
<button type="button" id="btnStep3Back" class="btn btn-outline" style="width:100%;margin-top:10px;padding:14px;font-size:14px;border-radius:10px;min-height:50px;">
<i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> رجوع
</button>
</div>
</div>
<!-- Step 4: Confirmation -->
<div id="step4" class="card" style="margin-bottom:16px;display:none;">
<div style="padding:16px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:16px;font-weight:600;"><i data-lucide="check-circle" style="width:18px;height:18px;vertical-align:middle;margin-left:6px;"></i> تأكيد الحجز</h3>
</div>
<div style="padding:20px;">
<div id="confirmSummary" style="margin-bottom:20px;"></div>
<button type="button" id="btnConfirmBook" class="btn btn-primary" style="width:100%;padding:16px;font-size:16px;font-weight:700;border-radius:12px;min-height:56px;">
<i data-lucide="banknote" style="width:20px;height:20px;vertical-align:middle;margin-left:6px;"></i> تأكيد وإرسال للخزينة
</button>
<button type="button" id="btnStep4Back" class="btn btn-outline" style="width:100%;margin-top:10px;padding:14px;font-size:14px;border-radius:10px;min-height:50px;">
<i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> رجوع
</button>
<div id="step4Error" style="display:none;margin-top:12px;padding:12px;background:#FEF2F2;border-radius:8px;color:#DC2626;font-size:13px;"></div>
</div>
</div>
<!-- Success State -->
<div id="stepSuccess" class="card" style="margin-bottom:16px;display:none;">
<div style="padding:40px 20px;text-align:center;">
<div style="width:64px;height:64px;border-radius:50%;background:#ECFDF5;display:inline-flex;align-items:center;justify-content:center;margin-bottom:16px;">
<i data-lucide="check" style="width:32px;height:32px;color:#059669;"></i>
</div>
<h3 style="margin:0 0 8px;font-size:18px;font-weight:700;color:#059669;">تم إنشاء الحجز بنجاح</h3>
<div id="successBookingNumber" style="font-size:14px;color:#6B7280;margin-bottom:8px;"></div>
<div style="padding:12px;background:#FEF3C7;border-radius:8px;margin:16px auto;max-width:400px;">
<i data-lucide="clock" style="width:16px;height:16px;vertical-align:middle;color:#D97706;margin-left:4px;"></i>
<span style="color:#92400E;font-size:13px;">بانتظار التحصيل من الخزينة</span>
</div>
<button type="button" id="btnNewBooking" class="btn btn-primary" style="padding:14px 32px;font-size:14px;border-radius:10px;min-height:50px;">
<i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> حجز جديد
</button>
</div>
</div>
</div>
<!-- Recent Bookings -->
<?php if (!empty($recentBookings)): ?>
<div class="card" style="margin-top:16px;">
<div style="padding:12px 20px;border-bottom:1px solid #E5E7EB;">
<h4 style="margin:0;font-size:14px;font-weight:600;color:#374151;">آخر الحجوزات</h4>
</div>
<div style="padding:0;">
<?php foreach ($recentBookings as $bk): ?>
<a href="/sa/bookings/<?= (int) $bk['id'] ?>" style="display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-bottom:1px solid #F3F4F6;text-decoration:none;color:inherit;min-height:56px;touch-action:manipulation;">
<div>
<div style="font-weight:600;font-size:14px;color:#1A1A2E;"><?= e($bk['booker_name']) ?></div>
<div style="font-size:12px;color:#6B7280;margin-top:2px;">
<?= e($bk['facility_name'] ?? '') ?><?= e($bk['unit_name'] ?? '') ?>
· <?= e($bk['booking_date']) ?> (<?= e(substr($bk['start_time'], 0, 5)) ?><?= e(substr($bk['end_time'], 0, 5)) ?>)
</div>
</div>
<div style="text-align:left;flex-shrink:0;margin-right:12px;">
<div style="font-size:13px;font-weight:700;color:#2563EB;"><?= number_format((float) $bk['total_amount'], 0) ?> ج.م</div>
<?php
$statusColors = ['confirmed' => '#2563EB', 'checked_in' => '#059669', 'completed' => '#6B7280', 'cancelled' => '#DC2626', 'pending' => '#D97706'];
$statusLabels = ['confirmed' => 'مؤكد', 'checked_in' => 'حاضر', 'completed' => 'مكتمل', 'cancelled' => 'ملغي', 'pending' => 'معلق'];
$sc = $statusColors[$bk['status']] ?? '#6B7280';
$sl = $statusLabels[$bk['status']] ?? $bk['status'];
$pc = $bk['payment_status'] === 'paid' ? '#059669' : ($bk['payment_status'] === 'pending' ? '#D97706' : '#DC2626');
$pl = $bk['payment_status'] === 'paid' ? 'مدفوع' : ($bk['payment_status'] === 'pending' ? 'بانتظار الدفع' : 'غير مدفوع');
?>
<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:<?= $sc ?>20;color:<?= $sc ?>;"><?= $sl ?></span>
<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:<?= $pc ?>20;color:<?= $pc ?>;"><?= $pl ?></span>
</div>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<script>
(function() {
var csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '<?= e($_SESSION['_csrf_token'] ?? '') ?>';
var state = {
nationalId: '',
name: '',
isMember: false,
memberId: null,
effectiveType: 'non_member',
unitId: null,
unitName: '',
facilityName: '',
date: document.getElementById('bkDate').value,
startTime: '',
endTime: '',
participants: 1,
totalAmount: 0,
pricing: null
};
function setStep(n) {
for (var i = 1; i <= 4; i++) {
var el = document.getElementById('step' + i);
if (el) el.style.display = (i === n) ? '' : 'none';
}
document.getElementById('stepSuccess').style.display = 'none';
for (var j = 1; j <= 4; j++) {
var c = document.getElementById('stepCircle' + j);
if (j < n) { c.style.background = '#059669'; c.style.color = '#fff'; }
else if (j === n) { c.style.background = '#2563EB'; c.style.color = '#fff'; }
else { c.style.background = '#E5E7EB'; c.style.color = '#6B7280'; }
}
}
function showSuccess() {
for (var i = 1; i <= 4; i++) { document.getElementById('step' + i).style.display = 'none'; }
document.getElementById('stepSuccess').style.display = '';
for (var j = 1; j <= 4; j++) {
var c = document.getElementById('stepCircle' + j);
c.style.background = '#059669'; c.style.color = '#fff';
}
}
// Step 1: NID input
var nidInput = document.getElementById('bkNid');
var nameInput = document.getElementById('bkName');
var btnStep1 = document.getElementById('btnStep1Next');
function checkStep1Valid() {
btnStep1.disabled = (nameInput.value.trim() === '');
}
nameInput.addEventListener('input', checkStep1Valid);
nidInput.addEventListener('input', function() {
var val = this.value.replace(/\D/g, '');
this.value = val;
if (val.length === 14) {
fetch('/sa/booking-wizard/lookup', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrfToken},
body: JSON.stringify({national_id: val, _csrf_token: csrfToken})
}).then(function(r) { return r.json(); }).then(function(data) {
var box = document.getElementById('nidResult');
if (data.success) {
state.nationalId = val;
state.effectiveType = data.effective_type;
state.isMember = data.is_member;
state.memberId = data.member_id;
if (data.name && !nameInput.value.trim()) {
nameInput.value = data.name;
}
document.getElementById('bkGender').textContent = data.gender === 'male' ? 'ذكر' : (data.gender === 'female' ? 'أنثى' : '—');
document.getElementById('bkAge').textContent = data.age ? data.age + ' سنة' : '—';
if (data.is_member) {
box.style.background = '#ECFDF5';
box.style.color = '#059669';
box.innerHTML = '<strong>عضو فعال</strong>' + (data.membership_number ? ' — رقم ' + data.membership_number : '');
} else if (data.reason) {
box.style.background = '#FEF3C7';
box.style.color = '#92400E';
box.innerHTML = '<strong>غير عضو</strong> — ' + data.reason;
} else {
box.style.background = '#F3F4F6';
box.style.color = '#374151';
box.innerHTML = 'غير عضو';
}
box.style.display = '';
checkStep1Valid();
} else {
box.style.background = '#FEF2F2';
box.style.color = '#DC2626';
box.textContent = data.error || 'خطأ';
box.style.display = '';
}
});
} else {
document.getElementById('nidResult').style.display = 'none';
}
});
btnStep1.addEventListener('click', function() {
state.name = nameInput.value.trim();
if (!state.name) return;
setStep(2);
loadFacilities();
});
// Step 2: Facilities
function loadFacilities() {
fetch('/sa/booking-wizard/units', {headers: {'X-Requested-With': 'XMLHttpRequest'}})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var container = document.getElementById('facilitiesList');
var html = '';
data.facilities.forEach(function(fac) {
html += '<div style="margin-bottom:8px;"><div style="font-size:13px;font-weight:600;color:#374151;margin-bottom:6px;padding:4px 0;border-bottom:1px solid #E5E7EB;">' + fac.name + '</div>';
fac.units.forEach(function(u) {
var icon = u.booking_mode === 'exclusive' ? 'lock' : 'users';
html += '<div class="unit-card" data-unit-id="' + u.id + '" data-unit-name="' + u.name + '" data-facility-name="' + fac.name + '" style="padding:14px;border:2px solid #E5E7EB;border-radius:10px;margin-bottom:8px;cursor:pointer;touch-action:manipulation;min-height:56px;display:flex;align-items:center;justify-content:space-between;transition:border-color 0.15s;">';
html += '<div><div style="font-weight:600;font-size:14px;">' + u.name + '</div>';
html += '<div style="font-size:11px;color:#6B7280;">' + (u.booking_mode === 'exclusive' ? 'حجز كامل' : 'مشترك — سعة ' + u.max_capacity) + '</div></div>';
html += '<i data-lucide="' + icon + '" style="width:20px;height:20px;color:#9CA3AF;"></i>';
html += '</div>';
});
html += '</div>';
});
container.innerHTML = html;
if (window.lucide) lucide.createIcons();
container.querySelectorAll('.unit-card').forEach(function(card) {
card.addEventListener('click', function() {
container.querySelectorAll('.unit-card').forEach(function(c) { c.style.borderColor = '#E5E7EB'; c.style.background = ''; });
this.style.borderColor = '#2563EB';
this.style.background = '#EFF6FF';
state.unitId = parseInt(this.dataset.unitId);
state.unitName = this.dataset.unitName;
state.facilityName = this.dataset.facilityName;
setTimeout(function() {
setStep(3);
document.getElementById('selectedUnitLabel').textContent = state.facilityName + ' — ' + state.unitName;
loadSlots();
}, 200);
});
});
});
}
document.getElementById('btnStep2Back').addEventListener('click', function() { setStep(1); });
// Step 3: Time slots
var dateInput = document.getElementById('bkDate');
var participantsInput = document.getElementById('bkParticipants');
dateInput.addEventListener('change', function() {
state.date = this.value;
loadSlots();
});
participantsInput.addEventListener('change', function() {
state.participants = parseInt(this.value) || 1;
if (state.startTime) updatePricePreview();
});
function loadSlots() {
if (!state.unitId || !state.date) return;
var grid = document.getElementById('slotsGrid');
grid.innerHTML = '<div style="text-align:center;padding:20px;color:#9CA3AF;grid-column:1/-1;">جاري التحميل...</div>';
fetch('/sa/booking-wizard/slots?unit_id=' + state.unitId + '&date=' + state.date, {headers: {'X-Requested-With': 'XMLHttpRequest'}})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { grid.innerHTML = '<div style="color:#DC2626;padding:20px;grid-column:1/-1;">' + (data.error || 'خطأ') + '</div>'; return; }
var html = '';
data.slots.forEach(function(s) {
var bg, color, cursor, clickable;
if (s.status === 'free') { bg = '#ECFDF5'; color = '#059669'; cursor = 'pointer'; clickable = true; }
else if (s.status === 'partial') { bg = '#FEF3C7'; color = '#D97706'; cursor = 'pointer'; clickable = true; }
else if (s.status === 'occupied') { bg = '#FEF2F2'; color = '#DC2626'; cursor = 'not-allowed'; clickable = false; }
else { bg = '#F3F4F6'; color = '#9CA3AF'; cursor = 'not-allowed'; clickable = false; }
html += '<div class="slot-btn" data-start="' + s.start_time + '" data-end="' + s.end_time + '" data-status="' + s.status + '" data-remaining="' + s.remaining_capacity + '" style="padding:10px 6px;border-radius:8px;text-align:center;background:' + bg + ';color:' + color + ';cursor:' + cursor + ';border:2px solid transparent;touch-action:manipulation;font-size:13px;font-weight:600;' + (!clickable ? 'opacity:0.7;' : '') + '">';
html += s.start_time;
if (s.status === 'partial') html += '<div style="font-size:10px;font-weight:400;">متبقي ' + s.remaining_capacity + '</div>';
if (s.status === 'occupied' && s.booker) html += '<div style="font-size:9px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + s.booker + '</div>';
html += '</div>';
});
grid.innerHTML = html;
grid.querySelectorAll('.slot-btn').forEach(function(btn) {
if (btn.dataset.status === 'occupied' || btn.dataset.status === 'blackout') return;
btn.addEventListener('click', function() {
grid.querySelectorAll('.slot-btn').forEach(function(b) { b.style.borderColor = 'transparent'; });
this.style.borderColor = '#2563EB';
state.startTime = this.dataset.start;
state.endTime = this.dataset.end;
document.getElementById('selectedSlotInfo').style.display = '';
document.getElementById('slotSummary').textContent = state.date + ' — ' + state.startTime + ' إلى ' + state.endTime;
document.getElementById('btnStep3Next').disabled = false;
updatePricePreview();
});
});
});
document.getElementById('selectedSlotInfo').style.display = 'none';
document.getElementById('btnStep3Next').disabled = true;
}
function updatePricePreview() {
var url = '/api/sa/bookings/price-preview?unit_id=' + state.unitId + '&date=' + state.date + '&start_time=' + state.startTime + '&end_time=' + state.endTime + '&participants=' + state.participants + '&is_member=' + (state.isMember ? '1' : '0');
fetch(url, {headers: {'X-Requested-With': 'XMLHttpRequest'}})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.pricing && data.pricing.total_amount !== undefined) {
state.totalAmount = data.pricing.total_amount;
state.pricing = data.pricing;
document.getElementById('pricePreview').textContent = Number(data.pricing.total_amount).toLocaleString() + ' ج.م';
} else {
document.getElementById('pricePreview').textContent = '—';
}
}).catch(function() {
document.getElementById('pricePreview').textContent = '—';
});
}
document.getElementById('btnStep3Next').addEventListener('click', function() {
state.participants = parseInt(participantsInput.value) || 1;
setStep(4);
renderConfirmation();
});
document.getElementById('btnStep3Back').addEventListener('click', function() { setStep(2); });
// Step 4: Confirmation
function renderConfirmation() {
var memberBadge = state.isMember
? '<span style="padding:2px 8px;border-radius:4px;background:#ECFDF5;color:#059669;font-size:11px;font-weight:600;">عضو</span>'
: '<span style="padding:2px 8px;border-radius:4px;background:#FEF3C7;color:#D97706;font-size:11px;font-weight:600;">غير عضو</span>';
var html = '<div style="border:1px solid #E5E7EB;border-radius:10px;overflow:hidden;">';
html += '<div style="padding:12px 16px;background:#F9FAFB;border-bottom:1px solid #E5E7EB;font-weight:600;font-size:14px;">ملخص الحجز</div>';
html += '<table style="width:100%;font-size:13px;">';
html += '<tr><td style="padding:10px 16px;font-weight:600;color:#6B7280;width:35%;">الحاجز</td><td style="padding:10px 16px;">' + state.name + ' ' + memberBadge + '</td></tr>';
html += '<tr style="background:#F9FAFB;"><td style="padding:10px 16px;font-weight:600;color:#6B7280;">المرفق</td><td style="padding:10px 16px;">' + state.facilityName + ' — ' + state.unitName + '</td></tr>';
html += '<tr><td style="padding:10px 16px;font-weight:600;color:#6B7280;">التاريخ</td><td style="padding:10px 16px;">' + state.date + '</td></tr>';
html += '<tr style="background:#F9FAFB;"><td style="padding:10px 16px;font-weight:600;color:#6B7280;">الوقت</td><td style="padding:10px 16px;">' + state.startTime + ' — ' + state.endTime + '</td></tr>';
html += '<tr><td style="padding:10px 16px;font-weight:600;color:#6B7280;">المشاركين</td><td style="padding:10px 16px;">' + state.participants + '</td></tr>';
if (state.pricing) {
html += '<tr style="background:#F9FAFB;"><td style="padding:10px 16px;font-weight:600;color:#6B7280;">السعر للفرد/ساعة</td><td style="padding:10px 16px;">' + Number(state.pricing.price_per_person).toLocaleString() + ' ج.م</td></tr>';
html += '<tr><td style="padding:10px 16px;font-weight:600;color:#6B7280;">الفترة</td><td style="padding:10px 16px;">' + (state.pricing.bracket_name || '—') + '</td></tr>';
}
html += '<tr style="border-top:2px solid #E5E7EB;"><td style="padding:14px 16px;font-weight:800;font-size:16px;">الإجمالي</td><td style="padding:14px 16px;font-weight:800;font-size:18px;color:#2563EB;">' + Number(state.totalAmount).toLocaleString() + ' ج.م</td></tr>';
html += '</table></div>';
document.getElementById('confirmSummary').innerHTML = html;
}
document.getElementById('btnStep4Back').addEventListener('click', function() { setStep(3); });
document.getElementById('btnConfirmBook').addEventListener('click', function() {
var btn = this;
btn.disabled = true;
btn.innerHTML = '<span>جاري الحجز...</span>';
document.getElementById('step4Error').style.display = 'none';
fetch('/sa/booking-wizard/book', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-TOKEN': csrfToken},
body: JSON.stringify({
unit_id: state.unitId,
date: state.date,
start_time: state.startTime,
end_time: state.endTime,
participants: state.participants,
booker_name: state.name,
national_id: state.nationalId,
is_member: state.isMember,
member_id: state.memberId || 0,
_csrf_token: csrfToken
})
}).then(function(r) { return r.json(); }).then(function(data) {
if (data.success) {
document.getElementById('successBookingNumber').textContent = 'رقم الحجز: ' + data.booking_number;
showSuccess();
} else {
document.getElementById('step4Error').textContent = data.error || 'حدث خطأ';
document.getElementById('step4Error').style.display = '';
btn.disabled = false;
btn.innerHTML = '<i data-lucide="banknote" style="width:20px;height:20px;vertical-align:middle;margin-left:6px;"></i> تأكيد وإرسال للخزينة';
if (window.lucide) lucide.createIcons();
}
}).catch(function() {
document.getElementById('step4Error').textContent = 'خطأ في الاتصال';
document.getElementById('step4Error').style.display = '';
btn.disabled = false;
btn.innerHTML = '<i data-lucide="banknote" style="width:20px;height:20px;vertical-align:middle;margin-left:6px;"></i> تأكيد وإرسال للخزينة';
if (window.lucide) lucide.createIcons();
});
});
document.getElementById('btnNewBooking').addEventListener('click', function() {
state = {nationalId:'', name:'', isMember:false, memberId:null, effectiveType:'non_member', unitId:null, unitName:'', facilityName:'', date:document.getElementById('bkDate').value, startTime:'', endTime:'', participants:1, totalAmount:0, pricing:null};
nidInput.value = '';
nameInput.value = '';
document.getElementById('nidResult').style.display = 'none';
document.getElementById('bkGender').textContent = '—';
document.getElementById('bkAge').textContent = '—';
btnStep1.disabled = true;
setStep(1);
});
setStep(1);
})();
</script>
<?php $__template->endSection(); ?>
......@@ -280,23 +280,10 @@
<div style="padding:20px;">
<form id="startForm">
<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>
<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">
<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>
<label class="form-label">الاسم بالعربي <span style="color:#DC2626;">*</span></label>
......@@ -372,35 +359,44 @@ document.addEventListener('DOMContentLoaded', function() {
// Start form
var startForm = document.getElementById('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 regNidInfo = document.getElementById('regNidInfo');
var memberBadge = document.getElementById('regMemberBadge');
if (regNid) {
regNid.addEventListener('input', function() {
this.value = this.value.replace(/\D/g, '');
if (this.value.length === 14) {
fetch('/api/members/parse-nid', {
fetch('/sa/booking-wizard/lookup', {
method: 'POST',
headers: {'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},
body: JSON.stringify({national_id: this.value})
headers: {'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest','X-CSRF-TOKEN': csrfToken},
body: JSON.stringify({national_id: this.value, _csrf_token: csrfToken})
}).then(function(r){return r.json();}).then(function(data) {
if (data.parsed && data.parsed.is_valid) {
regNidInfo.style.display = 'block';
regNidInfo.style.color = '#059669';
regNidInfo.textContent = '✓ ' + data.parsed.governorate_name_ar + ' — ' + data.parsed.age_years + ' سنة — ' + (data.parsed.gender === 'male' ? 'ذكر' : 'أنثى');
} else {
regNidInfo.style.display = 'block';
regNidInfo.style.color = '#DC2626';
regNidInfo.textContent = 'رقم قومي غير صالح';
if (data.success) {
if (data.is_member) {
memberBadge.style.background = '#ECFDF5';
memberBadge.style.color = '#059669';
memberBadge.innerHTML = '<strong>عضو فعال</strong>' + (data.membership_number ? ' — رقم ' + data.membership_number : '');
} else if (data.reason) {
memberBadge.style.background = '#FEF3C7';
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 {
regNidInfo.style.display = 'none';
memberBadge.style.display = 'none';
}
});
}
......
......@@ -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' => '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' => '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' => '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],
......@@ -64,6 +65,7 @@ PermissionRegistry::register('sports_activity', [
'sa.group.enroll' => ['ar' => 'تسجيل لاعب في مجموعة', 'en' => 'Enroll Player in Group'],
'sa.schedule.view' => ['ar' => 'عرض الجدول', 'en' => 'View 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.create' => ['ar' => 'إنشاء حجز', 'en' => 'Create Booking'],
'sa.booking.manage' => ['ar' => 'إدارة الحجوزات', 'en' => 'Manage Bookings'],
......
......@@ -177,7 +177,7 @@ final class TreasuryService
}
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') {
$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
// ── SA REGISTRATION ──
'sa-registration.registration-wizard' => [
['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' => 'بعد سداد الاستمارة: التقط صورة عبر <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>يظهر تلقائياً: رسوم الكارت + الاستمارة + الاشتراك مع أي خصومات مطبقة.'],
......@@ -1288,6 +1288,13 @@ final class TutorialRegistry
['title' => 'الدخول خلال المدة', 'body' => 'اللاعب يستخدم الكارت المؤقت للدخول من البوابة بشكل طبيعي خلال فترة الصلاحية.'],
['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' => [
['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>'],
......@@ -3375,13 +3382,21 @@ final class TutorialRegistry
'category' => 'cards',
'order' => 5,
],
'booking-wizard' => [
'title' => 'معالج الحجز بالساعة',
'subtitle' => 'حجز ملاعب ومرافق بالساعة — اكتشاف العضوية التلقائي',
'icon' => 'calendar-clock',
'color' => '#0D7377',
'category' => 'booking',
'order' => 6,
],
'academy-pricing' => [
'title' => 'أسعار الأكاديميات',
'subtitle' => 'عرض وحساب أسعار الاشتراكات والخصومات',
'icon' => 'calculator',
'color' => '#D97706',
'category' => 'registration',
'order' => 6,
'order' => 7,
],
];
}
......@@ -3392,6 +3407,7 @@ final class TutorialRegistry
'registration' => ['label' => 'التسجيل', 'icon' => 'clipboard-check', 'color' => '#0891B2'],
'cards' => ['label' => 'الكروت', 'icon' => 'id-card', 'color' => '#2563EB'],
'gate' => ['label' => 'البوابة', 'icon' => 'scan', 'color' => '#059669'],
'booking' => ['label' => 'الحجز', 'icon' => 'calendar-clock', 'color' => '#0D7377'],
'reports' => ['label' => 'التقارير', 'icon' => 'bar-chart-3', 'color' => '#7C3AED'],
];
}
......
......@@ -124,7 +124,7 @@ class UserPermissionController extends Controller
$allRolesMap = [];
foreach (Role::allActive() as $r) {
$allRolesMap[(int) $r->id] = $r->role_code;
$allRolesMap[(int) $r['id']] = $r['role_code'];
}
$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