Commit d14f0c10 authored by Mahmoud Aglan's avatar Mahmoud Aglan

multiple fixes

parent f834d38b
...@@ -20,15 +20,16 @@ final class DivorceFeeCalculator ...@@ -20,15 +20,16 @@ final class DivorceFeeCalculator
public static function getAnnualSubscription(): string public static function getAnnualSubscription(): string
{ {
$month = (int) date('n'); $db = App::getInstance()->db();
$year = (int) date('Y'); $childRate = $db->selectOne(
$fy = $month >= 7 ? $year . '/' . ($year + 1) : ($year - 1) . '/' . $year; "SELECT base_amount FROM service_catalog WHERE service_code LIKE 'SVC_ANNUAL_CHILD%' AND is_active = 1 ORDER BY effective_from DESC LIMIT 1"
);
$devFeeData = RuleEngine::get('DEVELOPMENT_FEE');
$ratesData = RuleEngine::get('membership.annual_rates.' . $fy); $rate = $childRate ? $childRate['base_amount'] : '222.00';
$childRate = $ratesData['child'] ?? '222'; $dev = $devFeeData['amount'] ?? '35.00';
$devFee = $ratesData['dev'] ?? '35';
return bcadd($childRate, $devFee, 2); return bcadd($rate, $dev, 2);
} }
/** /**
......
...@@ -211,9 +211,15 @@ $statusLabel = $statusLabels[$case['status']] ?? $case['status']; ...@@ -211,9 +211,15 @@ $statusLabel = $statusLabels[$case['status']] ?? $case['status'];
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var percInput = document.querySelector('input[name="fee_percentage"]'); var percInput = document.querySelector('input[name="fee_percentage"]');
if (!percInput) return; if (!percInput) return;
<?php
$__formFeeData = \App\Modules\Rules\Services\RuleEngine::get('FORM_TRANSFER_FEE');
$__devFeeData = \App\Modules\Rules\Services\RuleEngine::get('DEVELOPMENT_FEE');
$__db = \App\Core\App::getInstance()->db();
$__childRate = $__db->selectOne("SELECT base_amount FROM service_catalog WHERE service_code LIKE 'SVC_ANNUAL_CHILD%' AND is_active = 1 ORDER BY effective_from DESC LIMIT 1");
?>
var membershipValue = <?= json_encode((float) ($case['membership_value'] ?? 0)) ?>; var membershipValue = <?= json_encode((float) ($case['membership_value'] ?? 0)) ?>;
var formFee = 570; var formFee = <?= json_encode((float) ($__formFeeData['amount'] ?? '570')) ?>;
var annualSub = 257; var annualSub = <?= json_encode((float) ($__childRate['base_amount'] ?? '222') + (float) ($__devFeeData['amount'] ?? '35')) ?>;
percInput.addEventListener('input', function() { percInput.addEventListener('input', function() {
var perc = parseFloat(this.value) || 0; var perc = parseFloat(this.value) || 0;
......
...@@ -68,15 +68,16 @@ final class FormFeeService ...@@ -68,15 +68,16 @@ final class FormFeeService
public static function getAnnualSubscriptionForAddition(): string public static function getAnnualSubscriptionForAddition(): string
{ {
$month = (int) date('n'); $db = App::getInstance()->db();
$year = (int) date('Y'); $childRate = $db->selectOne(
$fy = $month >= 7 ? $year . '/' . ($year + 1) : ($year - 1) . '/' . $year; "SELECT base_amount FROM service_catalog WHERE service_code LIKE 'SVC_ANNUAL_CHILD%' AND is_active = 1 ORDER BY effective_from DESC LIMIT 1"
);
$devFeeData = RuleEngine::get('DEVELOPMENT_FEE');
$ratesData = RuleEngine::get('membership.annual_rates.' . $fy); $rate = $childRate ? $childRate['base_amount'] : '222.00';
$childRate = $ratesData['child'] ?? '222'; $dev = $devFeeData['amount'] ?? '35.00';
$devFee = $ratesData['dev'] ?? '35';
return bcadd($childRate, $devFee, 2); return bcadd($rate, $dev, 2);
} }
/** /**
......
...@@ -121,10 +121,54 @@ final class PaymentLifecycleService ...@@ -121,10 +121,54 @@ final class PaymentLifecycleService
$paymentType = $data['payment_type'] ?? ''; $paymentType = $data['payment_type'] ?? '';
$entityType = $data['related_entity_type'] ?? null; $entityType = $data['related_entity_type'] ?? null;
$entityId = (int) ($data['related_entity_id'] ?? 0); $entityId = (int) ($data['related_entity_id'] ?? 0);
$memberId = (int) ($data['member_id'] ?? 0);
if ($paymentType === 'addition_fee' && $entityType && $entityId > 0) {
self::archiveCancelledDependent($entityType, $entityId, $memberId);
return;
}
self::revertLifeEventOnVoid($paymentType, $entityType, $entityId); self::revertLifeEventOnVoid($paymentType, $entityType, $entityId);
} }
/**
* Archive a dependent whose addition fee was cancelled.
* The member addition is considered withdrawn — archive the record.
*/
private static function archiveCancelledDependent(string $entityType, int $entityId, int $memberId): void
{
$validTables = ['spouses', 'children', 'temporary_members'];
if (!in_array($entityType, $validTables, true)) {
return;
}
$db = App::getInstance()->db();
$entity = $db->selectOne(
"SELECT id, status FROM `{$entityType}` WHERE id = ? AND member_id = ? AND is_archived = 0",
[$entityId, $memberId]
);
if (!$entity) {
return;
}
// Only archive if still pending — if active via another payment, leave it
if (!in_array($entity['status'], ['pending_payment', 'inactive'], true)) {
return;
}
$employee = App::getInstance()->currentEmployee();
$db->update($entityType, [
'is_archived' => 1,
'status' => 'inactive',
'archived_at' => date('Y-m-d H:i:s'),
'archived_by' => $employee ? (int) $employee->id : null,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$entityId]);
Logger::info("PaymentLifecycleService: archived {$entityType} #{$entityId} after addition_fee cancelled for member #{$memberId}");
}
/** /**
* Revert life-event entity status when its fee payment is voided/cancelled. * Revert life-event entity status when its fee payment is voided/cancelled.
*/ */
......
<?php
declare(strict_types=1);
namespace App\Modules\Pricing\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Pricing\Services\RuleFieldMapper;
use App\Modules\Rules\Services\RuleEngine;
class PricingDashboardController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$categories = RuleFieldMapper::getCategoryConfig();
$grouped = [];
$today = date('Y-m-d');
foreach ($categories as $cat => $meta) {
$rules = $db->select(
"SELECT * FROM business_rules WHERE category = ? AND is_active = 1 AND branch_id IS NULL AND effective_from <= ? AND (effective_to IS NULL OR effective_to >= ?) ORDER BY rule_code",
[$cat, $today, $today]
);
if (!empty($rules)) {
$grouped[$cat] = [
'meta' => $meta,
'rules' => $rules,
];
}
}
$annualRates = $this->getAnnualRates($db);
$membershipPrices = $this->getMembershipPrices($db);
return $this->view('Pricing.Views.dashboard', [
'grouped' => $grouped,
'annualRates' => $annualRates,
'membershipPrices' => $membershipPrices,
]);
}
public function saveSection(Request $request, string $category): Response
{
$rules = $request->post('rule', []);
$rawRules = $request->post('rule_raw', []);
$reason = trim((string) $request->post('change_reason', ''));
if ($reason === '') {
$reason = 'تحديث عبر لوحة التسعير';
}
$updated = 0;
foreach ($rules as $ruleCode => $fields) {
if (!is_array($fields)) {
continue;
}
$currentData = RuleEngine::get($ruleCode);
if ($currentData === null) {
continue;
}
$newFields = [];
foreach ($fields as $key => $val) {
if (is_numeric($val) && strpos($val, '.') !== false) {
$newFields[$key] = $val;
} elseif (is_numeric($val)) {
$newFields[$key] = (int) $val;
} elseif ($val === '0' || $val === '1') {
$newFields[$key] = (int) $val;
} else {
$newFields[$key] = $val;
}
}
$currentJson = json_encode($currentData, JSON_UNESCAPED_UNICODE);
$newJson = json_encode($newFields, JSON_UNESCAPED_UNICODE);
if ($currentJson !== $newJson) {
RuleEngine::update($ruleCode, $newJson, $reason);
$updated++;
}
}
foreach ($rawRules as $ruleCode => $rawJson) {
$decoded = json_decode($rawJson, true);
if ($decoded === null) {
continue;
}
$currentData = RuleEngine::get($ruleCode);
$currentJson = json_encode($currentData, JSON_UNESCAPED_UNICODE);
$newJson = json_encode($decoded, JSON_UNESCAPED_UNICODE);
if ($currentJson !== $newJson) {
RuleEngine::update($ruleCode, $newJson, $reason);
$updated++;
}
}
if ($request->isAjax()) {
return $this->json(['success' => true, 'updated' => $updated]);
}
return $this->redirect('/pricing')->withSuccess("تم تحديث {$updated} قاعدة بنجاح");
}
public function saveAnnualRates(Request $request): Response
{
$db = App::getInstance()->db();
$rates = $request->post('rate', []);
$reason = trim((string) $request->post('change_reason', 'تحديث الاشتراكات السنوية'));
$employee = App::getInstance()->currentEmployee();
$updated = 0;
foreach ($rates as $id => $amount) {
$amount = trim((string) $amount);
if (!is_numeric($amount)) {
continue;
}
$existing = $db->selectOne("SELECT id, base_amount FROM service_catalog WHERE id = ?", [(int) $id]);
if ($existing && (string) $existing['base_amount'] !== $amount) {
$db->update('service_catalog', [
'base_amount' => $amount,
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [(int) $id]);
$updated++;
}
}
$devFee = $request->post('dev_fee');
if ($devFee !== null && is_numeric($devFee)) {
$currentDev = RuleEngine::get('DEVELOPMENT_FEE');
if ($currentDev && (string) ($currentDev['amount'] ?? '') !== (string) $devFee) {
RuleEngine::update('DEVELOPMENT_FEE', json_encode(['amount' => $devFee], JSON_UNESCAPED_UNICODE), $reason);
$updated++;
}
}
if ($request->isAjax()) {
return $this->json(['success' => true, 'updated' => $updated]);
}
return $this->redirect('/pricing')->withSuccess("تم تحديث {$updated} سعر بنجاح");
}
public function addYear(Request $request): Response
{
$db = App::getInstance()->db();
$year = (int) $request->post('year', 0);
if ($year < 2020 || $year > 2040) {
return $this->redirect('/pricing')->withError('سنة غير صالحة');
}
$fy = "{$year}/" . ($year + 1);
$effectiveFrom = "{$year}-07-01";
$effectiveTo = ($year + 1) . "-06-30";
$exists = $db->selectOne(
"SELECT id FROM service_catalog WHERE service_code = ? LIMIT 1",
["SVC_ANNUAL_MEMBER_{$year}"]
);
if ($exists) {
return $this->redirect('/pricing')->withError("السنة المالية {$fy} موجودة بالفعل");
}
$member = (string) $request->post('member_rate', '492');
$spouse = (string) $request->post('spouse_rate', '492');
$child = (string) $request->post('child_rate', '222');
$temp = (string) $request->post('temp_rate', '222');
$employee = App::getInstance()->currentEmployee();
$now = date('Y-m-d H:i:s');
$entries = [
["SVC_ANNUAL_MEMBER_{$year}", "اشتراك سنوي - عضو {$year}", $member],
["SVC_ANNUAL_SPOUSE_{$year}", "اشتراك سنوي - زوج/ة {$year}", $spouse],
["SVC_ANNUAL_CHILD_{$year}", "اشتراك سنوي - ابن/بنت {$year}", $child],
["SVC_ANNUAL_TEMP_{$year}", "اشتراك سنوي - مؤقت {$year}", $temp],
];
foreach ($entries as [$code, $name, $amount]) {
$db->insert('service_catalog', [
'service_code' => $code,
'name_ar' => $name,
'price_type' => 'fixed',
'base_amount' => $amount,
'currency' => 'EGP',
'effective_from'=> $effectiveFrom,
'effective_to' => $effectiveTo,
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
'created_by' => $employee ? (int) $employee->id : null,
]);
}
return $this->redirect('/pricing')->withSuccess("تم إضافة أسعار السنة المالية {$fy} بنجاح");
}
public function saveMembershipPrices(Request $request): Response
{
$db = App::getInstance()->db();
$prices = $request->post('price', []);
$reason = trim((string) $request->post('change_reason', 'تحديث أسعار العضويات'));
$employee = App::getInstance()->currentEmployee();
$updated = 0;
foreach ($prices as $id => $amount) {
$amount = trim((string) $amount);
if (!is_numeric($amount)) {
continue;
}
$existing = $db->selectOne("SELECT id, base_amount FROM service_catalog WHERE id = ?", [(int) $id]);
if ($existing && (string) $existing['base_amount'] !== $amount) {
$db->update('service_catalog', [
'base_amount' => $amount,
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [(int) $id]);
$updated++;
}
}
if ($request->isAjax()) {
return $this->json(['success' => true, 'updated' => $updated]);
}
return $this->redirect('/pricing')->withSuccess("تم تحديث {$updated} سعر عضوية بنجاح");
}
public function ruleVersions(Request $request, string $ruleCode): Response
{
$versions = RuleEngine::getVersionHistory($ruleCode);
return $this->json(['success' => true, 'versions' => $versions]);
}
public function addRuleVersion(Request $request, string $ruleCode): Response
{
$valueJson = $request->post('value_json', '');
$effectiveFrom = $request->post('effective_from', '');
$effectiveTo = $request->post('effective_to', '');
$reason = trim((string) $request->post('change_reason', 'إضافة نسخة تاريخية'));
if (!$effectiveFrom || !$effectiveTo) {
return $this->json(['success' => false, 'error' => 'يجب تحديد تاريخ البداية والنهاية'], 422);
}
if ($effectiveTo < $effectiveFrom) {
return $this->json(['success' => false, 'error' => 'تاريخ النهاية يجب أن يكون بعد تاريخ البداية'], 422);
}
$decoded = json_decode($valueJson, true);
if ($decoded === null && $valueJson !== 'null') {
return $this->json(['success' => false, 'error' => 'قيمة JSON غير صالحة'], 422);
}
try {
RuleEngine::addHistoricalVersion($ruleCode, $valueJson, $effectiveFrom, $effectiveTo, $reason);
return $this->json(['success' => true]);
} catch (\RuntimeException $e) {
return $this->json(['success' => false, 'error' => $e->getMessage()], 422);
}
}
private function getAnnualRates($db): array
{
$rows = $db->select(
"SELECT * FROM service_catalog WHERE service_code LIKE 'SVC_ANNUAL_%' AND is_active = 1 ORDER BY effective_from DESC, service_code"
);
$years = [];
foreach ($rows as $row) {
$code = $row['service_code'];
if (preg_match('/SVC_ANNUAL_(MEMBER|SPOUSE|CHILD|TEMP)_(\d{4})/', $code, $m)) {
$type = strtolower($m[1]);
$year = $m[2];
$years[$year][$type] = $row;
} elseif (preg_match('/SVC_ANNUAL_(MEMBER|SPOUSE|CHILD|TEMP)$/', $code, $m)) {
$type = strtolower($m[1]);
$years['current'][$type] = $row;
}
}
krsort($years);
return $years;
}
private function getMembershipPrices($db): array
{
return $db->select(
"SELECT * FROM service_catalog WHERE service_code LIKE 'SVC_MEMBERSHIP_%' AND is_active = 1 ORDER BY effective_from DESC, service_code"
);
}
}
...@@ -2,9 +2,21 @@ ...@@ -2,9 +2,21 @@
declare(strict_types=1); declare(strict_types=1);
return [ return [
['GET', '/pricing', 'Pricing\Controllers\PricingController@index', ['auth'], 'pricing.view'], // Pricing Dashboard
['GET', '/pricing/{id:\d+}/edit', 'Pricing\Controllers\PricingController@edit', ['auth'], 'pricing.edit'], ['GET', '/pricing', 'Pricing\Controllers\PricingDashboardController@index', ['auth'], 'pricing.view'],
['POST', '/pricing/{id:\d+}', 'Pricing\Controllers\PricingController@update', ['auth', 'csrf'], 'pricing.edit'], ['POST', '/pricing/section/{category}', 'Pricing\Controllers\PricingDashboardController@saveSection', ['auth', 'csrf'], 'pricing.edit'],
['POST', '/pricing/annual-rates', 'Pricing\Controllers\PricingDashboardController@saveAnnualRates', ['auth', 'csrf'], 'pricing.edit'],
['POST', '/pricing/annual-rates/add-year', 'Pricing\Controllers\PricingDashboardController@addYear', ['auth', 'csrf'], 'pricing.edit'],
['POST', '/pricing/membership-prices', 'Pricing\Controllers\PricingDashboardController@saveMembershipPrices', ['auth', 'csrf'], 'pricing.edit'],
// Rule versioning
['GET', '/pricing/rule/{ruleCode}/versions', 'Pricing\Controllers\PricingDashboardController@ruleVersions', ['auth'], 'pricing.view'],
['POST', '/pricing/rule/{ruleCode}/add-version', 'Pricing\Controllers\PricingDashboardController@addRuleVersion', ['auth', 'csrf'], 'pricing.edit'],
// Legacy pricing configs
['GET', '/pricing/configs', 'Pricing\Controllers\PricingController@index', ['auth'], 'pricing.view'],
['GET', '/pricing/configs/{id:\d+}/edit', 'Pricing\Controllers\PricingController@edit', ['auth'], 'pricing.edit'],
['POST', '/pricing/configs/{id:\d+}', 'Pricing\Controllers\PricingController@update', ['auth', 'csrf'], 'pricing.edit'],
// Special Discounts // Special Discounts
['GET', '/pricing/special-discounts', 'Pricing\Controllers\SpecialDiscountController@index', ['auth'], 'pricing.special_discounts.view'], ['GET', '/pricing/special-discounts', 'Pricing\Controllers\SpecialDiscountController@index', ['auth'], 'pricing.special_discounts.view'],
......
<?php
declare(strict_types=1);
namespace App\Modules\Pricing\Services;
final class RuleFieldMapper
{
private static array $keyConfig = [
'percentage' => ['label' => 'النسبة', 'suffix' => '%', 'type' => 'number', 'step' => '0.01'],
'percentage_of_subscription' => ['label' => 'نسبة من الاشتراك', 'suffix' => '%', 'type' => 'number', 'step' => '0.01'],
'max_percentage' => ['label' => 'أقصى نسبة', 'suffix' => '%', 'type' => 'number', 'step' => '0.01'],
'discount_percentage' => ['label' => 'نسبة الخصم', 'suffix' => '%', 'type' => 'number', 'step' => '0.01'],
'increase_percentage' => ['label' => 'نسبة الزيادة', 'suffix' => '%', 'type' => 'number', 'step' => '0.01'],
'amount' => ['label' => 'المبلغ', 'suffix' => 'ج.م', 'type' => 'number', 'step' => '0.01'],
'amount_usd' => ['label' => 'المبلغ', 'suffix' => 'USD', 'type' => 'number', 'step' => '0.01'],
'annual_flat' => ['label' => 'الرسم السنوي الثابت', 'suffix' => 'ج.م', 'type' => 'number', 'step' => '0.01'],
'total' => ['label' => 'الإجمالي', 'suffix' => 'ج.م', 'type' => 'number', 'step' => '0.01'],
'form' => ['label' => 'رسم الاستمارة', 'suffix' => 'ج.م', 'type' => 'number', 'step' => '0.01'],
'stamp' => ['label' => 'طابع الشهداء', 'suffix' => 'ج.م', 'type' => 'number', 'step' => '0.01'],
'fee_usd' => ['label' => 'الرسم', 'suffix' => 'USD', 'type' => 'number', 'step' => '0.01'],
'value' => ['label' => 'القيمة', 'suffix' => '', 'type' => 'number', 'step' => '1'],
'days' => ['label' => 'الأيام', 'suffix' => 'يوم', 'type' => 'number', 'step' => '1'],
'months' => ['label' => 'الشهور', 'suffix' => 'شهر', 'type' => 'number', 'step' => '1'],
'years' => ['label' => 'السنوات', 'suffix' => 'سنة', 'type' => 'number', 'step' => '1'],
'max_years' => ['label' => 'أقصى سنوات', 'suffix' => 'سنة', 'type' => 'number', 'step' => '1'],
'min_years' => ['label' => 'أقل سنوات', 'suffix' => 'سنة', 'type' => 'number', 'step' => '1'],
'min' => ['label' => 'الحد الأدنى', 'suffix' => '', 'type' => 'number', 'step' => '1'],
'max' => ['label' => 'الحد الأقصى', 'suffix' => '', 'type' => 'number', 'step' => '1'],
'hour' => ['label' => 'الساعة', 'suffix' => '', 'type' => 'number', 'step' => '1'],
'day' => ['label' => 'اليوم', 'suffix' => '', 'type' => 'number', 'step' => '1'],
'month' => ['label' => 'الشهر', 'suffix' => '', 'type' => 'number', 'step' => '1'],
'hours' => ['label' => 'الساعات', 'suffix' => 'ساعة', 'type' => 'number', 'step' => '1'],
'minutes' => ['label' => 'الدقائق', 'suffix' => 'دقيقة', 'type' => 'number', 'step' => '1'],
'units' => ['label' => 'الوحدات', 'suffix' => '', 'type' => 'number', 'step' => '1'],
'until_age' => ['label' => 'حتى سن', 'suffix' => 'سنة', 'type' => 'number', 'step' => '1'],
'spouse' => ['label' => 'الأزواج', 'suffix' => '', 'type' => 'number', 'step' => '1'],
'children' => ['label' => 'الأبناء', 'suffix' => '', 'type' => 'number', 'step' => '1'],
'exempt' => ['label' => 'معفى', 'suffix' => '', 'type' => 'toggle'],
'renewable' => ['label' => 'قابل للتجديد', 'suffix' => '', 'type' => 'toggle'],
'same_number' => ['label' => 'نفس الرقم', 'suffix' => '', 'type' => 'toggle'],
'waived_if_children' => ['label' => 'يُعفى بوجود أبناء', 'suffix' => '', 'type' => 'toggle'],
'drop_after' => ['label' => 'إسقاط بعدها', 'suffix' => '', 'type' => 'toggle'],
'base' => ['label' => 'الأساس', 'suffix' => '', 'type' => 'text'],
'type' => ['label' => 'النوع', 'suffix' => '', 'type' => 'text'],
'treat_as' => ['label' => 'يُعامل كـ', 'suffix' => '', 'type' => 'text'],
'fee_type' => ['label' => 'نوع الرسم', 'suffix' => '', 'type' => 'text'],
'transfer_to' => ['label' => 'ينتقل إلى', 'suffix' => '', 'type' => 'text'],
'currency' => ['label' => 'العملة', 'suffix' => '', 'type' => 'text'],
'note' => ['label' => 'ملاحظة', 'suffix' => '', 'type' => 'text'],
'format' => ['label' => 'الصيغة', 'suffix' => '', 'type' => 'text'],
'date' => ['label' => 'التاريخ', 'suffix' => '', 'type' => 'date'],
'start_seq' => ['label' => 'رقم البداية', 'suffix' => '', 'type' => 'number', 'step' => '1'],
'base_date_rule' => ['label' => 'قاعدة تاريخ الأساس', 'suffix' => '', 'type' => 'text'],
'partial_year_rule' => ['label' => 'قاعدة جزء السنة', 'suffix' => '', 'type' => 'text'],
];
public static function getFieldConfig(string $key): array
{
return self::$keyConfig[$key] ?? ['label' => $key, 'suffix' => '', 'type' => 'text', 'step' => ''];
}
public static function renderFields(array $rule): string
{
$ruleCode = $rule['rule_code'];
$values = json_decode($rule['current_value_json'], true) ?? [];
$dataType = $rule['data_type'] ?? 'string';
if (empty($values) || !is_array($values)) {
return self::renderRawField($ruleCode, $rule['current_value_json']);
}
$html = '';
foreach ($values as $key => $value) {
$config = self::getFieldConfig($key);
$inputName = "rule[{$ruleCode}][{$key}]";
if ($config['type'] === 'toggle') {
$html .= self::renderToggle($inputName, $key, $config, $value);
} elseif ($config['type'] === 'number') {
$html .= self::renderNumber($inputName, $key, $config, $value);
} elseif ($config['type'] === 'date') {
$html .= self::renderDate($inputName, $key, $config, $value);
} else {
$html .= self::renderText($inputName, $key, $config, $value);
}
}
return $html;
}
private static function renderNumber(string $name, string $key, array $config, mixed $value): string
{
$label = e($config['label']);
$suffix = e($config['suffix']);
$step = $config['step'] ?? '1';
$val = e((string) $value);
$suffixHtml = $suffix ? "<span class=\"pricing-input-suffix\">{$suffix}</span>" : '';
return <<<HTML
<div class="pricing-input-group">
<label class="pricing-input-label">{$label}</label>
<div class="pricing-input-wrap">
<input type="number" name="{$name}" value="{$val}" step="{$step}" class="form-input pricing-input" dir="ltr" data-key="{$key}">
{$suffixHtml}
</div>
</div>
HTML;
}
private static function renderToggle(string $name, string $key, array $config, mixed $value): string
{
$label = e($config['label']);
$checked = ($value === true || $value === 1 || $value === '1' || $value === 'true') ? 'checked' : '';
return <<<HTML
<div class="pricing-input-group">
<label class="pricing-input-label">{$label}</label>
<div class="pricing-input-wrap">
<input type="hidden" name="{$name}" value="0">
<label class="toggle-switch">
<input type="checkbox" name="{$name}" value="1" {$checked} data-key="{$key}">
<span class="toggle-slider"></span>
</label>
</div>
</div>
HTML;
}
private static function renderText(string $name, string $key, array $config, mixed $value): string
{
$label = e($config['label']);
$val = e((string) $value);
return <<<HTML
<div class="pricing-input-group">
<label class="pricing-input-label">{$label}</label>
<div class="pricing-input-wrap">
<input type="text" name="{$name}" value="{$val}" class="form-input pricing-input" data-key="{$key}">
</div>
</div>
HTML;
}
private static function renderDate(string $name, string $key, array $config, mixed $value): string
{
$label = e($config['label']);
$val = e((string) $value);
return <<<HTML
<div class="pricing-input-group">
<label class="pricing-input-label">{$label}</label>
<div class="pricing-input-wrap">
<input type="date" name="{$name}" value="{$val}" class="form-input pricing-input" dir="ltr" data-key="{$key}">
</div>
</div>
HTML;
}
private static function renderRawField(string $ruleCode, string $json): string
{
$val = e($json);
return <<<HTML
<div class="pricing-input-group" style="grid-column:1/-1;">
<label class="pricing-input-label">القيمة (JSON)</label>
<div class="pricing-input-wrap">
<input type="text" name="rule_raw[{$ruleCode}]" value="{$val}" class="form-input pricing-input" dir="ltr" style="font-family:monospace;">
</div>
</div>
HTML;
}
public static function getCategoryConfig(): array
{
return [
'financial' => [
'label' => 'الرسوم المالية',
'icon' => '💰',
'color' => '#0D7377',
'description' => 'رسوم الاستمارات، التنمية، الأقساط، البدل',
],
'spouse_fee' => [
'label' => 'رسوم الأزواج',
'icon' => '💍',
'color' => '#7C3AED',
'description' => 'نسب ورسوم إضافة الزوجات',
],
'children_fee' => [
'label' => 'رسوم الأبناء',
'icon' => '👶',
'color' => '#2563EB',
'description' => 'نسب ورسوم إضافة الأبناء حسب السن',
],
'separation_fee' => [
'label' => 'رسوم الفصل والتنازل',
'icon' => '📋',
'color' => '#DC2626',
'description' => 'نسب الفصل حسب السنة، رسوم التنازل، التحويل الرياضي',
],
'divorce' => [
'label' => 'رسوم الطلاق',
'icon' => '⚖️',
'color' => '#9333EA',
'description' => 'نسب ورسوم حالات الطلاق',
],
'penalty' => [
'label' => 'الغرامات والجزاءات',
'icon' => '⚠️',
'color' => '#D97706',
'description' => 'غرامات التأخير، حدود المخالفات، الإسقاط',
],
'discount' => [
'label' => 'الخصومات',
'icon' => '🏷️',
'color' => '#059669',
'description' => 'خصومات المجموعات والفروع',
],
'foreign' => [
'label' => 'الأعضاء الأجانب',
'icon' => '🌍',
'color' => '#6366F1',
'description' => 'رسوم العضوية الأجنبية والفخرية والرياضية',
],
'temporary' => [
'label' => 'العضوية المؤقتة والموسمية',
'icon' => '⏳',
'color' => '#0284C7',
'description' => 'رسوم وشروط العضوية المؤقتة والموسمية',
],
'membership' => [
'label' => 'رسوم العضوية',
'icon' => '🏠',
'color' => '#475569',
'description' => 'رسوم المؤقتين والمربيات',
],
'age' => [
'label' => 'حدود السن',
'icon' => '📅',
'color' => '#64748B',
'description' => 'الأعمار المطلوبة والحدود القصوى',
],
'workflow' => [
'label' => 'إعدادات سير العمل',
'icon' => '⚙️',
'color' => '#374151',
'description' => 'مواعيد التحصيل، صلاحيات الاستمارات، التجميد',
],
];
}
}
<?php
/** @var array $grouped */
/** @var array $annualRates */
/** @var array $membershipPrices */
use App\Modules\Pricing\Services\RuleFieldMapper;
$__template->layout('Layout.main');
$__template->section('title'); ?>لوحة التسعير<?php $__template->endSection(); ?>
<?php $__template->section('styles'); ?>
<style>
.pricing-dashboard { display:flex; flex-direction:column; gap:20px; }
.pricing-section {
background:#fff;
border-radius:12px;
border:1px solid #E5E7EB;
overflow:hidden;
transition:box-shadow .2s;
}
.pricing-section:hover { box-shadow:0 4px 12px rgba(0,0,0,.06); }
.pricing-section-header {
padding:18px 24px;
display:flex;
align-items:center;
gap:14px;
cursor:pointer;
user-select:none;
border-bottom:1px solid transparent;
transition:border-color .2s;
}
.pricing-section.open .pricing-section-header { border-bottom-color:#E5E7EB; }
.pricing-section-icon {
width:42px; height:42px;
border-radius:10px;
display:flex; align-items:center; justify-content:center;
font-size:20px;
flex-shrink:0;
}
.pricing-section-title {
flex:1;
}
.pricing-section-title h3 { margin:0; font-size:16px; font-weight:700; color:#1F2937; }
.pricing-section-title p { margin:2px 0 0; font-size:13px; color:#6B7280; }
.pricing-section-badge {
background:#F3F4F6; border-radius:20px; padding:4px 12px;
font-size:12px; color:#6B7280; font-weight:600;
}
.pricing-section-badge.dirty { background:#FEF3C7; color:#D97706; }
.pricing-section-chevron {
width:20px; height:20px; transition:transform .2s;
color:#9CA3AF;
}
.pricing-section.open .pricing-section-chevron { transform:rotate(180deg); }
.pricing-section-body {
display:none;
padding:24px;
}
.pricing-section.open .pricing-section-body { display:block; }
.pricing-fields-grid {
display:grid;
grid-template-columns:repeat(auto-fill, minmax(260px, 1fr));
gap:18px;
}
.pricing-rule-card {
background:#F9FAFB;
border:1px solid #E5E7EB;
border-radius:10px;
padding:14px 16px;
}
.pricing-rule-card-title {
font-size:13px; font-weight:600; color:#374151;
margin-bottom:10px; padding-bottom:8px;
border-bottom:1px solid #E5E7EB;
}
.pricing-input-group {
display:flex;
flex-direction:column;
gap:4px;
margin-bottom:10px;
}
.pricing-input-group:last-child { margin-bottom:0; }
.pricing-input-label {
font-size:12px; color:#6B7280; font-weight:500;
}
.pricing-input-wrap {
display:flex; align-items:center; gap:6px;
}
.pricing-input {
flex:1;
padding:8px 12px;
border:1px solid #D1D5DB;
border-radius:8px;
font-size:14px;
font-weight:600;
background:#fff;
transition:border-color .15s, box-shadow .15s;
min-width:0;
}
.pricing-input:focus {
outline:none;
border-color:#0D7377;
box-shadow:0 0 0 3px rgba(13,115,119,.1);
}
.pricing-input.dirty {
border-color:#D97706;
background:#FFFBEB;
}
.pricing-input-suffix {
background:#F3F4F6;
border:1px solid #E5E7EB;
padding:8px 10px;
border-radius:8px;
font-size:12px;
color:#6B7280;
font-weight:600;
white-space:nowrap;
}
.pricing-section-footer {
padding:16px 24px;
border-top:1px solid #E5E7EB;
display:flex;
align-items:center;
gap:12px;
background:#F9FAFB;
}
.pricing-section-footer textarea {
flex:1;
padding:8px 12px;
border:1px solid #D1D5DB;
border-radius:8px;
font-size:13px;
resize:none;
height:36px;
}
.toggle-switch { position:relative; display:inline-block; width:44px; height:24px; }
.toggle-switch input { opacity:0; width:0; height:0; }
.toggle-slider {
position:absolute; cursor:pointer; inset:0;
background:#D1D5DB; border-radius:24px; transition:.2s;
}
.toggle-slider:before {
content:""; position:absolute; height:18px; width:18px;
right:3px; bottom:3px; background:#fff; border-radius:50%; transition:.2s;
}
.toggle-switch input:checked + .toggle-slider { background:#0D7377; }
.toggle-switch input:checked + .toggle-slider:before { transform:translateX(-20px); }
/* Annual rates */
.annual-tabs { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; }
.annual-tab {
padding:8px 16px; border-radius:8px; font-size:13px; font-weight:600;
border:1px solid #E5E7EB; background:#fff; cursor:pointer; transition:.15s;
}
.annual-tab.active { background:#0D7377; color:#fff; border-color:#0D7377; }
.annual-tab:hover:not(.active) { background:#F3F4F6; }
.annual-panel { display:none; }
.annual-panel.active { display:grid; grid-template-columns:repeat(auto-fill, minmax(200px, 1fr)); gap:14px; }
.membership-prices-grid {
display:grid;
grid-template-columns:repeat(auto-fill, minmax(240px, 1fr));
gap:14px;
}
.membership-price-card {
background:#F9FAFB; border:1px solid #E5E7EB; border-radius:10px; padding:14px 16px;
}
.membership-price-card h4 { margin:0 0 8px; font-size:13px; color:#374151; font-weight:600; }
.btn-save-section {
padding:8px 20px; border-radius:8px; font-size:13px; font-weight:600;
background:#0D7377; color:#fff; border:none; cursor:pointer;
transition:background .15s;
}
.btn-save-section:hover { background:#0A5F62; }
.btn-save-section:disabled { background:#D1D5DB; cursor:not-allowed; }
.add-year-form {
display:flex; gap:10px; align-items:end; flex-wrap:wrap;
margin-top:16px; padding-top:16px; border-top:1px solid #E5E7EB;
}
.add-year-form .form-group { display:flex; flex-direction:column; gap:4px; }
.add-year-form label { font-size:12px; color:#6B7280; font-weight:500; }
.add-year-form input { padding:8px 12px; border:1px solid #D1D5DB; border-radius:8px; font-size:14px; width:100px; }
.toast-msg {
position:fixed; top:20px; left:50%; transform:translateX(-50%);
padding:12px 24px; border-radius:10px; font-size:14px; font-weight:600;
z-index:9999; animation:slideDown .3s ease;
box-shadow:0 4px 20px rgba(0,0,0,.15);
}
.toast-msg.success { background:#059669; color:#fff; }
.toast-msg.error { background:#DC2626; color:#fff; }
@keyframes slideDown { from { opacity:0; transform:translateX(-50%) translateY(-10px); } to { opacity:1; transform:translateX(-50%) translateY(0); } }
/* Versioning UI */
.btn-versions {
background:none; border:1px solid #D1D5DB; border-radius:6px;
padding:3px 8px; font-size:11px; color:#6B7280; cursor:pointer;
transition:.15s; display:inline-flex; align-items:center; gap:4px;
margin-top:8px;
}
.btn-versions:hover { background:#F3F4F6; color:#374151; border-color:#9CA3AF; }
.btn-versions svg { width:12px; height:12px; }
.version-modal-overlay {
position:fixed; inset:0; background:rgba(0,0,0,.5); z-index:10000;
display:flex; align-items:center; justify-content:center;
animation:fadeIn .2s;
}
@keyframes fadeIn { from{opacity:0} to{opacity:1} }
.version-modal {
background:#fff; border-radius:14px; width:90%; max-width:640px;
max-height:80vh; overflow:hidden; display:flex; flex-direction:column;
box-shadow:0 20px 60px rgba(0,0,0,.2);
}
.version-modal-header {
padding:18px 24px; border-bottom:1px solid #E5E7EB;
display:flex; align-items:center; gap:12px;
}
.version-modal-header h3 { flex:1; margin:0; font-size:16px; color:#1F2937; }
.version-modal-close { background:none; border:none; cursor:pointer; font-size:20px; color:#9CA3AF; padding:4px; }
.version-modal-close:hover { color:#1F2937; }
.version-modal-body { padding:20px 24px; overflow-y:auto; flex:1; }
.version-timeline { display:flex; flex-direction:column; gap:12px; }
.version-item {
display:flex; gap:12px; padding:12px 14px;
background:#F9FAFB; border:1px solid #E5E7EB; border-radius:10px;
}
.version-item.current { border-color:#0D7377; background:rgba(13,115,119,.04); }
.version-dot {
width:10px; height:10px; border-radius:50%; margin-top:4px; flex-shrink:0;
background:#D1D5DB;
}
.version-item.current .version-dot { background:#0D7377; }
.version-info { flex:1; min-width:0; }
.version-dates { font-size:12px; color:#6B7280; margin-bottom:4px; }
.version-value { font-size:13px; color:#374151; font-weight:500; direction:ltr; text-align:right; word-break:break-all; }
.version-badge { font-size:10px; background:#0D7377; color:#fff; padding:2px 8px; border-radius:4px; font-weight:600; }
.version-add-form {
margin-top:16px; padding-top:16px; border-top:1px solid #E5E7EB;
}
.version-add-form h4 { font-size:14px; color:#374151; margin:0 0 12px; font-weight:600; }
.version-form-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:12px; }
.version-form-grid .form-group { display:flex; flex-direction:column; gap:4px; }
.version-form-grid label { font-size:12px; color:#6B7280; font-weight:500; }
.version-form-grid input, .version-form-grid textarea {
padding:8px 12px; border:1px solid #D1D5DB; border-radius:8px; font-size:13px;
}
.version-form-grid textarea { grid-column:1/-1; resize:none; height:60px; }
.btn-add-version {
padding:8px 16px; border-radius:8px; font-size:13px; font-weight:600;
background:#2563EB; color:#fff; border:none; cursor:pointer;
}
.btn-add-version:hover { background:#1D4ED8; }
</style>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="pricing-dashboard">
<!-- Annual Rates Section -->
<div class="pricing-section open">
<div class="pricing-section-header" onclick="toggleSection(this)">
<div class="pricing-section-icon" style="background:rgba(5,150,105,.1);">📊</div>
<div class="pricing-section-title">
<h3>الاشتراكات السنوية</h3>
<p>أسعار الاشتراك السنوي لكل فئة حسب السنة المالية</p>
</div>
<span class="pricing-section-badge"><?= count($annualRates) ?> سنة</span>
<svg class="pricing-section-chevron" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"/></svg>
</div>
<div class="pricing-section-body">
<form method="POST" action="/pricing/annual-rates" class="section-form" data-section="annual-rates">
<?= csrf_field() ?>
<div class="annual-tabs">
<?php $first = true; foreach ($annualRates as $yearKey => $types): ?>
<?php $label = $yearKey === 'current' ? 'الحالي' : $yearKey . '/' . ((int)$yearKey + 1); ?>
<div class="annual-tab <?= $first ? 'active' : '' ?>" onclick="switchTab(this, '<?= e($yearKey) ?>')"><?= $label ?></div>
<?php $first = false; endforeach; ?>
</div>
<?php $first = true; foreach ($annualRates as $yearKey => $types): ?>
<div class="annual-panel <?= $first ? 'active' : '' ?>" data-year="<?= e($yearKey) ?>">
<?php
$typeLabels = ['member' => 'العضو', 'spouse' => 'الزوج/ة', 'child' => 'الابن/البنت', 'temp' => 'المؤقت'];
foreach ($typeLabels as $typeKey => $typeLabel):
$row = $types[$typeKey] ?? null;
if (!$row) continue;
?>
<div class="pricing-rule-card">
<div class="pricing-rule-card-title"><?= $typeLabel ?></div>
<div class="pricing-input-group">
<label class="pricing-input-label">الاشتراك السنوي</label>
<div class="pricing-input-wrap">
<input type="number" name="rate[<?= (int)$row['id'] ?>]" value="<?= e($row['base_amount']) ?>" step="0.01" class="pricing-input" dir="ltr">
<span class="pricing-input-suffix">ج.م</span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php $first = false; endforeach; ?>
<?php
$devFeeData = \App\Modules\Rules\Services\RuleEngine::get('DEVELOPMENT_FEE');
$devFee = $devFeeData['amount'] ?? '35.00';
?>
<div style="margin-top:14px;">
<div class="pricing-rule-card" style="max-width:260px;">
<div class="pricing-rule-card-title">رسم التنمية (يُضاف لكل اشتراك)</div>
<div class="pricing-input-group">
<div class="pricing-input-wrap">
<input type="number" name="dev_fee" value="<?= e($devFee) ?>" step="0.01" class="pricing-input" dir="ltr">
<span class="pricing-input-suffix">ج.م</span>
</div>
</div>
</div>
</div>
<div class="pricing-section-footer" style="margin-top:16px;padding:16px 0;border-top:1px solid #E5E7EB;background:transparent;">
<textarea name="change_reason" placeholder="سبب التعديل..." style="flex:1;"></textarea>
<button type="submit" class="btn-save-section">حفظ الاشتراكات</button>
</div>
</form>
<form method="POST" action="/pricing/annual-rates/add-year" class="add-year-form">
<?= csrf_field() ?>
<div class="form-group">
<label>سنة مالية جديدة (تبدأ يوليو)</label>
<input type="number" name="year" placeholder="<?= date('Y') ?>" min="2020" max="2040" required>
</div>
<div class="form-group">
<label>عضو</label>
<input type="number" name="member_rate" value="492" step="0.01">
</div>
<div class="form-group">
<label>زوج/ة</label>
<input type="number" name="spouse_rate" value="492" step="0.01">
</div>
<div class="form-group">
<label>ابن</label>
<input type="number" name="child_rate" value="222" step="0.01">
</div>
<div class="form-group">
<label>مؤقت</label>
<input type="number" name="temp_rate" value="222" step="0.01">
</div>
<button type="submit" class="btn-save-section" style="background:#2563EB;">+ إضافة سنة</button>
</form>
</div>
</div>
<!-- Membership Prices Section -->
<?php if (!empty($membershipPrices)): ?>
<div class="pricing-section">
<div class="pricing-section-header" onclick="toggleSection(this)">
<div class="pricing-section-icon" style="background:rgba(99,102,241,.1);">🏠</div>
<div class="pricing-section-title">
<h3>أسعار العضويات</h3>
<p>قيمة العضوية حسب المؤهل والفترة</p>
</div>
<span class="pricing-section-badge"><?= count($membershipPrices) ?></span>
<svg class="pricing-section-chevron" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"/></svg>
</div>
<div class="pricing-section-body">
<form method="POST" action="/pricing/membership-prices" class="section-form" data-section="membership-prices">
<?= csrf_field() ?>
<div class="membership-prices-grid">
<?php foreach ($membershipPrices as $mp): ?>
<div class="membership-price-card">
<h4><?= e($mp['name_ar']) ?></h4>
<div class="pricing-input-group">
<label class="pricing-input-label">من <?= e($mp['effective_from']) ?><?= $mp['effective_to'] ? ' إلى ' . e($mp['effective_to']) : ' (ساري)' ?></label>
<div class="pricing-input-wrap">
<input type="number" name="price[<?= (int)$mp['id'] ?>]" value="<?= e($mp['base_amount']) ?>" step="0.01" class="pricing-input" dir="ltr">
<span class="pricing-input-suffix">ج.م</span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="pricing-section-footer" style="margin-top:16px;padding:16px 0;border-top:1px solid #E5E7EB;background:transparent;">
<textarea name="change_reason" placeholder="سبب التعديل..." style="flex:1;"></textarea>
<button type="submit" class="btn-save-section">حفظ أسعار العضويات</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
<!-- Business Rules Sections -->
<?php foreach ($grouped as $category => $data): ?>
<div class="pricing-section">
<div class="pricing-section-header" onclick="toggleSection(this)">
<div class="pricing-section-icon" style="background:<?= e($data['meta']['color']) ?>15;"><?= $data['meta']['icon'] ?></div>
<div class="pricing-section-title">
<h3><?= e($data['meta']['label']) ?></h3>
<p><?= e($data['meta']['description']) ?></p>
</div>
<span class="pricing-section-badge"><?= count($data['rules']) ?></span>
<svg class="pricing-section-chevron" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"/></svg>
</div>
<div class="pricing-section-body">
<form method="POST" action="/pricing/section/<?= e($category) ?>" class="section-form" data-section="<?= e($category) ?>">
<?= csrf_field() ?>
<div class="pricing-fields-grid">
<?php foreach ($data['rules'] as $rule): ?>
<div class="pricing-rule-card">
<div class="pricing-rule-card-title"><?= e($rule['name_ar'] ?: $rule['rule_code']) ?></div>
<?= RuleFieldMapper::renderFields($rule) ?>
<button type="button" class="btn-versions" onclick="openVersions('<?= e($rule['rule_code']) ?>', '<?= e($rule['name_ar'] ?: $rule['rule_code']) ?>')">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"/></svg>
النسخ التاريخية
</button>
</div>
<?php endforeach; ?>
</div>
<div class="pricing-section-footer">
<textarea name="change_reason" placeholder="سبب التعديل..."></textarea>
<button type="submit" class="btn-save-section">حفظ <?= e($data['meta']['label']) ?></button>
</div>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
<?php $__template->endSection(); ?>
<?php $__template->section('scripts'); ?>
<script>
function toggleSection(header) {
header.closest('.pricing-section').classList.toggle('open');
}
function switchTab(tab, yearKey) {
var container = tab.closest('.pricing-section-body');
container.querySelectorAll('.annual-tab').forEach(function(t) { t.classList.remove('active'); });
container.querySelectorAll('.annual-panel').forEach(function(p) { p.classList.remove('active'); });
tab.classList.add('active');
container.querySelector('.annual-panel[data-year="' + yearKey + '"]').classList.add('active');
}
function showToast(message, type) {
var el = document.createElement('div');
el.className = 'toast-msg ' + type;
el.textContent = message;
document.body.appendChild(el);
setTimeout(function() { el.remove(); }, 3000);
}
document.querySelectorAll('.section-form').forEach(function(form) {
form.addEventListener('submit', function(e) {
e.preventDefault();
var btn = form.querySelector('.btn-save-section');
btn.disabled = true;
btn.textContent = 'جاري الحفظ...';
var formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
btn.disabled = false;
btn.textContent = btn.textContent.replace('جاري الحفظ...', '');
if (data.success) {
showToast('تم الحفظ بنجاح — ' + (data.updated || 0) + ' تعديل', 'success');
form.querySelectorAll('.pricing-input.dirty').forEach(function(i) { i.classList.remove('dirty'); });
btn.textContent = form.closest('.pricing-section').querySelector('h3').textContent.replace(/.*/, 'حفظ $&');
} else {
showToast(data.error || 'حدث خطأ', 'error');
}
})
.catch(function() {
btn.disabled = false;
showToast('فشل الاتصال بالخادم', 'error');
});
// Restore button text
var sectionTitle = form.closest('.pricing-section').querySelector('h3');
setTimeout(function() {
if (btn.textContent === 'جاري الحفظ...') {
btn.textContent = 'حفظ ' + (sectionTitle ? sectionTitle.textContent : '');
}
}, 100);
});
});
// Dirty state tracking
document.querySelectorAll('.pricing-input').forEach(function(input) {
var original = input.value;
input.addEventListener('input', function() {
if (this.value !== original) {
this.classList.add('dirty');
} else {
this.classList.remove('dirty');
}
});
});
// Versioning modal
function openVersions(ruleCode, ruleName) {
var overlay = document.createElement('div');
overlay.className = 'version-modal-overlay';
overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = '<div class="version-modal">' +
'<div class="version-modal-header">' +
'<h3>النسخ التاريخية — ' + ruleName + '</h3>' +
'<button class="version-modal-close" onclick="this.closest(\'.version-modal-overlay\').remove()">&times;</button>' +
'</div>' +
'<div class="version-modal-body"><div style="text-align:center;color:#9CA3AF;padding:20px;">جاري التحميل...</div></div>' +
'</div>';
document.body.appendChild(overlay);
fetch('/pricing/rule/' + ruleCode + '/versions', { headers: {'X-Requested-With':'XMLHttpRequest'} })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { showToast(data.error || 'خطأ', 'error'); overlay.remove(); return; }
var body = overlay.querySelector('.version-modal-body');
var today = new Date().toISOString().slice(0, 10);
var html = '<div class="version-timeline">';
if (data.versions.length === 0) {
html += '<div style="text-align:center;color:#9CA3AF;padding:12px;">لا توجد نسخ تاريخية</div>';
} else {
data.versions.forEach(function(v) {
var isCurrent = v.effective_from <= today && (v.effective_to === null || v.effective_to >= today);
html += '<div class="version-item ' + (isCurrent ? 'current' : '') + '">' +
'<div class="version-dot"></div>' +
'<div class="version-info">' +
'<div class="version-dates">' +
'من ' + v.effective_from + (v.effective_to ? ' إلى ' + v.effective_to : ' (ساري)') +
(isCurrent ? ' <span class="version-badge">الحالي</span>' : '') +
'</div>' +
'<div class="version-value">' + v.current_value_json + '</div>' +
'</div>' +
'</div>';
});
}
html += '</div>';
html += '<div class="version-add-form">' +
'<h4>إضافة نسخة تاريخية</h4>' +
'<div class="version-form-grid">' +
'<div class="form-group"><label>من تاريخ</label><input type="date" id="ver-from"></div>' +
'<div class="form-group"><label>إلى تاريخ</label><input type="date" id="ver-to"></div>' +
'<div class="form-group" style="grid-column:1/-1;"><label>القيمة (JSON)</label><textarea id="ver-value" dir="ltr" placeholder=\'{"amount":"500.00"}\'></textarea></div>' +
'<div class="form-group" style="grid-column:1/-1;"><label>السبب</label><input type="text" id="ver-reason" placeholder="إضافة قيمة تاريخية لاستيراد بيانات قديمة"></div>' +
'</div>' +
'<button class="btn-add-version" onclick="submitVersion(\'' + ruleCode + '\')">إضافة نسخة</button>' +
'</div>';
body.innerHTML = html;
})
.catch(function() { showToast('فشل تحميل النسخ', 'error'); overlay.remove(); });
}
function submitVersion(ruleCode) {
var from = document.getElementById('ver-from').value;
var to = document.getElementById('ver-to').value;
var value = document.getElementById('ver-value').value;
var reason = document.getElementById('ver-reason').value;
if (!from || !to || !value) { showToast('يرجى ملء جميع الحقول', 'error'); return; }
var formData = new FormData();
formData.append('effective_from', from);
formData.append('effective_to', to);
formData.append('value_json', value);
formData.append('change_reason', reason || 'إضافة نسخة تاريخية');
formData.append('_token', document.querySelector('input[name="_token"]').value);
fetch('/pricing/rule/' + ruleCode + '/add-version', {
method: 'POST', body: formData, headers: {'X-Requested-With':'XMLHttpRequest'}
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast('تم إضافة النسخة بنجاح', 'success');
document.querySelector('.version-modal-overlay').remove();
} else {
showToast(data.error || 'حدث خطأ', 'error');
}
})
.catch(function() { showToast('فشل الاتصال', 'error'); });
}
</script>
<?php $__template->endSection(); ?>
<?php <?php
declare(strict_types=1); declare(strict_types=1);
// Menu and permissions registered in Rules/bootstrap.php (shared group)
\ No newline at end of file use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
MenuRegistry::register('pricing', [
'label_ar' => 'التسعير',
'label_en' => 'Pricing',
'icon' => 'banknote',
'route' => '/pricing',
'permission' => 'pricing.view',
'parent' => null,
'order' => 850,
'children' => [
['label_ar' => 'لوحة التسعير', 'label_en' => 'Pricing Dashboard', 'route' => '/pricing', 'permission' => 'pricing.view', 'order' => 1],
['label_ar' => 'الخصومات الخاصة', 'label_en' => 'Special Discounts', 'route' => '/pricing/special-discounts', 'permission' => 'pricing.special_discounts.view', 'order' => 2],
],
]);
PermissionRegistry::register('pricing', [
'pricing.view' => ['ar' => 'عرض لوحة التسعير', 'en' => 'View Pricing Dashboard'],
'pricing.edit' => ['ar' => 'تعديل الأسعار والرسوم', 'en' => 'Edit Prices & Fees'],
'pricing.special_discounts.view' => ['ar' => 'عرض الخصومات الخاصة', 'en' => 'View Special Discounts'],
'pricing.special_discounts.create' => ['ar' => 'إنشاء خصم خاص', 'en' => 'Create Special Discount'],
'pricing.special_discounts.edit' => ['ar' => 'تعديل الخصومات الخاصة','en' => 'Edit Special Discounts'],
]);
...@@ -66,13 +66,14 @@ final class RuleEngine ...@@ -66,13 +66,14 @@ final class RuleEngine
return BusinessRule::allByCategory($category, $branchId); return BusinessRule::allByCategory($category, $branchId);
} }
public static function update(string $ruleCode, string $newValueJson, string $reason, ?int $branchId = null): void public static function update(string $ruleCode, string $newValueJson, string $reason, ?int $branchId = null, ?string $effectiveFrom = null): void
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee(); $employee = App::getInstance()->currentEmployee();
$effectiveFrom = $effectiveFrom ?? date('Y-m-d');
$where = 'rule_code = ? AND is_active = 1'; $where = 'rule_code = ? AND is_active = 1 AND effective_from <= ? AND (effective_to IS NULL OR effective_to >= ?)';
$params = [$ruleCode]; $params = [$ruleCode, $effectiveFrom, $effectiveFrom];
if ($branchId !== null) { if ($branchId !== null) {
$where .= ' AND branch_id = ?'; $where .= ' AND branch_id = ?';
$params[] = $branchId; $params[] = $branchId;
...@@ -80,14 +81,20 @@ final class RuleEngine ...@@ -80,14 +81,20 @@ final class RuleEngine
$where .= ' AND branch_id IS NULL'; $where .= ' AND branch_id IS NULL';
} }
$rule = $db->selectOne("SELECT * FROM business_rules WHERE {$where}", $params); $rule = $db->selectOne("SELECT * FROM business_rules WHERE {$where} ORDER BY effective_from DESC LIMIT 1", $params);
if (!$rule) { if (!$rule) {
$fallback = $db->selectOne(
"SELECT * FROM business_rules WHERE rule_code = ? AND is_active = 1" . ($branchId !== null ? " AND branch_id = ?" : " AND branch_id IS NULL") . " ORDER BY effective_from DESC LIMIT 1",
$branchId !== null ? [$ruleCode, $branchId] : [$ruleCode]
);
if (!$fallback) {
throw new \RuntimeException("Rule not found: {$ruleCode}"); throw new \RuntimeException("Rule not found: {$ruleCode}");
} }
$rule = $fallback;
}
$newVersion = (int) $rule['version'] + 1; $newVersion = (int) $rule['version'] + 1;
// Create version history
$db->insert('rule_versions', [ $db->insert('rule_versions', [
'rule_id' => (int) $rule['id'], 'rule_id' => (int) $rule['id'],
'version_number' => $newVersion, 'version_number' => $newVersion,
...@@ -98,18 +105,105 @@ final class RuleEngine ...@@ -98,18 +105,105 @@ final class RuleEngine
'change_reason' => $reason, 'change_reason' => $reason,
]); ]);
// Update current value if ($rule['effective_from'] === $effectiveFrom) {
$db->update('business_rules', [ $db->update('business_rules', [
'current_value_json' => $newValueJson, 'current_value_json' => $newValueJson,
'version' => $newVersion, 'version' => $newVersion,
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => $employee ? (int) $employee->id : null, 'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [$rule['id']]); ], '`id` = ?', [$rule['id']]);
} else {
$yesterday = date('Y-m-d', strtotime($effectiveFrom . ' -1 day'));
$db->update('business_rules', [
'effective_to' => $yesterday,
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [$rule['id']]);
$db->insert('business_rules', [
'rule_code' => $rule['rule_code'],
'category' => $rule['category'],
'name_ar' => $rule['name_ar'],
'name_en' => $rule['name_en'],
'description_ar' => $rule['description_ar'],
'description_en' => $rule['description_en'],
'parameters_json' => $rule['parameters_json'],
'current_value_json' => $newValueJson,
'data_type' => $rule['data_type'],
'branch_id' => $rule['branch_id'],
'effective_from' => $effectiveFrom,
'effective_to' => $rule['effective_to'],
'is_active' => 1,
'version' => $newVersion,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
'updated_by' => $employee ? (int) $employee->id : null,
]);
}
// Clear cache
self::$cache = []; self::$cache = [];
Logger::info("Rule updated: {$ruleCode}", ['version' => $newVersion, 'effective_from' => $effectiveFrom, 'reason' => $reason]);
}
Logger::info("Rule updated: {$ruleCode}", ['version' => $newVersion, 'reason' => $reason]); public static function addHistoricalVersion(string $ruleCode, string $valueJson, string $effectiveFrom, string $effectiveTo, string $reason, ?int $branchId = null): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$existing = $db->selectOne(
"SELECT * FROM business_rules WHERE rule_code = ? AND is_active = 1" . ($branchId !== null ? " AND branch_id = ?" : " AND branch_id IS NULL") . " LIMIT 1",
$branchId !== null ? [$ruleCode, $branchId] : [$ruleCode]
);
if (!$existing) {
throw new \RuntimeException("Rule not found: {$ruleCode}");
}
$overlap = $db->selectOne(
"SELECT id FROM business_rules WHERE rule_code = ? AND is_active = 1 AND effective_from <= ? AND (effective_to IS NULL OR effective_to >= ?)" . ($branchId !== null ? " AND branch_id = ?" : " AND branch_id IS NULL"),
$branchId !== null ? [$ruleCode, $effectiveTo, $effectiveFrom, $branchId] : [$ruleCode, $effectiveTo, $effectiveFrom]
);
if ($overlap) {
throw new \RuntimeException("Date range overlaps with existing version for rule: {$ruleCode}");
}
$db->insert('business_rules', [
'rule_code' => $existing['rule_code'],
'category' => $existing['category'],
'name_ar' => $existing['name_ar'],
'name_en' => $existing['name_en'],
'description_ar' => $existing['description_ar'],
'description_en' => $existing['description_en'],
'parameters_json' => $existing['parameters_json'],
'current_value_json' => $valueJson,
'data_type' => $existing['data_type'],
'branch_id' => $existing['branch_id'],
'effective_from' => $effectiveFrom,
'effective_to' => $effectiveTo,
'is_active' => 1,
'version' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
'updated_by' => $employee ? (int) $employee->id : null,
]);
self::$cache = [];
Logger::info("Historical version added: {$ruleCode}", ['from' => $effectiveFrom, 'to' => $effectiveTo, 'reason' => $reason]);
}
public static function getVersionHistory(string $ruleCode, ?int $branchId = null): array
{
$db = App::getInstance()->db();
$where = 'rule_code = ? AND is_active = 1';
$params = [$ruleCode];
if ($branchId !== null) {
$where .= ' AND branch_id = ?';
$params[] = $branchId;
} else {
$where .= ' AND branch_id IS NULL';
}
return $db->select("SELECT * FROM business_rules WHERE {$where} ORDER BY effective_from DESC", $params);
} }
public static function override(string $ruleCode, string $entityType, int $entityId, string $overrideValueJson, string $reason): void public static function override(string $ruleCode, string $entityType, int $entityId, string $overrideValueJson, string $reason): void
......
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
<div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-groups.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div> <div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-groups.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">إضافة مجموعة</h3><div class="tut-step-body">من القائمة: <span class="field">الأنشطة الرياضية</span> > <span class="field">المجموعات</span> > إضافة مجموعة.</div></div> <div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">إضافة مجموعة</h3><div class="tut-step-body">من القائمة: <span class="field">الأنشطة الرياضية</span> > <span class="field">المجموعات</span> > إضافة مجموعة.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">البيانات والربط</h3><div class="tut-step-body"><ul><li><span class="field">code / name_ar</span></li><li><span class="field">program_id</span> — البرنامج (يحدد اللعبة والمستوى تلقائياً)</li><li><span class="field">coach_id</span> — المدرب (يظهر فقط المتخصصين في نفس اللعبة)</li><li><span class="field">min_capacity / max_capacity</span> — الحد الأدنى والأقصى</li><li><span class="field">monthly_fee_member / monthly_fee_nonmember</span> — الرسوم الشهرية</li><li><span class="field">season_start / season_end</span> — مدة الموسم</li></ul></div></div> <div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">البيانات والربط</h3><div class="tut-step-body"><ul><li><span class="field">code / name_ar / name_en</span> — كود واسم المجموعة</li><li><span class="field">النشاط الرياضي</span> — اختياري لتصفية البرامج والمدربين</li><li><span class="field">program_id</span> — البرنامج (يحدد المستوى والفئة العمرية)</li><li><span class="field">coach_id</span> — المدرب (يتم تصفيته حسب النشاط المختار)</li><li><span class="field">الحد الأدنى / الحد الأقصى</span> — سعة المجموعة</li><li><span class="field">رسوم شهرية (أعضاء) / رسوم شهرية (غير أعضاء)</span> — الرسوم</li><li><span class="field">بداية الموسم / نهاية الموسم</span> — مدة الموسم</li></ul><span class="info">اختيار النشاط الرياضي اختياري — لكنه يصفي قائمة البرامج والمدربين المتاحة تلقائياً.</span></div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">إعداد الجدول الأسبوعي</h3><div class="tut-step-body">من صفحة المجموعة > الجدول > إضافة حصة:<ul><li><span class="field">day_of_week</span> — اليوم</li><li><span class="field">facility_unit_id</span> — الوحدة/الحارة/الملعب</li><li><span class="field">start_time / end_time</span> — الوقت</li></ul>مثال: أحد + ثلاثاء + خميس — حارة 3 — من 16:00 إلى 17:00<span class="warn">الجدول الأسبوعي يتحقق من التعارض — لو الوحدة محجوزة في نفس الوقت، النظام يرفض ويوضح التعارض.</span><span class="success">بعد إنشاء الجدول، يمكنك استخدام "توليد الحجوزات" لإنشاء حجوزات تلقائية لكل حصة.</span></div></div> <div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">إعداد الجدول الأسبوعي</h3><div class="tut-step-body">من صفحة المجموعة > الجدول > إضافة حصة:<ul><li><span class="field">day_of_week</span> — اليوم</li><li><span class="field">facility_unit_id</span> — الوحدة/الحارة/الملعب</li><li><span class="field">start_time / end_time</span> — الوقت</li></ul>مثال: أحد + ثلاثاء + خميس — حارة 3 — من 16:00 إلى 17:00<span class="warn">الجدول الأسبوعي يتحقق من التعارض — لو الوحدة محجوزة في نفس الوقت، النظام يرفض ويوضح التعارض.</span><span class="success">بعد إنشاء الجدول، يمكنك استخدام "توليد الحجوزات" لإنشاء حجوزات تلقائية لكل حصة.</span></div></div>
<div class="tut-nav"> <div class="tut-nav">
<a href="/tutorials/sports-activity/create-program"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> إنشاء برنامج تدريبي</a> <a href="/tutorials/sports-activity/create-program"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> إنشاء برنامج تدريبي</a>
......
...@@ -21,11 +21,11 @@ ...@@ -21,11 +21,11 @@
<div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-booking-wizard.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div> <div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-booking-wizard.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">إنشاء حجز جديد</h3><div class="tut-step-body">من القائمة: <span class="field">الحجوزات</span> > <span class="field">حجز جديد</span>.</div></div> <div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح معالج الحجز</h3><div class="tut-step-body">من القائمة الجانبية: <span class="field">الأنشطة الرياضية</span> > <span class="field">معالج الحجز</span>. أو من لوحة التحكم اضغط زر <span class="field">حجز جديد</span>.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">اختيار الوحدة والوقت</h3><div class="tut-step-body"><ul><li><span class="field">facility_unit_id</span> — الوحدة (ملعب تنس، كورت...)</li><li><span class="field">booking_date</span> — التاريخ</li><li><span class="field">start_time / end_time</span> — الوقت</li></ul></div></div> <div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">الخطوة 1: بيانات الحاجز</h3><div class="tut-step-body">المعالج يبدأ بـ 4 خطوات تتبعية. في الخطوة الأولى:<ul><li>أدخل <span class="field">رقم العضوية</span> لملء البيانات تلقائياً (للأعضاء)</li><li>أو أدخل <span class="field">الرقم القومي</span> والاسم يدوياً (للزوار)</li></ul><span class="info">إذا أدخلت رقم العضوية، النظام يسحب الاسم والنوع والسن تلقائياً.</span></div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">الفحص التلقائي</h3><div class="tut-step-body">النظام يتحقق: هل الوحدة فاضية؟ هل في blackout؟ هل الوقت ضمن ساعات التشغيل؟<span class="warn">للوحدات exclusive: حجز واحد فقط يأخذ الوحدة بالكامل. لو في تمرين مجدول — الحجز مرفوض.</span></div></div> <div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">الخطوة 2: اختيار المرفق</h3><div class="tut-step-body">اختر المرفق (ملعب تنس، كورت، حمام سباحة...) ثم اختر الوحدة المحددة.<span class="warn">للوحدات exclusive: حجز واحد فقط يأخذ الوحدة بالكامل. لو في تمرين مجدول — الحجز مرفوض.</span></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">حساب السعر</h3><div class="tut-step-body">النظام يحدد time_bracket تلقائياً → يجلب pricing_rule → يحسب السعر الإجمالي.<span class="info">السعر يتم حسابه لحظياً — أي تغيير في الوقت أو عدد المشاركين يعيد الحساب فوراً.</span></div></div> <div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">الخطوة 3: اختيار الموعد</h3><div class="tut-step-body">حدد التاريخ والوقت. النظام يعرض فقط الأوقات المتاحة ويتحقق تلقائياً من:<ul><li>هل الوحدة فاضية؟</li><li>هل في blackout (صيانة)؟</li><li>هل الوقت ضمن ساعات التشغيل؟</li></ul>السعر يتم حسابه تلقائياً بناءً على <span class="field">time_bracket</span> و<span class="field">pricing_rule</span>.<span class="info">السعر يتم حسابه لحظياً — أي تغيير في الوقت أو عدد المشاركين يعيد الحساب فوراً.</span></div></div>
<div class="tut-step"><div class="tut-step-num">5</div><h3 class="tut-step-title">تأكيد الحجز</h3><div class="tut-step-body">تأكيد → <span class="field">status = confirmed</span> ويتولد <span class="field">booking_number</span> تلقائياً.<span class="success">بعد الحجز، يظهر في المراية كـ "محجوز" (booked) بلون مميز عن التمارين (training).</span></div></div> <div class="tut-step"><div class="tut-step-num">5</div><h3 class="tut-step-title">الخطوة 4: التأكيد والحجز</h3><div class="tut-step-body">راجع الملخص (الشخص، المرفق، الموعد، السعر) ثم اضغط <span class="field">تأكيد الحجز</span>.<br>يتولد <span class="field">booking_number</span> تلقائياً وطلب دفع يُرسل لخزنة الأنشطة.<span class="success">بعد الحجز، يظهر في المراية كـ "محجوز" (booked) بلون مميز عن التمارين (training).</span></div></div>
<div class="tut-nav"> <div class="tut-nav">
<a href="/tutorials/sports-activity/generate-training-bookings"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> توليد حجوزات التمارين</a> <a href="/tutorials/sports-activity/generate-training-bookings"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> توليد حجوزات التمارين</a>
<a href="/tutorials/sports-activity/booking-shared-lane">حجز حارة سباحة <i data-lucide="arrow-left" style="width:14px;height:14px;"></i></a> <a href="/tutorials/sports-activity/booking-shared-lane">حجز حارة سباحة <i data-lucide="arrow-left" style="width:14px;height:14px;"></i></a>
......
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
<div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-subscriptions.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div> <div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-subscriptions.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح التوليد</h3><div class="tut-step-body">من القائمة: <span class="field">الاشتراكات</span> > <span class="field">توليد اشتراكات شهرية</span>.</div></div> <div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح التوليد</h3><div class="tut-step-body">من صفحة الاشتراكات الشهرية، اضغط زر <span class="field">توليد الاشتراكات</span> أعلى الصفحة. أو يمكنك الضغط على <span class="field">معاينة الشهر القادم</span> أولاً لرؤية ما سيتم توليده.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">اختيار الشهر</h3><div class="tut-step-body">اختر الشهر والسنة (مثلاً: يونيو 2026).</div></div> <div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">اختيار الشهر</h3><div class="tut-step-body">اختر الشهر والسنة (مثلاً: يونيو 2026).</div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">المعالجة</h3><div class="tut-step-body">لكل لاعب active في كل مجموعة نشطة:<ul><li>يتحقق: هل سبق توليد اشتراك لنفس الفترة؟</li><li>يحدد المبلغ حسب <span class="field">player_type</span></li><li>ينشئ سجل بحالة <span class="field">unpaid</span></li></ul><div class="tut-diagram">المجموعة "سباحة صباحي": <div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">المعالجة</h3><div class="tut-step-body">لكل لاعب active في كل مجموعة نشطة:<ul><li>يتحقق: هل سبق توليد اشتراك لنفس الفترة؟</li><li>يحدد المبلغ حسب <span class="field">player_type</span></li><li>ينشئ سجل بحالة <span class="field">unpaid</span></li></ul><div class="tut-diagram">المجموعة "سباحة صباحي":
├── أحمد (عضو) 300 ج → اشتراك #SUB-001 unpaid ├── أحمد (عضو) 300 ج → اشتراك #SUB-001 unpaid
......
...@@ -21,8 +21,9 @@ ...@@ -21,8 +21,9 @@
<div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-mirror.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div> <div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-mirror.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح المراية</h3><div class="tut-step-body">من القائمة: <span class="field">الأنشطة الرياضية</span> > <span class="field">المراية</span> > اختر المرفق.</div></div> <div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح المراية</h3><div class="tut-step-body">من القائمة الجانبية: <span class="field">الأنشطة الرياضية</span> > <span class="field">المراية</span>. أو من لوحة التحكم اضغط زر <span class="field">المراية</span>.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">العرض الشبكي</h3><div class="tut-step-body">شبكة = الوحدات (أعمدة) × الفترات الزمنية (صفوف):<div class="tut-diagram">المراية — حمام السباحة — 2026-05-18 <div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">العرض الرئيسي — حالة المرافق</h3><div class="tut-step-body">الصفحة الرئيسية تعرض جميع المرافق مجمّعة حسب النشاط الرياضي. كل مرفق يظهر كبطاقة تحتوي على:<ul><li>اسم المرفق ونوعه (ملعب / صالة / حمام سباحة)</li><li>عدد الوحدات</li></ul>يمكنك التصفية بالضغط على اسم الرياضة في شريط <span class="field">تصفية بالرياضة</span> أعلى الصفحة.</div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">العرض التفصيلي — شبكة المرفق</h3><div class="tut-step-body">عند الضغط على بطاقة مرفق معين، تنتقل لشبكة الوقت الخاصة به:<div class="tut-diagram">المراية — حمام السباحة — اليوم
الوقت | حارة 1 | حارة 2 | حارة 3 | حارة 4 الوقت | حارة 1 | حارة 2 | حارة 3 | حارة 4
─────────┼───────────┼───────────┼───────────┼────────── ─────────┼───────────┼───────────┼───────────┼──────────
...@@ -30,8 +31,8 @@ ...@@ -30,8 +31,8 @@
07:00-08 | 🟡 4/8 | 🟢 free | 🟣 تمرين | 🟢 free 07:00-08 | 🟡 4/8 | 🟢 free | 🟣 تمرين | 🟢 free
08:00-09 | 🔴 full | 🔵 حجز | 🟢 free | 🟢 free 08:00-09 | 🔴 full | 🔵 حجز | 🟢 free | 🟢 free
09:00-10 | 🟢 free | 🟢 free | 🟢 free | ⚫ صيانة</div></div></div> 09:00-10 | 🟢 free | 🟢 free | 🟢 free | ⚫ صيانة</div></div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">الحالات والألوان</h3><div class="tut-step-body"><ul><li>🟢 <span class="field">free</span> — فارغة ومتاحة</li><li>🔵 <span class="field">booked</span> — محجوزة بالساعة</li><li>🟣 <span class="field">training</span> — تمرين مجموعة</li><li>🟡 <span class="field">partial</span> — محجوزة جزئياً (shared)</li><li>🔴 <span class="field">full</span> — ممتلئة</li><li><span class="field">blocked</span> — صيانة</li></ul></div></div> <div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">الحالات والألوان</h3><div class="tut-step-body"><ul><li>🟢 <span class="field">free</span> — فارغة ومتاحة</li><li>🔵 <span class="field">booked</span> — محجوزة بالساعة</li><li>🟣 <span class="field">training</span> — تمرين مجموعة</li><li>🟡 <span class="field">partial</span> — محجوزة جزئياً (shared)</li><li>🔴 <span class="field">full</span> — ممتلئة</li><li><span class="field">blocked</span> — صيانة</li></ul></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">ملاحظات</h3><div class="tut-step-body"><span class="info">المراية للعرض فقط — لا يوجد فيها أي نموذج إدخال. تُعرض عادة على شاشة كبيرة في الاستقبال.</span><span class="warn">تعرض فقط الحجوزات الفعالة. الملغية لا تظهر. تتحدث كل 30 ثانية تلقائياً.</span></div></div> <div class="tut-step"><div class="tut-step-num">5</div><h3 class="tut-step-title">ملاحظات</h3><div class="tut-step-body"><span class="info">المراية للعرض فقط — لا يوجد فيها أي نموذج إدخال. تُعرض عادة على شاشة كبيرة في الاستقبال.</span><span class="warn">تعرض فقط الحجوزات الفعالة. الملغية لا تظهر. تتحدث كل 30 ثانية تلقائياً.</span></div></div>
<div class="tut-nav"> <div class="tut-nav">
<a href="/tutorials/sports-activity/record-attendance"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> تسجيل الحضور</a> <a href="/tutorials/sports-activity/record-attendance"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> تسجيل الحضور</a>
<a href="/tutorials/sports-activity/waitlist-management">إدارة قائمة الانتظار <i data-lucide="arrow-left" style="width:14px;height:14px;"></i></a> <a href="/tutorials/sports-activity/waitlist-management">إدارة قائمة الانتظار <i data-lucide="arrow-left" style="width:14px;height:14px;"></i></a>
......
...@@ -21,8 +21,8 @@ ...@@ -21,8 +21,8 @@
<div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-attendance.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div> <div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-attendance.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح الحضور</h3><div class="tut-step-body">من القائمة: <span class="field">الأنشطة الرياضية</span> > <span class="field">الحضور</span>.</div></div> <div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح الحضور</h3><div class="tut-step-body">من القائمة الجانبية: <span class="field">الأنشطة الرياضية</span> > <span class="field">الحضور</span>. تعرض الصفحة حصص التدريب اليوم مع إمكانية التصفية بالنشاط الرياضي والمدرب.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">اختيار التمرين</h3><div class="tut-step-body">اختر التاريخ والمجموعة. النظام يعرض كل اللاعبين المسجلين.</div></div> <div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">اختيار الحصة</h3><div class="tut-step-body">اضغط زر <span class="field">تسجيل الحضور</span> بجانب الحصة المطلوبة. يعرض الجدول: الوقت، المجموعة، المدرب، المرفق، والوحدة. النظام يعرض كل اللاعبين المسجلين في تلك المجموعة.</div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">تسجيل الحالة</h3><div class="tut-step-body">لكل لاعب:<ul><li><span class="field">present</span> — حاضر (مع check_in_time)</li><li><span class="field">absent</span> — غائب</li><li><span class="field">late</span> — متأخر (مع الوقت الفعلي)</li><li><span class="field">excused</span> — معتذر</li></ul><span class="info">الحضور يرتبط بحجز التمرين — لازم يكون في حجز training لهذا اليوم والمجموعة.</span></div></div> <div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">تسجيل الحالة</h3><div class="tut-step-body">لكل لاعب:<ul><li><span class="field">present</span> — حاضر (مع check_in_time)</li><li><span class="field">absent</span> — غائب</li><li><span class="field">late</span> — متأخر (مع الوقت الفعلي)</li><li><span class="field">excused</span> — معتذر</li></ul><span class="info">الحضور يرتبط بحجز التمرين — لازم يكون في حجز training لهذا اليوم والمجموعة.</span></div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">التقارير</h3><div class="tut-step-body"><span class="success">تقارير متاحة لكل لاعب (نسبة حضوره) ولكل مجموعة (متوسط الحضور). المدرب يقدر يشوف أنماط الغياب.</span></div></div> <div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">التقارير</h3><div class="tut-step-body"><span class="success">تقارير متاحة لكل لاعب (نسبة حضوره) ولكل مجموعة (متوسط الحضور). المدرب يقدر يشوف أنماط الغياب.</span></div></div>
<div class="tut-nav"> <div class="tut-nav">
......
...@@ -21,13 +21,20 @@ ...@@ -21,13 +21,20 @@
<div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-players.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div> <div style="margin-bottom:20px;border:1px solid #E5E7EB;border-radius:12px;overflow:hidden;"><img src="/assets/tutorials/screenshots/sa-players.png" alt="لقطة شاشة" style="width:100%;display:block;" loading="lazy"></div>
<div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح صفحة اللاعبين</h3><div class="tut-step-body">من القائمة: <span class="field">الأنشطة الرياضية</span> > <span class="field">اللاعبين</span>.</div></div> <div class="tut-step"><div class="tut-step-num">1</div><h3 class="tut-step-title">فتح صفحة اللاعبين</h3><div class="tut-step-body">من القائمة الجانبية: <span class="field">الأنشطة الرياضية</span> > <span class="field">اللاعبين</span>.</div></div>
<div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">تسجيل لاعب جديد</h3><div class="tut-step-body">اضغط <span class="field">تسجيل لاعب جديد</span>.</div></div> <div class="tut-step"><div class="tut-step-num">2</div><h3 class="tut-step-title">إضافة لاعب جديد</h3><div class="tut-step-body">اضغط زر <span class="field">إضافة لاعب جديد</span> أعلى الصفحة. ستظهر نموذج التسجيل في صفحة واحدة مقسمة لأقسام.</div></div>
<div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">نوع اللاعب</h3><div class="tut-step-body"><span class="field">player_type</span>: عضو (member) — يتم ربطه بعضوية موجودة، أو غير عضو (non_member).<span class="info">لو اللاعب عضو، بيتم سحب بياناته الأساسية من سجل العضوية تلقائياً.</span></div></div> <div class="tut-step"><div class="tut-step-num">3</div><h3 class="tut-step-title">البيانات الأساسية</h3><div class="tut-step-body">
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">البيانات الشخصية</h3><div class="tut-step-body"><ul><li>الاسم الكامل بالعربي والإنجليزي</li><li>الرقم القومي، تاريخ الميلاد، النوع</li><li>الهاتف، البريد الإلكتروني</li></ul></div></div> <ul>
<div class="tut-step"><div class="tut-step-num">5</div><h3 class="tut-step-title">بيانات ولي الأمر</h3><div class="tut-step-body">للأطفال: اسم ولي الأمر، هاتفه، رقمه القومي، صلة القرابة.</div></div> <li><span class="field">نوع اللاعب</span> — عضو (member) أو غير عضو (non_member). إذا اخترت عضو، أدخل رقم العضوية وستُملأ البيانات تلقائياً</li>
<div class="tut-step"><div class="tut-step-num">6</div><h3 class="tut-step-title">الحالة الطبية والكارنيه</h3><div class="tut-step-body">الحالة الطبية تبدأ <span class="field">pending</span> — لازم يتم رفع شهادة طبية واعتمادها. الكارنيه يبدأ <span class="field">inactive</span>.<span class="warn">اللاعب مش هيقدر يتسجل في أي مجموعة أو يحجز ساعة إلا لما حالته الطبية تبقى "fit" أو "conditional".</span></div></div> <li><span class="field">الاسم بالعربي</span> — إلزامي</li>
<div class="tut-step"><div class="tut-step-num">7</div><h3 class="tut-step-title">حفظ</h3><div class="tut-step-body">يتم توليد <span class="field">registration_serial</span> تلقائياً. الخطوة التالية: رفع الشهادة الطبية.</div></div> <li><span class="field">الاسم بالإنجليزي</span> — اختياري</li>
<li><span class="field">الرقم القومي</span></li>
<li><span class="field">تاريخ الميلاد</span> والنوع</li>
<li><span class="field">الهاتف</span> و<span class="field">البريد الإلكتروني</span></li>
</ul>
</div></div>
<div class="tut-step"><div class="tut-step-num">4</div><h3 class="tut-step-title">بيانات ولي الأمر</h3><div class="tut-step-body">للأطفال: <span class="field">اسم ولي الأمر</span>، <span class="field">هاتف ولي الأمر</span>، <span class="field">الرقم القومي لولي الأمر</span>، <span class="field">صلة القرابة</span>.</div></div>
<div class="tut-step"><div class="tut-step-num">5</div><h3 class="tut-step-title">ملاحظات وحفظ</h3><div class="tut-step-body">يمكنك إضافة ملاحظات إضافية، ثم اضغط <span class="field">حفظ</span>.<br>الحالة الطبية تبدأ <span class="field">pending</span> — لازم يتم رفع شهادة طبية واعتمادها.<span class="warn">اللاعب مش هيقدر يتسجل في أي مجموعة أو يحجز ساعة إلا لما حالته الطبية تبقى "fit" أو "conditional".</span><span class="success">بعد الحفظ: يتم توليد <span class="field">registration_serial</span> تلقائياً. الخطوة التالية: رفع الشهادة الطبية.</span></div></div>
<div class="tut-nav"> <div class="tut-nav">
<a href="/tutorials/sports-activity/register-coach"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> تسجيل مدرب جديد</a> <a href="/tutorials/sports-activity/register-coach"><i data-lucide="arrow-right" style="width:14px;height:14px;"></i> تسجيل مدرب جديد</a>
<a href="/tutorials/sports-activity/medical-workflow">دورة الموافقة الطبية <i data-lucide="arrow-left" style="width:14px;height:14px;"></i></a> <a href="/tutorials/sports-activity/medical-workflow">دورة الموافقة الطبية <i data-lucide="arrow-left" style="width:14px;height:14px;"></i></a>
......
...@@ -31,9 +31,9 @@ ...@@ -31,9 +31,9 @@
<li><span class="field">name_ar</span> — الاسم بالعربي (مثل: كرة القدم)</li> <li><span class="field">name_ar</span> — الاسم بالعربي (مثل: كرة القدم)</li>
<li><span class="field">name_en</span> — الاسم بالإنجليزي (اختياري)</li> <li><span class="field">name_en</span> — الاسم بالإنجليزي (اختياري)</li>
<li><span class="field">category</span> — تصنيف اللعبة</li> <li><span class="field">category</span> — تصنيف اللعبة</li>
<li><span class="field">icon</span> — أيقونة العرض</li> <li><span class="field">sort_order</span> — ترتيب العرض في القوائم</li>
<li><span class="field">icon</span> — أيقونة العرض (مع معاينة مباشرة)</li>
<li><span class="field">description_ar</span> — وصف مختصر</li> <li><span class="field">description_ar</span> — وصف مختصر</li>
<li><span class="field">config_json</span> — إعدادات إضافية (فئات عمرية، مستويات مهارة)</li>
</ul> </ul>
<span class="info">التصنيفات المتاحة: فردي (individual) — جماعي (team) — مضرب (racket) — مائي (aquatic) — قتالي (combat) — ترفيهي (leisure)</span> <span class="info">التصنيفات المتاحة: فردي (individual) — جماعي (team) — مضرب (racket) — مائي (aquatic) — قتالي (combat) — ترفيهي (leisure)</span>
</div></div> </div></div>
......
<?php
declare(strict_types=1);
return function (\App\Core\Database $db): void {
$now = date('Y-m-d H:i:s');
$rules = [
[
'rule_code' => 'ACQUIRED_CHILD_FEE_UNDER_12',
'category' => 'children_fee',
'name_ar' => 'رسوم ابن المنضم - أقل من 12 سنة',
'name_en' => 'Acquired Member Child Fee - Under 12',
'data_type' => 'percentage',
'current_value_json' => '{"percentage":"15.00","base":"membership_value"}',
'parameters_json' => '{"percentage":"decimal","base":"string"}',
],
[
'rule_code' => 'ACQUIRED_CHILD_FEE_12_TO_16',
'category' => 'children_fee',
'name_ar' => 'رسوم ابن المنضم - 12 إلى 16 سنة',
'name_en' => 'Acquired Member Child Fee - 12 to 16',
'data_type' => 'percentage',
'current_value_json' => '{"percentage":"20.00","base":"membership_value"}',
'parameters_json' => '{"percentage":"decimal","base":"string"}',
],
[
'rule_code' => 'ACQUIRED_CHILD_FEE_16_TO_18',
'category' => 'children_fee',
'name_ar' => 'رسوم ابن المنضم - 16 إلى 18 سنة',
'name_en' => 'Acquired Member Child Fee - 16 to 18',
'data_type' => 'percentage',
'current_value_json' => '{"percentage":"25.00","base":"membership_value"}',
'parameters_json' => '{"percentage":"decimal","base":"string"}',
],
[
'rule_code' => 'ACQUIRED_CHILD_FEE_OVER_18',
'category' => 'children_fee',
'name_ar' => 'رسوم ابن المنضم - أكبر من 18 سنة',
'name_en' => 'Acquired Member Child Fee - Over 18',
'data_type' => 'percentage',
'current_value_json' => '{"percentage":"30.00","base":"membership_value"}',
'parameters_json' => '{"percentage":"decimal","base":"string"}',
],
[
'rule_code' => 'ACQUIRED_SPOUSE_FEE_2ND_PLUS',
'category' => 'spouse_fee',
'name_ar' => 'رسوم زوجة المنضم - الثانية فأكثر',
'name_en' => 'Acquired Member Spouse Fee - 2nd+',
'data_type' => 'percentage',
'current_value_json' => '{"percentage":"75.00","base":"membership_value"}',
'parameters_json' => '{"percentage":"decimal","base":"string"}',
],
];
foreach ($rules as $rule) {
$exists = $db->selectOne(
"SELECT id FROM business_rules WHERE rule_code = ? AND branch_id IS NULL",
[$rule['rule_code']]
);
if ($exists) {
continue;
}
$db->insert('business_rules', array_merge($rule, [
'effective_from' => '2024-01-01',
'is_active' => 1,
'version' => 1,
'created_at' => $now,
'updated_at' => $now,
]));
}
};
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE `business_rules` DROP INDEX `uq_business_rules_code_branch`;
ALTER TABLE `business_rules` ADD UNIQUE KEY `uq_business_rules_code_branch_date` (`rule_code`, `branch_id`, `effective_from`)
",
'down' => "
ALTER TABLE `business_rules` DROP INDEX `uq_business_rules_code_branch_date`;
ALTER TABLE `business_rules` ADD UNIQUE KEY `uq_business_rules_code_branch` (`rule_code`, `branch_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