Commit 5ff91171 authored by Mahmoud Aglan's avatar Mahmoud Aglan

test

parent 75815e05
...@@ -23,13 +23,16 @@ class AuditController extends Controller ...@@ -23,13 +23,16 @@ class AuditController extends Controller
]; ];
$page = max(1, (int) $request->get('page', 1)); $page = max(1, (int) $request->get('page', 1));
$result = AuditTrail::search($filters, 30, $page); $result = AuditTrail::search($filters, 40, $page);
$actions = AuditTrail::getDistinctActions(); $actions = AuditTrail::getDistinctActions();
$entityTypes = AuditTrail::getDistinctEntityTypes(); $entityTypes = AuditTrail::getDistinctEntityTypes();
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$employees = $db->select("SELECT id, full_name_ar FROM employees WHERE is_archived = 0 ORDER BY full_name_ar"); $employees = [];
try {
$employees = $db->select("SELECT id, full_name_ar FROM employees WHERE is_archived = 0 ORDER BY full_name_ar");
} catch (\Throwable $e) {}
return $this->view('Audit.Views.index', [ return $this->view('Audit.Views.index', [
'rows' => $result['data'], 'rows' => $result['data'],
...@@ -41,14 +44,44 @@ class AuditController extends Controller ...@@ -41,14 +44,44 @@ class AuditController extends Controller
]); ]);
} }
public function show(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$entry = $db->selectOne("SELECT * FROM audit_trail WHERE id = ?", [(int) $id]);
if (!$entry) {
return $this->redirect('/audit')->withError('السجل غير موجود');
}
$prevEntry = $db->selectOne(
"SELECT id FROM audit_trail WHERE entity_type = ? AND entity_id = ? AND id < ? ORDER BY id DESC LIMIT 1",
[$entry['entity_type'], $entry['entity_id'], (int) $id]
);
$nextEntry = $db->selectOne(
"SELECT id FROM audit_trail WHERE entity_type = ? AND entity_id = ? AND id > ? ORDER BY id ASC LIMIT 1",
[$entry['entity_type'], $entry['entity_id'], (int) $id]
);
return $this->view('Audit.Views.show', [
'entry' => $entry,
'prevId' => $prevEntry ? (int) $prevEntry['id'] : null,
'nextId' => $nextEntry ? (int) $nextEntry['id'] : null,
]);
}
public function entityHistory(Request $request, string $type, string $id): Response public function entityHistory(Request $request, string $type, string $id): Response
{ {
$history = AuditTrail::getEntityHistory($type, (int) $id); $history = AuditTrail::getEntityHistory($type, (int) $id);
$entityLabel = null;
if (!empty($history)) {
$entityLabel = $history[0]['entity_label'] ?? null;
}
return $this->view('Audit.Views.entity-history', [ return $this->view('Audit.Views.entity-history', [
'entityType' => $type, 'entityType' => $type,
'entityId' => (int) $id, 'entityId' => (int) $id,
'history' => $history, 'entityLabel' => $entityLabel,
'history' => $history,
]); ]);
} }
} }
\ No newline at end of file
...@@ -3,5 +3,6 @@ declare(strict_types=1); ...@@ -3,5 +3,6 @@ declare(strict_types=1);
return [ return [
['GET', '/audit', 'Audit\Controllers\AuditController@index', ['auth'], 'report.view_audit'], ['GET', '/audit', 'Audit\Controllers\AuditController@index', ['auth'], 'report.view_audit'],
['GET', '/audit/{id:\d+}', 'Audit\Controllers\AuditController@show', ['auth'], 'report.view_audit'],
['GET', '/audit/entity/{type}/{id:\d+}', 'Audit\Controllers\AuditController@entityHistory', ['auth'], 'report.view_audit'], ['GET', '/audit/entity/{type}/{id:\d+}', 'Audit\Controllers\AuditController@entityHistory', ['auth'], 'report.view_audit'],
]; ];
\ No newline at end of file
...@@ -300,4 +300,195 @@ final class AuditService ...@@ -300,4 +300,195 @@ final class AuditService
} catch (\Throwable $e2) {} } catch (\Throwable $e2) {}
} }
} }
public static function getActionLabel(string $action): string
{
return match ($action) {
'create' => 'إنشاء',
'update' => 'تعديل',
'delete' => 'حذف',
'archive' => 'أرشفة',
'login' => 'تسجيل دخول',
'logout' => 'تسجيل خروج',
'login_failed' => 'فشل تسجيل دخول',
default => $action,
};
}
public static function getActionIcon(string $action): string
{
return match ($action) {
'create' => '➕',
'update' => '✏️',
'delete' => '🗑️',
'archive' => '📦',
'login' => '🔑',
'logout' => '🚪',
'login_failed' => '🚫',
default => '📋',
};
}
public static function getActionColor(string $action): string
{
return match ($action) {
'create' => '#059669',
'update' => '#0284C7',
'delete' => '#DC2626',
'archive' => '#D97706',
'login' => '#6366F1',
'logout' => '#6B7280',
'login_failed' => '#DC2626',
default => '#6B7280',
};
}
public static function getEntityTypeLabel(string $type): string
{
return match ($type) {
'members' => 'عضو',
'employees' => 'موظف',
'spouses' => 'زوج/ة',
'children' => 'ابن/ابنة',
'temporary_members' => 'عضو مؤقت',
'seasonal_memberships' => 'عضوية موسمية',
'payments' => 'دفعة',
'payment_requests' => 'طلب دفع',
'receipts' => 'إيصال',
'installment_plans' => 'خطة تقسيط',
'installment_schedule' => 'قسط',
'subscriptions' => 'اشتراك',
'fines' => 'غرامة',
'divorce_cases' => 'حالة طلاق',
'death_cases' => 'حالة وفاة',
'waiver_requests' => 'طلب تنازل',
'transfer_requests' => 'طلب نقل/فصل',
'branches' => 'فرع',
'roles' => 'دور وظيفي',
'qualifications' => 'مؤهل',
'business_rules' => 'قاعدة عمل',
'service_catalog' => 'خدمة',
'form_submissions' => 'استمارة',
'workflow_instances' => 'مسار عمل',
'journal_entries' => 'قيد محاسبي',
'sports_members' => 'عضو رياضي',
'carnets' => 'كارنيه',
'employee' => 'موظف',
default => $type,
};
}
public static function getFieldLabel(string $field): string
{
return match ($field) {
'full_name_ar' => 'الاسم بالعربية',
'full_name_en' => 'الاسم بالإنجليزية',
'national_id' => 'الرقم القومي',
'status' => 'الحالة',
'membership_number' => 'رقم العضوية',
'form_number' => 'رقم الاستمارة',
'branch_id' => 'الفرع',
'qualification_id' => 'المؤهل',
'membership_value' => 'قيمة العضوية',
'membership_type' => 'نوع العضوية',
'member_category' => 'فئة العضو',
'date_of_birth' => 'تاريخ الميلاد',
'gender' => 'النوع',
'phone' => 'الهاتف',
'email' => 'البريد الإلكتروني',
'marital_status' => 'الحالة الاجتماعية',
'nationality' => 'الجنسية',
'religion' => 'الديانة',
'occupation' => 'المهنة',
'residence_address' => 'عنوان السكن',
'is_archived' => 'مؤرشف',
'archived_at' => 'تاريخ الأرشفة',
'archived_by' => 'أرشف بواسطة',
'created_at' => 'تاريخ الإنشاء',
'updated_at' => 'تاريخ التعديل',
'created_by' => 'أنشئ بواسطة',
'updated_by' => 'عُدّل بواسطة',
'amount' => 'المبلغ',
'payment_type' => 'نوع الدفعة',
'payment_method' => 'طريقة الدفع',
'payment_date' => 'تاريخ الدفع',
'receipt_number' => 'رقم الإيصال',
'request_number' => 'رقم الطلب',
'addition_fee' => 'رسوم الإضافة',
'spouse_order' => 'ترتيب الزوجة',
'child_order' => 'ترتيب الابن',
'classification' => 'التصنيف',
'join_date' => 'تاريخ الالتحاق',
'marriage_date' => 'تاريخ الزواج',
'age_years' => 'السن (سنوات)',
'age_months' => 'السن (شهور)',
'category' => 'الفئة',
'notes' => 'ملاحظات',
'is_voided' => 'ملغى',
'voided_at' => 'تاريخ الإلغاء',
'void_reason' => 'سبب الإلغاء',
'description_ar' => 'الوصف',
'current_value_json' => 'القيمة الحالية',
'rule_code' => 'كود القاعدة',
'fee_amount' => 'مبلغ الرسوم',
'start_date' => 'تاريخ البداية',
'end_date' => 'تاريخ النهاية',
'discount_amount' => 'مبلغ الخصم',
'special_discount_id' => 'خصم خاص',
'has_championship' => 'بطولات رياضية',
'disability_documentation' => 'وثائق إعاقة',
'relationship_to_member' => 'صلة القرابة',
'passport_number' => 'رقم جواز السفر',
'can_separate' => 'يمكن الفصل',
'can_get_independent' => 'يمكن الاستقلال',
'processed_by' => 'معالج بواسطة',
'processed_at' => 'تاريخ المعالجة',
'requested_by' => 'مقدم الطلب',
'related_entity_type' => 'نوع الكيان المرتبط',
'related_entity_id' => 'رقم الكيان المرتبط',
default => $field,
};
}
public static function getStatusLabel(string $status): string
{
return match ($status) {
'active' => 'نشط',
'inactive' => 'غير نشط',
'potential' => 'محتمل',
'under_review' => 'قيد المراجعة',
'accepted' => 'مقبول',
'rejected' => 'مرفوض',
'payment_pending' => 'في انتظار الدفع',
'pending_payment' => 'في انتظار الدفع',
'pending' => 'معلق',
'processing' => 'قيد المعالجة',
'completed' => 'مكتمل',
'cancelled' => 'ملغى',
'frozen' => 'مجمد',
'suspended' => 'موقوف',
'dropped' => 'منسحب',
'expired' => 'منتهي',
default => $status,
};
}
public static function formatFieldValue(string $field, mixed $value): string
{
if ($value === null || $value === '') return '—';
if (in_array($field, ['amount', 'membership_value', 'addition_fee', 'fee_amount', 'discount_amount', 'total_amount', 'down_payment', 'remaining_balance', 'monthly_payment'], true)) {
return number_format((float) $value, 2) . ' ج.م';
}
if ($field === 'status') return self::getStatusLabel((string) $value);
if ($field === 'gender') return match ((string) $value) { 'male' => 'ذكر', 'female' => 'أنثى', default => (string) $value };
if (in_array($field, ['is_archived', 'is_voided', 'has_championship', 'disability_documentation', 'can_separate', 'can_get_independent'], true)) {
return $value ? 'نعم' : 'لا';
}
if ($field === 'payment_type') {
return \App\Modules\Cashier\Services\PaymentRequestService::getPaymentTypeLabel((string) $value);
}
return mb_substr((string) $value, 0, 200);
}
} }
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?> <?php
<?php $__template->section('title'); ?>تاريخ الكيان: <?= e($entityType) ?> #<?= $entityId ?><?php $__template->endSection(); ?> $__template->layout('Layout.main');
use App\Modules\Audit\Services\AuditService;
?>
<?php $__template->section('title'); ?>سجل <?= e(AuditService::getEntityTypeLabel($entityType)) ?><?php if ($entityLabel): ?><?= e($entityLabel) ?><?php else: ?> #<?= $entityId ?><?php endif; ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?> <?php $__template->section('page_actions'); ?>
<a href="/audit" class="btn btn-outline">العودة لسجل المراجعة</a> <a href="/audit" class="btn btn-sm btn-outline">العودة لسجل المراجعة</a>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
<?php $__template->section('content'); ?> <?php $__template->section('content'); ?>
<div class="card">
<?php if (empty($history)): ?> <div class="card" style="margin-bottom:20px;padding:16px 20px;">
<div style="padding:40px;text-align:center;color:#6B7280;">لا يوجد سجل لهذا الكيان</div> <div style="display:flex;align-items:center;gap:12px;">
<?php else: ?> <div style="width:40px;height:40px;border-radius:50%;background:#0D7377;display:flex;align-items:center;justify-content:center;color:#fff;font-size:18px;flex-shrink:0;">
<div style="padding:20px;"> <?= e(mb_substr(AuditService::getEntityTypeLabel($entityType), 0, 1)) ?>
<?php foreach ($history as $i => $entry): ?> </div>
<div style="display:flex;gap:15px;padding:15px 0;<?= $i < count($history) - 1 ? 'border-bottom:1px solid #F3F4F6;' : '' ?>"> <div>
<div style="flex-shrink:0;width:10px;position:relative;"> <div style="font-size:18px;font-weight:700;color:#1A1A2E;"><?= e(AuditService::getEntityTypeLabel($entityType)) ?><?php if ($entityLabel): ?><?= e($entityLabel) ?><?php endif; ?></div>
<div style="width:10px;height:10px;border-radius:50%;background:<?= $entry['action'] === 'create' ? '#059669' : ($entry['action'] === 'delete' || $entry['action'] === 'archive' ? '#DC2626' : '#0284C7') ?>;margin-top:5px;"></div> <div style="font-size:13px;color:#6B7280;">الجدول: <?= e($entityType) ?> | الرقم: #<?= $entityId ?> | عدد السجلات: <?= count($history) ?></div>
<?php if ($i < count($history) - 1): ?> </div>
<div style="position:absolute;top:15px;right:4px;width:2px;height:calc(100% + 15px);background:#E5E7EB;"></div> </div>
</div>
<?php if (empty($history)): ?>
<div class="card" style="padding:60px;text-align:center;color:#6B7280;font-size:16px;">لا يوجد سجل لهذا الكيان</div>
<?php else: ?>
<div style="position:relative;padding-right:30px;">
<div style="position:absolute;right:14px;top:0;bottom:0;width:3px;background:linear-gradient(to bottom,#0D7377,#E5E7EB);border-radius:2px;"></div>
<?php foreach ($history as $i => $entry):
$action = $entry['action'] ?? '';
$color = AuditService::getActionColor($action);
$icon = AuditService::getActionIcon($action);
$actionLabel = AuditService::getActionLabel($action);
$changedFields = null;
if (!empty($entry['changed_fields_json'])) {
$changedFields = json_decode($entry['changed_fields_json'], true);
}
$afterData = null;
if (!empty($entry['after_data_json'])) {
$afterData = json_decode($entry['after_data_json'], true);
}
$beforeData = null;
if (!empty($entry['before_data_json'])) {
$beforeData = json_decode($entry['before_data_json'], true);
}
$ts = strtotime($entry['created_at']);
$diff = time() - $ts;
if ($diff < 60) $timeAgo = 'الآن';
elseif ($diff < 3600) $timeAgo = (int)($diff / 60) . ' دقيقة';
elseif ($diff < 86400) $timeAgo = (int)($diff / 3600) . ' ساعة';
elseif ($diff < 604800) $timeAgo = (int)($diff / 86400) . ' يوم';
else $timeAgo = date('Y/m/d', $ts);
?>
<div style="position:relative;margin-bottom:16px;">
<div style="position:absolute;right:-30px;top:12px;width:28px;height:28px;border-radius:50%;background:<?= $color ?>;display:flex;align-items:center;justify-content:center;font-size:14px;z-index:1;box-shadow:0 0 0 3px #fff;"><?= $icon ?></div>
<div class="card" style="border-right:4px solid <?= $color ?>;overflow:hidden;">
<div style="padding:12px 16px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;cursor:pointer;" onclick="toggleHistoryDetail(<?= $i ?>)">
<div style="flex:1;min-width:200px;">
<span style="color:<?= $color ?>;font-weight:700;font-size:14px;"><?= e($actionLabel) ?></span>
<span style="color:#9CA3AF;font-size:12px;margin-right:8px;">بواسطة <?= e($entry['employee_name'] ?? 'النظام') ?></span>
<?php if ($action === 'update' && is_array($changedFields) && !empty($changedFields)): ?>
<span style="background:#E0F2FE;color:#0284C7;padding:1px 8px;border-radius:10px;font-size:11px;"><?= count($changedFields) ?> تغيير</span>
<?php elseif ($action === 'create' && is_array($afterData)): ?>
<span style="background:#D1FAE5;color:#059669;padding:1px 8px;border-radius:10px;font-size:11px;"><?= count($afterData) ?> حقل</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div style="flex:1;">
<div style="display:flex;justify-content:space-between;margin-bottom:5px;"> <div style="min-width:200px;max-width:350px;font-size:12px;">
<strong style="color:#1A1A2E;"><?= e($entry['action']) ?></strong> <?php if ($action === 'update' && is_array($changedFields) && !empty($changedFields)): ?>
<span style="color:#9CA3AF;font-size:12px;"><?= e($entry['created_at']) ?></span> <?php $first = array_slice($changedFields, 0, 1, true); ?>
</div> <?php foreach ($first as $fname => $fchange): ?>
<div style="font-size:13px;color:#6B7280;margin-bottom:5px;"> <?php if (is_array($fchange) && isset($fchange['from'])): ?>
بواسطة: <?= e($entry['employee_name'] ?? 'النظام') ?> <span style="color:#374151;font-weight:600;"><?= e(AuditService::getFieldLabel($fname)) ?>:</span>
<?php if ($entry['ip_address']): ?> <span style="color:#DC2626;text-decoration:line-through;"><?= e(mb_substr(AuditService::formatFieldValue($fname, $fchange['from']), 0, 20)) ?></span>
<span style="direction:ltr;display:inline-block;">(<?= e($entry['ip_address']) ?>)</span> <span style="color:#9CA3AF;"></span>
<?php endif; ?> <span style="color:#059669;"><?= e(mb_substr(AuditService::formatFieldValue($fname, $fchange['to']), 0, 20)) ?></span>
</div> <?php if (count($changedFields) > 1): ?>
<?php if ($entry['notes']): ?> <span style="color:#9CA3AF;font-size:11px;">+<?= count($changedFields) - 1 ?></span>
<div style="font-size:13px;color:#4B5563;margin-bottom:5px;"><?= e($entry['notes']) ?></div> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
<?php if ($entry['changed_fields_json']): ?> <?php endforeach; ?>
<?php $fields = json_decode($entry['changed_fields_json'], true); ?>
<div style="font-size:12px;color:#9CA3AF;">الحقول المُغيَّرة: <?= e(implode(', ', $fields ?? [])) ?></div>
<?php endif; ?>
<?php if ($entry['before_data_json'] && $entry['after_data_json']): ?>
<?php
$before = json_decode($entry['before_data_json'], true) ?? [];
$after = json_decode($entry['after_data_json'], true) ?? [];
$changedKeys = json_decode($entry['changed_fields_json'] ?? '[]', true) ?? [];
?>
<?php if (!empty($changedKeys)): ?>
<details style="margin-top:8px;">
<summary style="cursor:pointer;color:#0D7377;font-size:13px;">عرض التفاصيل</summary>
<table style="width:100%;margin-top:8px;font-size:12px;border:1px solid #E5E7EB;border-radius:4px;">
<thead><tr style="background:#F9FAFB;"><th style="padding:6px 10px;text-align:right;">الحقل</th><th style="padding:6px 10px;text-align:right;">قبل</th><th style="padding:6px 10px;text-align:right;">بعد</th></tr></thead>
<tbody>
<?php foreach ($changedKeys as $ck): ?>
<tr>
<td style="padding:4px 10px;font-weight:600;"><?= e($ck) ?></td>
<td style="padding:4px 10px;color:#DC2626;background:#FEF2F2;"><?= e((string) ($before[$ck] ?? '—')) ?></td>
<td style="padding:4px 10px;color:#059669;background:#F0FDF4;"><?= e((string) ($after[$ck] ?? '—')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</details>
<?php endif; ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div style="flex-shrink:0;text-align:left;">
<div style="font-size:12px;color:#9CA3AF;" title="<?= e($entry['created_at']) ?>"><?= $timeAgo ?></div>
<a href="/audit/<?= (int) $entry['id'] ?>" onclick="event.stopPropagation();" style="font-size:11px;color:#0D7377;">تفاصيل</a>
</div>
</div>
<div id="history-detail-<?= $i ?>" style="display:none;border-top:1px solid #E5E7EB;padding:16px;background:#FAFBFC;">
<?php if ($action === 'create' && is_array($afterData) && !empty($afterData)): ?>
<div style="margin-bottom:8px;font-weight:700;color:#059669;font-size:13px;">البيانات المنشأة:</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:6px;">
<?php foreach ($afterData as $fk => $fv):
if (in_array($fk, ['id', 'created_at', 'updated_at'], true) || $fv === null || $fv === '') continue;
?>
<div style="background:#F0FDF4;border:1px solid #BBF7D0;border-radius:6px;padding:5px 10px;font-size:12px;">
<span style="color:#374151;font-weight:600;"><?= e(AuditService::getFieldLabel($fk)) ?>:</span>
<span style="color:#065F46;"><?= e(AuditService::formatFieldValue($fk, $fv)) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php elseif ($action === 'update' && is_array($changedFields) && !empty($changedFields)): ?>
<div style="margin-bottom:8px;font-weight:700;color:#0284C7;font-size:13px;">التغييرات:</div>
<table style="width:100%;font-size:12px;border:1px solid #E5E7EB;border-radius:6px;border-collapse:collapse;">
<thead>
<tr style="background:#F0F9FF;">
<th style="padding:8px 12px;text-align:right;border-bottom:1px solid #BAE6FD;width:25%;">الحقل</th>
<th style="padding:8px 12px;text-align:right;border-bottom:1px solid #BAE6FD;width:37%;">قبل</th>
<th style="padding:8px 12px;text-align:right;border-bottom:1px solid #BAE6FD;width:37%;">بعد</th>
</tr>
</thead>
<tbody>
<?php foreach ($changedFields as $fname => $fchange): ?>
<?php if (!is_array($fchange) || !isset($fchange['from'])) continue; ?>
<tr>
<td style="padding:6px 12px;font-weight:600;color:#374151;border-bottom:1px solid #F3F4F6;"><?= e(AuditService::getFieldLabel($fname)) ?></td>
<td style="padding:6px 12px;color:#DC2626;background:#FEF2F2;border-bottom:1px solid #F3F4F6;"><?= e(AuditService::formatFieldValue($fname, $fchange['from'])) ?></td>
<td style="padding:6px 12px;color:#059669;background:#F0FDF4;border-bottom:1px solid #F3F4F6;"><?= e(AuditService::formatFieldValue($fname, $fchange['to'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php elseif ($action === 'delete' && is_array($beforeData) && !empty($beforeData)): ?>
<div style="margin-bottom:8px;font-weight:700;color:#DC2626;font-size:13px;">البيانات المحذوفة:</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:6px;">
<?php foreach ($beforeData as $fk => $fv):
if ($fv === null || $fv === '') continue;
?>
<div style="background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;padding:5px 10px;font-size:12px;">
<span style="color:#374151;font-weight:600;"><?= e(AuditService::getFieldLabel($fk)) ?>:</span>
<span style="color:#991B1B;"><?= e(AuditService::formatFieldValue($fk, $fv)) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php elseif ($action === 'archive' && is_array($beforeData) && !empty($beforeData)): ?>
<div style="margin-bottom:8px;font-weight:700;color:#D97706;font-size:13px;">البيانات المؤرشفة:</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:6px;">
<?php foreach ($beforeData as $fk => $fv):
if ($fv === null || $fv === '') continue;
?>
<div style="background:#FFFBEB;border:1px solid #FDE68A;border-radius:6px;padding:5px 10px;font-size:12px;">
<span style="color:#374151;font-weight:600;"><?= e(AuditService::getFieldLabel($fk)) ?>:</span>
<span style="color:#92400E;"><?= e(AuditService::formatFieldValue($fk, $fv)) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php elseif ($entry['notes']): ?>
<div style="color:#4B5563;font-size:13px;"><?= e($entry['notes']) ?></div>
<?php else: ?>
<div style="color:#9CA3AF;font-size:13px;">لا توجد تفاصيل إضافية</div>
<?php endif; ?>
</div> </div>
<?php endforeach; ?>
</div> </div>
<?php endif; ?> </div>
<?php endforeach; ?>
</div> </div>
<?php $__template->endSection(); ?>
\ No newline at end of file <?php endif; ?>
<script>
function toggleHistoryDetail(idx) {
var el = document.getElementById('history-detail-' + idx);
if (!el) return;
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
</script>
<style>
.card { transition: box-shadow 0.15s ease; }
.card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
</style>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?> <?php
$__template->layout('Layout.main');
use App\Modules\Audit\Services\AuditService;
?>
<?php $__template->section('title'); ?>سجل المراجعة<?php $__template->endSection(); ?> <?php $__template->section('title'); ?>سجل المراجعة<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?> <?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;"> <div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/audit" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;"> <form method="GET" action="/audit" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div> <div>
<label class="form-label" style="font-size:12px;">بحث</label> <label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="q" value="<?= e($filters['search']) ?>" placeholder="بحث..." class="form-input" style="min-width:150px;"> <input type="text" name="q" value="<?= e($filters['search']) ?>" placeholder="بحث في السجلات..." class="form-input" style="min-width:180px;">
</div> </div>
<div> <div>
<label class="form-label" style="font-size:12px;">الإجراء</label> <label class="form-label" style="font-size:12px;">الإجراء</label>
<select name="action" class="form-select" style="min-width:120px;"> <select name="action" class="form-select" style="min-width:130px;">
<option value="">الكل</option> <option value="">الكل</option>
<?php foreach ($actions as $a): ?> <?php foreach ($actions as $a): ?>
<option value="<?= e($a) ?>" <?= $filters['action'] === $a ? 'selected' : '' ?>><?= e($a) ?></option> <option value="<?= e($a) ?>" <?= $filters['action'] === $a ? 'selected' : '' ?>><?= e(AuditService::getActionLabel($a)) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div> <div>
<label class="form-label" style="font-size:12px;">نوع الكيان</label> <label class="form-label" style="font-size:12px;">نوع الكيان</label>
<select name="entity_type" class="form-select" style="min-width:120px;"> <select name="entity_type" class="form-select" style="min-width:130px;">
<option value="">الكل</option> <option value="">الكل</option>
<?php foreach ($entityTypes as $et): ?> <?php foreach ($entityTypes as $et): ?>
<option value="<?= e($et) ?>" <?= $filters['entity_type'] === $et ? 'selected' : '' ?>><?= e($et) ?></option> <option value="<?= e($et) ?>" <?= $filters['entity_type'] === $et ? 'selected' : '' ?>><?= e(AuditService::getEntityTypeLabel($et)) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div> <div>
<label class="form-label" style="font-size:12px;">الموظف</label> <label class="form-label" style="font-size:12px;">الموظف</label>
<select name="employee_id" class="form-select" style="min-width:150px;"> <select name="employee_id" class="form-select" style="min-width:160px;">
<option value="">الكل</option> <option value="">الكل</option>
<?php foreach ($employees as $emp): ?> <?php foreach ($employees as $emp): ?>
<option value="<?= (int) $emp['id'] ?>" <?= $filters['employee_id'] == $emp['id'] ? 'selected' : '' ?>><?= e($emp['full_name_ar']) ?></option> <option value="<?= (int) $emp['id'] ?>" <?= $filters['employee_id'] == $emp['id'] ? 'selected' : '' ?>><?= e($emp['full_name_ar']) ?></option>
...@@ -47,107 +51,206 @@ ...@@ -47,107 +51,206 @@
</form> </form>
</div> </div>
<div class="card"> <?php if (empty($rows)): ?>
<div class="table-responsive"> <div class="card" style="padding:60px;text-align:center;color:#6B7280;font-size:16px;">لا توجد سجلات مطابقة</div>
<table class="data-table"> <?php else: ?>
<thead>
<tr> <div style="display:flex;flex-direction:column;gap:8px;">
<th>التاريخ</th> <?php foreach ($rows as $idx => $r):
<th>الموظف</th> $action = $r['action'] ?? '';
<th>الإجراء</th> $color = AuditService::getActionColor($action);
<th>نوع الكيان</th> $icon = AuditService::getActionIcon($action);
<th>الكيان</th> $actionLabel = AuditService::getActionLabel($action);
<th>التغييرات</th> $entityLabel = AuditService::getEntityTypeLabel($r['entity_type'] ?? '');
<th>IP</th>
</tr> $changedFields = null;
</thead> if (!empty($r['changed_fields_json'])) {
<tbody> $changedFields = json_decode($r['changed_fields_json'], true);
<?php foreach ($rows as $r): ?> }
<tr> $afterData = null;
<td style="font-size:12px;white-space:nowrap;"><?= e($r['created_at']) ?></td> if (!empty($r['after_data_json'])) {
<td><?= e($r['employee_name'] ?? '—') ?></td> $afterData = json_decode($r['after_data_json'], true);
<td> }
<?php $beforeData = null;
$actionColors = [ if (!empty($r['before_data_json'])) {
'create' => '#059669', 'update' => '#0284C7', 'delete' => '#DC2626', $beforeData = json_decode($r['before_data_json'], true);
'archive' => '#D97706', 'login' => '#6B7280', 'logout' => '#6B7280', }
'login_failed' => '#DC2626',
]; $ts = strtotime($r['created_at']);
$color = $actionColors[$r['action']] ?? '#6B7280'; $diff = time() - $ts;
?> if ($diff < 60) $timeAgo = 'الآن';
<span style="color:<?= $color ?>;font-weight:600;font-size:13px;"><?= e($r['action']) ?></span> elseif ($diff < 3600) $timeAgo = (int)($diff / 60) . ' د';
</td> elseif ($diff < 86400) $timeAgo = (int)($diff / 3600) . ' س';
<td style="font-size:13px;"><?= e($r['entity_type'] ?? '—') ?></td> elseif ($diff < 604800) $timeAgo = (int)($diff / 86400) . ' يوم';
<td> else $timeAgo = date('Y/m/d', $ts);
<?php if ($r['entity_type'] && $r['entity_id']): ?> ?>
<a href="/audit/entity/<?= urlencode($r['entity_type']) ?>/<?= (int) $r['entity_id'] ?>" style="color:#0D7377;"> <div class="card audit-row" style="border-right:4px solid <?= $color ?>;padding:0;overflow:hidden;cursor:pointer;" onclick="toggleAuditDetail(<?= $idx ?>)">
<?= e($r['entity_label'] ?? '#' . $r['entity_id']) ?> <div style="padding:12px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
</a> <div style="font-size:20px;flex-shrink:0;width:32px;text-align:center;"><?= $icon ?></div>
<?php else: ?>
<?= e($r['entity_label'] ?? '—') ?> <div style="flex:1;min-width:200px;">
<?php endif; ?> <div style="display:flex;align-items:center;gap:8px;margin-bottom:3px;">
</td> <span style="color:<?= $color ?>;font-weight:700;font-size:14px;"><?= e($actionLabel) ?></span>
<td style="font-size:12px;color:#6B7280;max-width:300px;"> <span style="background:#F3F4F6;padding:1px 8px;border-radius:10px;font-size:12px;color:#4B5563;"><?= e($entityLabel) ?></span>
<?php if ($r['changed_fields_json']): ?> <?php if ($r['entity_label']): ?>
<?php $fields = json_decode($r['changed_fields_json'], true); ?> <span style="color:#1A1A2E;font-weight:500;font-size:13px;"><?= e($r['entity_label']) ?></span>
<?php if (is_array($fields) && !empty($fields)): ?> <?php elseif ($r['entity_id']): ?>
<?php <span style="color:#9CA3AF;font-size:12px;">#<?= (int) $r['entity_id'] ?></span>
$first3 = array_slice($fields, 0, 3, true);
foreach ($first3 as $fname => $fchange):
if (is_array($fchange) && isset($fchange['from'])):
?>
<div style="margin-bottom:2px;">
<strong style="color:#374151;"><?= e($fname) ?>:</strong>
<span style="color:#DC2626;text-decoration:line-through;"><?= e(mb_substr((string)($fchange['from'] ?? '—'), 0, 30)) ?></span>
<span style="color:#059669;"><?= e(mb_substr((string)($fchange['to'] ?? '—'), 0, 30)) ?></span>
</div>
<?php elseif (is_string($fchange)): ?>
<span style="background:#F3F4F6;padding:1px 5px;border-radius:3px;font-size:11px;margin-left:3px;"><?= e($fchange) ?></span>
<?php else: ?>
<span style="background:#F3F4F6;padding:1px 5px;border-radius:3px;font-size:11px;margin-left:3px;"><?= e($fname) ?></span>
<?php endif; endforeach; ?>
<?php if (count($fields) > 3): ?>
<span style="color:#9CA3AF;font-size:11px;">+<?= count($fields) - 3 ?> حقل آخر</span>
<?php endif; ?>
<?php else: ?>
<?php endif; ?>
<?php elseif ($r['notes']): ?>
<?= e(mb_substr($r['notes'], 0, 80)) ?>
<?php else: ?>
<?php endif; ?>
</td>
<td style="direction:ltr;text-align:right;font-size:12px;color:#9CA3AF;"><?= e($r['ip_address'] ?? '—') ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?>
<tr><td colspan="7" style="text-align:center;padding:40px;color:#6B7280;">لا توجد سجلات</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </div>
</table>
</div> <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;color:#6B7280;">
<?php if ($pagination['last_page'] > 1): ?> <span><?= e($r['employee_name'] ?? 'النظام') ?></span>
<div style="padding:15px;"> <?php if ($r['ip_address']): ?>
<nav class="pagination-wrapper"> <span style="direction:ltr;display:inline-block;"><?= e($r['ip_address']) ?></span>
<ul class="pagination" style="display:flex;gap:5px;list-style:none;padding:0;justify-content:center;">
<?php if ($pagination['has_prev']): ?>
<li><a href="?page=<?= $pagination['prev_page'] ?>&q=<?= urlencode($filters['search']) ?>&action=<?= urlencode($filters['action']) ?>&entity_type=<?= urlencode($filters['entity_type']) ?>&employee_id=<?= urlencode($filters['employee_id']) ?>&date_from=<?= urlencode($filters['date_from']) ?>&date_to=<?= urlencode($filters['date_to']) ?>" class="btn btn-sm btn-outline">السابق</a></li>
<?php endif; ?> <?php endif; ?>
<?php foreach ($pagination['pages'] as $p): ?> </div>
<?php if ($p === '...'): ?> </div>
<li style="padding:5px;">...</li>
<?php else: ?> <div style="min-width:250px;max-width:400px;font-size:12px;">
<li><a href="?page=<?= $p ?>&q=<?= urlencode($filters['search']) ?>&action=<?= urlencode($filters['action']) ?>&entity_type=<?= urlencode($filters['entity_type']) ?>&employee_id=<?= urlencode($filters['employee_id']) ?>&date_from=<?= urlencode($filters['date_from']) ?>&date_to=<?= urlencode($filters['date_to']) ?>" class="btn btn-sm <?= $p === $pagination['current_page'] ? 'btn-primary' : 'btn-outline' ?>"><?= $p ?></a></li> <?php if ($action === 'update' && is_array($changedFields) && !empty($changedFields)): ?>
<?php $preview = array_slice($changedFields, 0, 2, true); ?>
<?php foreach ($preview as $fname => $fchange): ?>
<?php if (is_array($fchange) && isset($fchange['from'])): ?>
<div style="margin-bottom:1px;">
<span style="color:#374151;font-weight:600;"><?= e(AuditService::getFieldLabel($fname)) ?>:</span>
<span style="color:#DC2626;text-decoration:line-through;"><?= e(mb_substr(AuditService::formatFieldValue($fname, $fchange['from']), 0, 25)) ?></span>
<span style="color:#9CA3AF;margin:0 2px;"></span>
<span style="color:#059669;"><?= e(mb_substr(AuditService::formatFieldValue($fname, $fchange['to']), 0, 25)) ?></span>
</div>
<?php endif; ?> <?php endif; ?>
<?php endforeach; ?> <?php endforeach; ?>
<?php if ($pagination['has_next']): ?> <?php if (count($changedFields) > 2): ?>
<li><a href="?page=<?= $pagination['next_page'] ?>&q=<?= urlencode($filters['search']) ?>&action=<?= urlencode($filters['action']) ?>&entity_type=<?= urlencode($filters['entity_type']) ?>&employee_id=<?= urlencode($filters['employee_id']) ?>&date_from=<?= urlencode($filters['date_from']) ?>&date_to=<?= urlencode($filters['date_to']) ?>" class="btn btn-sm btn-outline">التالي</a></li> <span style="color:#9CA3AF;font-size:11px;">+<?= count($changedFields) - 2 ?> تغيير آخر</span>
<?php endif; ?> <?php endif; ?>
</ul> <?php elseif ($action === 'create' && is_array($afterData) && !empty($afterData)): ?>
</nav> <span style="color:#059669;font-size:11px;">سجل جديد (<?= count($afterData) ?> حقل)</span>
<?php elseif ($action === 'delete' && is_array($beforeData) && !empty($beforeData)): ?>
<span style="color:#DC2626;font-size:11px;">تم الحذف (<?= count($beforeData) ?> حقل)</span>
<?php elseif ($r['notes']): ?>
<span style="color:#6B7280;"><?= e(mb_substr($r['notes'], 0, 60)) ?></span>
<?php endif; ?>
</div>
<div style="flex-shrink:0;text-align:left;min-width:80px;">
<div style="font-size:12px;color:#9CA3AF;" title="<?= e($r['created_at']) ?>"><?= $timeAgo ?></div>
<a href="/audit/<?= (int) $r['id'] ?>" onclick="event.stopPropagation();" style="font-size:11px;color:#0D7377;">تفاصيل</a>
</div>
</div>
<div id="audit-detail-<?= $idx ?>" style="display:none;border-top:1px solid #E5E7EB;padding:16px;background:#FAFBFC;">
<?php if ($action === 'create' && is_array($afterData) && !empty($afterData)): ?>
<div style="margin-bottom:8px;font-weight:700;color:#059669;font-size:13px;">البيانات المنشأة:</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:6px;">
<?php foreach ($afterData as $fk => $fv):
if (in_array($fk, ['id', 'created_at', 'updated_at'], true) || $fv === null || $fv === '') continue;
?>
<div style="background:#F0FDF4;border:1px solid #BBF7D0;border-radius:6px;padding:6px 10px;font-size:12px;">
<span style="color:#374151;font-weight:600;"><?= e(AuditService::getFieldLabel($fk)) ?>:</span>
<span style="color:#065F46;"><?= e(AuditService::formatFieldValue($fk, $fv)) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php elseif ($action === 'update' && is_array($changedFields) && !empty($changedFields)): ?>
<div style="margin-bottom:8px;font-weight:700;color:#0284C7;font-size:13px;">التغييرات:</div>
<table style="width:100%;font-size:12px;border:1px solid #E5E7EB;border-radius:6px;border-collapse:collapse;">
<thead>
<tr style="background:#F0F9FF;">
<th style="padding:8px 12px;text-align:right;border-bottom:1px solid #E5E7EB;width:25%;">الحقل</th>
<th style="padding:8px 12px;text-align:right;border-bottom:1px solid #E5E7EB;width:37%;">قبل</th>
<th style="padding:8px 12px;text-align:right;border-bottom:1px solid #E5E7EB;width:37%;">بعد</th>
</tr>
</thead>
<tbody>
<?php foreach ($changedFields as $fname => $fchange): ?>
<?php if (!is_array($fchange) || !isset($fchange['from'])) continue; ?>
<tr>
<td style="padding:6px 12px;font-weight:600;color:#374151;border-bottom:1px solid #F3F4F6;"><?= e(AuditService::getFieldLabel($fname)) ?></td>
<td style="padding:6px 12px;color:#DC2626;background:#FEF2F2;border-bottom:1px solid #F3F4F6;"><?= e(AuditService::formatFieldValue($fname, $fchange['from'])) ?></td>
<td style="padding:6px 12px;color:#059669;background:#F0FDF4;border-bottom:1px solid #F3F4F6;"><?= e(AuditService::formatFieldValue($fname, $fchange['to'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php elseif ($action === 'delete' && is_array($beforeData) && !empty($beforeData)): ?>
<div style="margin-bottom:8px;font-weight:700;color:#DC2626;font-size:13px;">البيانات المحذوفة:</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:6px;">
<?php foreach ($beforeData as $fk => $fv):
if ($fv === null || $fv === '') continue;
?>
<div style="background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;padding:6px 10px;font-size:12px;">
<span style="color:#374151;font-weight:600;"><?= e(AuditService::getFieldLabel($fk)) ?>:</span>
<span style="color:#991B1B;"><?= e(AuditService::formatFieldValue($fk, $fv)) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php elseif ($action === 'archive' && is_array($beforeData) && !empty($beforeData)): ?>
<div style="margin-bottom:8px;font-weight:700;color:#D97706;font-size:13px;">البيانات المؤرشفة:</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:6px;">
<?php foreach ($beforeData as $fk => $fv):
if ($fv === null || $fv === '') continue;
?>
<div style="background:#FFFBEB;border:1px solid #FDE68A;border-radius:6px;padding:6px 10px;font-size:12px;">
<span style="color:#374151;font-weight:600;"><?= e(AuditService::getFieldLabel($fk)) ?>:</span>
<span style="color:#92400E;"><?= e(AuditService::formatFieldValue($fk, $fv)) ?></span>
</div>
<?php endforeach; ?>
</div>
<?php elseif ($r['notes']): ?>
<div style="color:#4B5563;font-size:13px;"><?= e($r['notes']) ?></div>
<?php else: ?>
<div style="color:#9CA3AF;font-size:13px;">لا توجد تفاصيل إضافية</div>
<?php endif; ?>
<?php if ($r['entity_type'] && $r['entity_id']): ?>
<div style="margin-top:10px;padding-top:8px;border-top:1px solid #E5E7EB;">
<a href="/audit/entity/<?= urlencode($r['entity_type']) ?>/<?= (int) $r['entity_id'] ?>" style="color:#0D7377;font-size:12px;font-weight:600;">عرض كل سجل هذا الكيان ←</a>
</div>
<?php endif; ?>
</div> </div>
<?php endif; ?>
</div> </div>
<?php $__template->endSection(); ?> <?php endforeach; ?>
\ No newline at end of file </div>
<?php if ($pagination['last_page'] > 1): ?>
<div style="padding:20px 0;">
<nav>
<ul style="display:flex;gap:5px;list-style:none;padding:0;justify-content:center;flex-wrap:wrap;">
<?php if ($pagination['has_prev']): ?>
<li><a href="?page=<?= $pagination['prev_page'] ?>&q=<?= urlencode($filters['search']) ?>&action=<?= urlencode($filters['action']) ?>&entity_type=<?= urlencode($filters['entity_type']) ?>&employee_id=<?= urlencode($filters['employee_id']) ?>&date_from=<?= urlencode($filters['date_from']) ?>&date_to=<?= urlencode($filters['date_to']) ?>" class="btn btn-sm btn-outline">السابق</a></li>
<?php endif; ?>
<?php foreach ($pagination['pages'] as $p): ?>
<?php if ($p === '...'): ?>
<li style="padding:5px;">...</li>
<?php else: ?>
<li><a href="?page=<?= $p ?>&q=<?= urlencode($filters['search']) ?>&action=<?= urlencode($filters['action']) ?>&entity_type=<?= urlencode($filters['entity_type']) ?>&employee_id=<?= urlencode($filters['employee_id']) ?>&date_from=<?= urlencode($filters['date_from']) ?>&date_to=<?= urlencode($filters['date_to']) ?>" class="btn btn-sm <?= $p === $pagination['current_page'] ? 'btn-primary' : 'btn-outline' ?>"><?= $p ?></a></li>
<?php endif; ?>
<?php endforeach; ?>
<?php if ($pagination['has_next']): ?>
<li><a href="?page=<?= $pagination['next_page'] ?>&q=<?= urlencode($filters['search']) ?>&action=<?= urlencode($filters['action']) ?>&entity_type=<?= urlencode($filters['entity_type']) ?>&employee_id=<?= urlencode($filters['employee_id']) ?>&date_from=<?= urlencode($filters['date_from']) ?>&date_to=<?= urlencode($filters['date_to']) ?>" class="btn btn-sm btn-outline">التالي</a></li>
<?php endif; ?>
</ul>
</nav>
</div>
<?php endif; ?>
<?php endif; ?>
<script>
function toggleAuditDetail(idx) {
var el = document.getElementById('audit-detail-' + idx);
if (!el) return;
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
</script>
<style>
.audit-row { transition: box-shadow 0.15s ease; }
.audit-row:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
</style>
<?php $__template->endSection(); ?>
<?php
$__template->layout('Layout.main');
use App\Modules\Audit\Services\AuditService;
$action = $entry['action'] ?? '';
$color = AuditService::getActionColor($action);
$icon = AuditService::getActionIcon($action);
$actionLabel = AuditService::getActionLabel($action);
$entityLabel = AuditService::getEntityTypeLabel($entry['entity_type'] ?? '');
$changedFields = null;
if (!empty($entry['changed_fields_json'])) {
$changedFields = json_decode($entry['changed_fields_json'], true);
}
$afterData = null;
if (!empty($entry['after_data_json'])) {
$afterData = json_decode($entry['after_data_json'], true);
}
$beforeData = null;
if (!empty($entry['before_data_json'])) {
$beforeData = json_decode($entry['before_data_json'], true);
}
?>
<?php $__template->section('title'); ?>تفاصيل السجل #<?= (int) $entry['id'] ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<div style="display:flex;gap:8px;">
<?php if ($prevId): ?>
<a href="/audit/<?= $prevId ?>" class="btn btn-sm btn-outline">→ السابق</a>
<?php endif; ?>
<?php if ($nextId): ?>
<a href="/audit/<?= $nextId ?>" class="btn btn-sm btn-outline">التالي ←</a>
<?php endif; ?>
<?php if ($entry['entity_type'] && $entry['entity_id']): ?>
<a href="/audit/entity/<?= urlencode($entry['entity_type']) ?>/<?= (int) $entry['entity_id'] ?>" class="btn btn-sm btn-outline">سجل الكيان</a>
<?php endif; ?>
<a href="/audit" class="btn btn-sm btn-outline">العودة للسجل</a>
</div>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="border-right:5px solid <?= $color ?>;margin-bottom:20px;">
<div style="padding:20px;">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
<span style="font-size:28px;"><?= $icon ?></span>
<div>
<div style="font-size:20px;font-weight:700;color:<?= $color ?>;"><?= e($actionLabel) ?></div>
<div style="font-size:14px;color:#6B7280;"><?= e($entityLabel) ?> <?php if ($entry['entity_label']): ?><?= e($entry['entity_label']) ?><?php endif; ?></div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;font-size:13px;">
<div style="background:#F9FAFB;border-radius:8px;padding:10px 14px;">
<div style="color:#9CA3AF;font-size:11px;margin-bottom:2px;">الموظف</div>
<div style="color:#1A1A2E;font-weight:600;"><?= e($entry['employee_name'] ?? 'النظام') ?></div>
</div>
<div style="background:#F9FAFB;border-radius:8px;padding:10px 14px;">
<div style="color:#9CA3AF;font-size:11px;margin-bottom:2px;">التاريخ والوقت</div>
<div style="color:#1A1A2E;font-weight:600;"><?= e($entry['created_at']) ?></div>
</div>
<?php if ($entry['ip_address']): ?>
<div style="background:#F9FAFB;border-radius:8px;padding:10px 14px;">
<div style="color:#9CA3AF;font-size:11px;margin-bottom:2px;">عنوان IP</div>
<div style="color:#1A1A2E;font-weight:600;direction:ltr;text-align:right;"><?= e($entry['ip_address']) ?></div>
</div>
<?php endif; ?>
<?php if ($entry['route']): ?>
<div style="background:#F9FAFB;border-radius:8px;padding:10px 14px;">
<div style="color:#9CA3AF;font-size:11px;margin-bottom:2px;">المسار</div>
<div style="color:#1A1A2E;font-weight:500;direction:ltr;text-align:right;font-size:12px;word-break:break-all;"><?= e($entry['route']) ?></div>
</div>
<?php endif; ?>
<?php if ($entry['entity_id']): ?>
<div style="background:#F9FAFB;border-radius:8px;padding:10px 14px;">
<div style="color:#9CA3AF;font-size:11px;margin-bottom:2px;">رقم الكيان</div>
<div style="color:#1A1A2E;font-weight:600;">#<?= (int) $entry['entity_id'] ?></div>
</div>
<?php endif; ?>
</div>
<?php if ($entry['notes']): ?>
<div style="margin-top:14px;padding:10px 14px;background:#FFF7ED;border:1px solid #FDE68A;border-radius:8px;font-size:13px;color:#92400E;">
<?= e($entry['notes']) ?>
</div>
<?php endif; ?>
</div>
</div>
<?php if ($action === 'create' && is_array($afterData) && !empty($afterData)): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:16px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#059669;font-size:15px;">البيانات المنشأة</h3>
</div>
<div style="padding:16px 20px;">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:8px;">
<?php foreach ($afterData as $fk => $fv):
if ($fv === null || $fv === '') continue;
?>
<div style="display:flex;align-items:baseline;gap:8px;padding:8px 12px;background:#F0FDF4;border:1px solid #BBF7D0;border-radius:6px;">
<span style="color:#374151;font-weight:600;font-size:12px;min-width:120px;flex-shrink:0;"><?= e(AuditService::getFieldLabel($fk)) ?></span>
<span style="color:#065F46;font-size:13px;word-break:break-word;"><?= e(AuditService::formatFieldValue($fk, $fv)) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php elseif ($action === 'update' && is_array($changedFields) && !empty($changedFields)): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:16px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#0284C7;font-size:15px;">التغييرات (<?= count($changedFields) ?> حقل)</h3>
</div>
<div style="padding:0;">
<table style="width:100%;font-size:13px;border-collapse:collapse;">
<thead>
<tr style="background:#F0F9FF;">
<th style="padding:10px 16px;text-align:right;border-bottom:2px solid #BAE6FD;width:25%;">الحقل</th>
<th style="padding:10px 16px;text-align:right;border-bottom:2px solid #BAE6FD;width:37%;">القيمة السابقة</th>
<th style="padding:10px 16px;text-align:right;border-bottom:2px solid #BAE6FD;width:37%;">القيمة الجديدة</th>
</tr>
</thead>
<tbody>
<?php foreach ($changedFields as $fname => $fchange): ?>
<?php if (!is_array($fchange) || !isset($fchange['from'])) continue; ?>
<tr>
<td style="padding:10px 16px;font-weight:600;color:#374151;border-bottom:1px solid #F3F4F6;"><?= e(AuditService::getFieldLabel($fname)) ?></td>
<td style="padding:10px 16px;color:#DC2626;background:#FEF2F2;border-bottom:1px solid #F3F4F6;">
<?= e(AuditService::formatFieldValue($fname, $fchange['from'])) ?>
</td>
<td style="padding:10px 16px;color:#059669;background:#F0FDF4;border-bottom:1px solid #F3F4F6;">
<?= e(AuditService::formatFieldValue($fname, $fchange['to'])) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php if (is_array($beforeData) && !empty($beforeData)): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:16px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#6B7280;font-size:15px;">اللقطة الكاملة (قبل التعديل)</h3>
</div>
<div style="padding:16px 20px;">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:6px;">
<?php foreach ($beforeData as $fk => $fv):
if ($fv === null || $fv === '') continue;
$isChanged = isset($changedFields[$fk]);
?>
<div style="display:flex;align-items:baseline;gap:8px;padding:6px 10px;background:<?= $isChanged ? '#FEF2F2' : '#F9FAFB' ?>;border:1px solid <?= $isChanged ? '#FECACA' : '#E5E7EB' ?>;border-radius:6px;">
<span style="color:#374151;font-weight:600;font-size:11px;min-width:110px;flex-shrink:0;"><?= e(AuditService::getFieldLabel($fk)) ?></span>
<span style="color:<?= $isChanged ? '#991B1B' : '#4B5563' ?>;font-size:12px;word-break:break-word;"><?= e(AuditService::formatFieldValue($fk, $fv)) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<?php elseif ($action === 'delete' && is_array($beforeData) && !empty($beforeData)): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:16px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#DC2626;font-size:15px;">البيانات المحذوفة</h3>
</div>
<div style="padding:16px 20px;">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:8px;">
<?php foreach ($beforeData as $fk => $fv):
if ($fv === null || $fv === '') continue;
?>
<div style="display:flex;align-items:baseline;gap:8px;padding:8px 12px;background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;">
<span style="color:#374151;font-weight:600;font-size:12px;min-width:120px;flex-shrink:0;"><?= e(AuditService::getFieldLabel($fk)) ?></span>
<span style="color:#991B1B;font-size:13px;word-break:break-word;"><?= e(AuditService::formatFieldValue($fk, $fv)) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php elseif ($action === 'archive' && is_array($beforeData) && !empty($beforeData)): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:16px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#D97706;font-size:15px;">البيانات المؤرشفة</h3>
</div>
<div style="padding:16px 20px;">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:8px;">
<?php foreach ($beforeData as $fk => $fv):
if ($fv === null || $fv === '') continue;
?>
<div style="display:flex;align-items:baseline;gap:8px;padding:8px 12px;background:#FFFBEB;border:1px solid #FDE68A;border-radius:6px;">
<span style="color:#374151;font-weight:600;font-size:12px;min-width:120px;flex-shrink:0;"><?= e(AuditService::getFieldLabel($fk)) ?></span>
<span style="color:#92400E;font-size:13px;word-break:break-word;"><?= e(AuditService::formatFieldValue($fk, $fv)) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
...@@ -52,7 +52,29 @@ $statusLabel = match($pr['status']) { ...@@ -52,7 +52,29 @@ $statusLabel = match($pr['status']) {
<td style="padding:8px 0;"><?= e($pr['description_ar']) ?></td> <td style="padding:8px 0;"><?= e($pr['description_ar']) ?></td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
<?php if ($pr['notes']): ?> <?php
$notesData = $pr['notes'] ? json_decode($pr['notes'], true) : null;
$feeBreakdown = $notesData['fee_breakdown'] ?? null;
?>
<?php if (is_array($feeBreakdown) && !empty($feeBreakdown)): ?>
<tr>
<td colspan="2" style="padding:12px 0;">
<div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:12px 16px;">
<div style="font-weight:700;color:#1A1A2E;margin-bottom:8px;font-size:13px;">تفاصيل المبلغ:</div>
<?php foreach ($feeBreakdown as $line): ?>
<?php if (str_contains($line, '═══')): ?>
<hr style="border:0;border-top:1px solid #D1D5DB;margin:6px 0;">
<?php elseif (str_starts_with($line, '💵')): ?>
<div style="font-weight:700;color:#059669;font-size:14px;margin-top:4px;"><?= e($line) ?></div>
<?php else: ?>
<div style="font-size:13px;color:#374151;padding:2px 0;"><?= e($line) ?></div>
<?php endif; ?>
<?php endforeach; ?>
</div>
</td>
</tr>
<?php endif; ?>
<?php if ($pr['notes'] && !is_array($notesData)): ?>
<tr> <tr>
<td style="padding:8px 0;color:#6B7280;">ملاحظات</td> <td style="padding:8px 0;color:#6B7280;">ملاحظات</td>
<td style="padding:8px 0;"><?= e($pr['notes']) ?></td> <td style="padding:8px 0;"><?= e($pr['notes']) ?></td>
......
...@@ -108,7 +108,28 @@ ...@@ -108,7 +108,28 @@
</td> </td>
<td style="font-weight:600;color:#D97706;"><?= e($r['form_number'] ?? '—') ?></td> <td style="font-weight:600;color:#D97706;"><?= e($r['form_number'] ?? '—') ?></td>
<td><?= $typeLabel ?></td> <td><?= $typeLabel ?></td>
<td style="direction:ltr;text-align:right;font-weight:700;font-size:15px;"><?= money($r['amount']) ?></td> <td style="direction:ltr;text-align:right;font-weight:700;font-size:15px;">
<?= money($r['amount']) ?>
<?php
$nd = $r['notes'] ? json_decode($r['notes'], true) : null;
$fb = $nd['fee_breakdown'] ?? null;
if (is_array($fb) && !empty($fb)):
$summary = [];
foreach ($fb as $ln) {
if (str_contains($ln, '═══') || str_starts_with($ln, '💵')) continue;
$summary[] = $ln;
}
?>
<div style="font-size:11px;font-weight:400;color:#6B7280;margin-top:2px;max-width:220px;line-height:1.4;">
<?php foreach (array_slice($summary, 0, 3) as $sl): ?>
<div><?= e($sl) ?></div>
<?php endforeach; ?>
<?php if (count($summary) > 3): ?>
<div style="color:#9CA3AF;">+<?= count($summary) - 3 ?> بنود أخرى</div>
<?php endif; ?>
</div>
<?php endif; ?>
</td>
<td><span style="color:<?= $statusColor ?>;font-weight:600;font-size:13px;"><?= $statusLabel ?></span></td> <td><span style="color:<?= $statusColor ?>;font-weight:600;font-size:13px;"><?= $statusLabel ?></span></td>
<td style="font-size:12px;<?= $urgent ? 'color:#DC2626;font-weight:700;' : 'color:#6B7280;' ?>"><?= $waitText ?></td> <td style="font-size:12px;<?= $urgent ? 'color:#DC2626;font-weight:700;' : 'color:#6B7280;' ?>"><?= $waitText ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e($r['requested_by_name'] ?? '—') ?></td> <td style="font-size:12px;color:#6B7280;"><?= e($r['requested_by_name'] ?? '—') ?></td>
......
...@@ -91,6 +91,30 @@ EventBus::listen('payment_request.completed', function (array $data) { ...@@ -91,6 +91,30 @@ EventBus::listen('payment_request.completed', function (array $data) {
} }
} }
// Activate all pending_payment dependants included in the lump sum
foreach (['spouses', 'children', 'temporary_members'] as $depTable) {
try {
$pendingDeps = $db->select(
"SELECT id FROM `{$depTable}` WHERE member_id = ? AND status = 'pending_payment' AND is_archived = 0",
[$memberId]
);
foreach ($pendingDeps as $dep) {
// Only activate if no separate pending payment request exists
$hasSeparateRequest = $db->selectOne(
"SELECT id FROM payment_requests WHERE member_id = ? AND payment_type = 'addition_fee' AND related_entity_type = ? AND related_entity_id = ? AND status IN ('pending','processing') AND is_voided = 0 LIMIT 1",
[$memberId, $depTable, (int) $dep['id']]
);
if (!$hasSeparateRequest) {
$db->update($depTable, [
'status' => 'active',
'join_date' => date('Y-m-d'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $dep['id']]);
}
}
} catch (\Throwable $e) {}
}
EventBus::dispatch('member.activated', ['member_id' => $memberId, 'membership_number' => $membershipNumber]); EventBus::dispatch('member.activated', ['member_id' => $memberId, 'membership_number' => $membershipNumber]);
\App\Core\Logger::info("Member activated via cashier", ['member_id' => $memberId, 'membership_number' => $membershipNumber]); \App\Core\Logger::info("Member activated via cashier", ['member_id' => $memberId, 'membership_number' => $membershipNumber]);
} }
......
...@@ -167,6 +167,7 @@ class ChildController extends Controller ...@@ -167,6 +167,7 @@ class ChildController extends Controller
'related_entity_type' => 'children', 'related_entity_type' => 'children',
'related_entity_id' => (int) $child->id, 'related_entity_id' => (int) $child->id,
'description_ar' => 'رسوم إضافة ' . $childLabel . ' — ' . trim($data['full_name_ar']), 'description_ar' => 'رسوم إضافة ' . $childLabel . ' — ' . trim($data['full_name_ar']),
'notes' => json_encode(['fee_breakdown' => $feeCalc['breakdown'] ?? []], JSON_UNESCAPED_UNICODE),
]); ]);
if ($payResult['success']) { if ($payResult['success']) {
......
...@@ -54,8 +54,36 @@ final class ChildFeeCalculator ...@@ -54,8 +54,36 @@ final class ChildFeeCalculator
} }
$childFee = $feeResult['fee'] ?? '0.00'; $childFee = $feeResult['fee'] ?? '0.00';
$percentage = $feeResult['percentage'] ?? '0.00';
$ruleApplied = $feeResult['rule_applied'] ?? '';
$formFee = FormFeeService::getFormFee($memberId, $member); $formFee = FormFeeService::getFormFee($memberId, $member);
$totalFee = bcadd($childFee, $formFee, 2);
$classLabels = [
'included' => 'مشمول (بدون رسوم)',
'dependent_with_fee' => 'تابع (برسوم)',
'temporary' => 'مؤقت',
];
$breakdown = [];
$breakdown[] = '📋 القاعدة المطبقة: ' . ($ruleApplied ?: 'تلقائي');
$breakdown[] = '💰 قيمة العضوية: ' . money($membershipValue);
$breakdown[] = '👶 الترتيب: #' . $childOrder . ' — السن: ' . $childAge . ' سنة';
$breakdown[] = '📌 التصنيف: ' . ($classLabels[$classification] ?? $classification);
if ($classification === 'included') {
$breakdown[] = '✅ مشمول في قيمة العضوية — بدون رسوم إضافية';
} else {
if (bccomp($childFee, '0', 2) > 0) {
$breakdown[] = '📊 نسبة ' . $percentage . '% × ' . money($membershipValue) . ' = ' . money($childFee);
}
if (bccomp($formFee, '0', 2) > 0) {
$breakdown[] = '📝 رسوم استمارة إضافة: ' . money($formFee);
}
}
$breakdown[] = '═══════════════════════════';
$breakdown[] = '💵 الإجمالي المطلوب: ' . money($totalFee);
return [ return [
'child_order' => $childOrder, 'child_order' => $childOrder,
...@@ -64,11 +92,12 @@ final class ChildFeeCalculator ...@@ -64,11 +92,12 @@ final class ChildFeeCalculator
'membership_value' => $membershipValue, 'membership_value' => $membershipValue,
'classification' => $classification, 'classification' => $classification,
'fee' => $childFee, 'fee' => $childFee,
'percentage' => $feeResult['percentage'] ?? '0.00', 'percentage' => $percentage,
'form_fee' => $formFee, 'form_fee' => $formFee,
'total_fee' => bcadd($childFee, $formFee, 2), 'total_fee' => $totalFee,
'rule_applied' => $feeResult['rule_applied'] ?? '', 'rule_applied' => $ruleApplied,
'error' => null, 'error' => null,
'breakdown' => $breakdown,
]; ];
} }
} }
...@@ -168,13 +168,23 @@ class MemberController extends Controller ...@@ -168,13 +168,23 @@ class MemberController extends Controller
$member = Member::find((int) $id); $member = Member::find((int) $id);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود'); if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
$formFeeAmount = MemberNumberGenerator::getFormFee();
$breakdown = [
'📋 رسوم استمارة عضوية جديدة',
'💰 رسوم الاستمارة: 500.00 ج.م',
'🏛️ طابع شهداء: 5.00 ج.م',
'═══════════════════════════',
'💵 الإجمالي: ' . money($formFeeAmount),
];
$result = PaymentRequestService::createRequest([ $result = PaymentRequestService::createRequest([
'member_id' => (int) $id, 'member_id' => (int) $id,
'amount' => MemberNumberGenerator::getFormFee(), 'amount' => $formFeeAmount,
'payment_type' => 'form_fee', 'payment_type' => 'form_fee',
'related_entity_type' => 'members', 'related_entity_type' => 'members',
'related_entity_id' => (int) $id, 'related_entity_id' => (int) $id,
'description_ar' => 'رسوم استمارة عضوية رقم ' . ($member->form_number ?? ''), 'description_ar' => 'رسوم استمارة عضوية رقم ' . ($member->form_number ?? ''),
'notes' => json_encode(['fee_breakdown' => $breakdown], JSON_UNESCAPED_UNICODE),
]); ]);
if (!$result['success']) return $this->redirect('/members/' . $id)->withError($result['error']); if (!$result['success']) return $this->redirect('/members/' . $id)->withError($result['error']);
return $this->redirect('/members/' . $id)->withSuccess('تم إرسال طلب الدفع للخزينة — رقم الطلب: ' . $result['request_number']); return $this->redirect('/members/' . $id)->withSuccess('تم إرسال طلب الدفع للخزينة — رقم الطلب: ' . $result['request_number']);
...@@ -191,7 +201,23 @@ class MemberController extends Controller ...@@ -191,7 +201,23 @@ class MemberController extends Controller
if (bccomp($amount, '0.01', 2) < 0) return $this->redirect('/members/' . $id)->withError('المبلغ غير صالح'); if (bccomp($amount, '0.01', 2) < 0) return $this->redirect('/members/' . $id)->withError('المبلغ غير صالح');
$months = ($paymentType === 'down_payment') ? min(30, max(1, (int) $request->post('installment_months', 30))) : null; $months = ($paymentType === 'down_payment') ? min(30, max(1, (int) $request->post('installment_months', 30))) : null;
$notes = $months ? json_encode(['installment_months' => $months], JSON_UNESCAPED_UNICODE) : null;
$bill = BillingService::getMemberBill((int) $id);
$breakdown = [];
foreach ($bill['items'] ?? [] as $item) {
if ($item['paid'] || $item['included']) continue;
$breakdown[] = ($item['category'] === 'discount' ? '🏷️ ' : '📌 ') . $item['label'] . ': ' . money($item['amount']);
}
$breakdown[] = '═══════════════════════════';
if ($paymentType === 'down_payment' && $months) {
$breakdown[] = '💵 مقدم التقسيط: ' . money($amount);
$breakdown[] = '📅 عدد الأشهر: ' . $months;
} else {
$breakdown[] = '💵 الإجمالي المطلوب: ' . money($amount);
}
$notesData = ['fee_breakdown' => $breakdown];
if ($months) $notesData['installment_months'] = $months;
$result = PaymentRequestService::createRequest([ $result = PaymentRequestService::createRequest([
'member_id' => (int) $id, 'member_id' => (int) $id,
...@@ -200,7 +226,7 @@ class MemberController extends Controller ...@@ -200,7 +226,7 @@ class MemberController extends Controller
'related_entity_type' => 'members', 'related_entity_type' => 'members',
'related_entity_id' => (int) $id, 'related_entity_id' => (int) $id,
'description_ar' => ($paymentType === 'down_payment' ? 'مقدم تقسيط' : 'قيمة العضوية') . ' — استمارة ' . ($member->form_number ?? ''), 'description_ar' => ($paymentType === 'down_payment' ? 'مقدم تقسيط' : 'قيمة العضوية') . ' — استمارة ' . ($member->form_number ?? ''),
'notes' => $notes, 'notes' => json_encode($notesData, JSON_UNESCAPED_UNICODE),
]); ]);
if (!$result['success']) return $this->redirect('/members/' . $id)->withError($result['error']); if (!$result['success']) return $this->redirect('/members/' . $id)->withError($result['error']);
......
...@@ -50,11 +50,12 @@ final class BillingService ...@@ -50,11 +50,12 @@ final class BillingService
$items[] = [ $items[] = [
'type' => 'form_fee', 'type' => 'form_fee',
'label' => 'رسوم استمارة عضوية (500 استمارة + 5 طابع شهداء)', 'label' => 'رسوم استمارة عضوية',
'amount' => $formFeeAmount, 'amount' => $formFeeAmount,
'paid' => $formFeePaid, 'paid' => $formFeePaid,
'included' => false, 'included' => false,
'category' => 'required', 'category' => 'required',
'breakdown' => ['رسوم الاستمارة: 500.00 ج.م', 'طابع شهداء: 5.00 ج.م'],
]; ];
// ── 2. Membership Value ── // ── 2. Membership Value ──
...@@ -132,15 +133,15 @@ final class BillingService ...@@ -132,15 +133,15 @@ final class BillingService
$isFirstFree = ($order === 1 && bccomp($fee, '0', 2) <= 0); $isFirstFree = ($order === 1 && bccomp($fee, '0', 2) <= 0);
$items[] = [ $items[] = [
'type' => 'spouse_fee', 'type' => 'spouse_fee',
'label' => 'زوجة #' . $order . ' — ' . $s['full_name_ar'], 'label' => 'زوجة #' . $order . ' — ' . $s['full_name_ar'],
'amount' => $isFirstFree ? '0.00' : $fee, 'amount' => $isFirstFree ? '0.00' : $fee,
'paid' => false, 'paid' => false,
'included' => $isFirstFree, 'included' => $isFirstFree,
'included_note' => $isFirstFree ? 'مشمولة في قيمة العضوية' : null, 'included_note' => $isFirstFree ? 'مشمولة في قيمة العضوية' : null,
'entity_type' => 'spouses', 'entity_type' => 'spouses',
'entity_id' => (int) $s['id'], 'entity_id' => (int) $s['id'],
'category' => 'addition', 'category' => 'addition',
]; ];
} }
...@@ -217,22 +218,34 @@ final class BillingService ...@@ -217,22 +218,34 @@ final class BillingService
} }
} catch (\Throwable $e) {} } catch (\Throwable $e) {}
// ── Check payment_requests for completed or pending additions ── // ── Check payment_requests for completed or pending additions + load breakdowns ──
try { try {
$additionRequests = $db->select( $additionRequests = $db->select(
"SELECT related_entity_type, related_entity_id, status FROM payment_requests "SELECT related_entity_type, related_entity_id, status, notes, payment_type FROM payment_requests
WHERE member_id = ? AND payment_type = 'addition_fee' AND is_voided = 0", WHERE member_id = ? AND is_voided = 0",
[$memberId] [$memberId]
); );
foreach ($additionRequests as $ar) { foreach ($additionRequests as $ar) {
foreach ($items as &$item) { foreach ($items as &$item) {
if (($item['entity_type'] ?? '') === $ar['related_entity_type'] $matchesEntity = ($item['entity_type'] ?? '') === ($ar['related_entity_type'] ?? '')
&& ($item['entity_id'] ?? 0) == $ar['related_entity_id']) { && ($item['entity_id'] ?? 0) == ($ar['related_entity_id'] ?? 0)
&& ($ar['payment_type'] === 'addition_fee');
$matchesType = !isset($item['entity_type'])
&& (($item['type'] === 'form_fee' && $ar['payment_type'] === 'form_fee')
|| ($item['type'] === 'membership_fee' && in_array($ar['payment_type'], ['membership_fee', 'down_payment'], true)));
if ($matchesEntity || $matchesType) {
if ($ar['status'] === 'completed') { if ($ar['status'] === 'completed') {
$item['paid'] = true; $item['paid'] = true;
} elseif (in_array($ar['status'], ['pending', 'processing'], true)) { } elseif (in_array($ar['status'], ['pending', 'processing'], true)) {
$item['in_queue'] = true; $item['in_queue'] = true;
} }
if (!empty($ar['notes'])) {
$nd = json_decode($ar['notes'], true);
if (is_array($nd) && !empty($nd['fee_breakdown'])) {
$item['breakdown'] = $nd['fee_breakdown'];
}
}
} }
} }
unset($item); unset($item);
......
...@@ -13,10 +13,8 @@ use App\Modules\ServiceCatalog\Models\ServicePrice; ...@@ -13,10 +13,8 @@ use App\Modules\ServiceCatalog\Models\ServicePrice;
* Business rule: * Business rule:
* - During initial membership creation (member status = potential / under_review), * - During initial membership creation (member status = potential / under_review),
* all additions are on the same initial form — no additional form fee. * all additions are on the same initial form — no additional form fee.
* - After activation, any modification needs a "form addition" (570 EGP). * - After activation (member has membership number), any addition/modification/removal
* - Multiple additions within the SAME form session share one form fee. * requires a 570 EGP form fee PER PERSON. No session sharing.
* A form session is "open" if a form fee was paid (or is pending) within
* the configured validity window (default 30 days).
*/ */
final class FormFeeService final class FormFeeService
{ {
...@@ -28,43 +26,10 @@ final class FormFeeService ...@@ -28,43 +26,10 @@ final class FormFeeService
return in_array($member['status'] ?? '', ['potential', 'under_review'], true); return in_array($member['status'] ?? '', ['potential', 'under_review'], true);
} }
/**
* Check if there's an active (open) addition form session for this member.
*
* An addition form is "open" if any addition_fee payment request was created
* within the configured validity window. The form fee is embedded in the first
* addition's total — subsequent additions within the same window share it.
*/
public static function hasOpenAdditionForm(int $memberId): bool
{
$db = App::getInstance()->db();
$validityDays = (int) (RuleEngine::getValue('ADDITION_FORM_VALIDITY_DAYS', 'value') ?? 30);
$cutoff = date('Y-m-d H:i:s', strtotime("-{$validityDays} days"));
$existing = $db->selectOne(
"SELECT id FROM payment_requests
WHERE member_id = ? AND payment_type = 'addition_fee' AND is_voided = 0
AND status IN ('pending', 'processing', 'completed')
AND created_at >= ?
LIMIT 1",
[$memberId, $cutoff]
);
if ($existing) return true;
$paid = $db->selectOne(
"SELECT id FROM payments
WHERE member_id = ? AND payment_type = 'addition_fee' AND is_voided = 0
AND created_at >= ?
LIMIT 1",
[$memberId, $cutoff]
);
return $paid !== null;
}
/** /**
* Get the form fee that should be charged for an addition. * Get the form fee that should be charged for an addition.
* Returns '0.00' if on initial form or if an addition form is already open. * Returns '0.00' if on initial form.
* Returns 570 EGP (or configured amount) per person after activation.
*/ */
public static function getFormFee(int $memberId, array $member): string public static function getFormFee(int $memberId, array $member): string
{ {
...@@ -72,10 +37,6 @@ final class FormFeeService ...@@ -72,10 +37,6 @@ final class FormFeeService
return '0.00'; return '0.00';
} }
if (self::hasOpenAdditionForm($memberId)) {
return '0.00';
}
$feeData = RuleEngine::get('FORM_ADDITION_FEE'); $feeData = RuleEngine::get('FORM_ADDITION_FEE');
return $feeData['amount'] ?? ServicePrice::getPrice('SVC_ADDITION_FORM', '570.00'); return $feeData['amount'] ?? ServicePrice::getPrice('SVC_ADDITION_FORM', '570.00');
} }
......
...@@ -112,13 +112,16 @@ $pendingAdditions ??= []; ...@@ -112,13 +112,16 @@ $pendingAdditions ??= [];
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($bill['items'] as $item): ?> <?php foreach ($bill['items'] as $bIdx => $item): ?>
<tr style="border-bottom:1px solid #F3F4F6;"> <tr style="border-bottom:1px solid #F3F4F6;<?= !empty($item['breakdown']) ? 'cursor:pointer;' : '' ?>" <?= !empty($item['breakdown']) ? 'onclick="toggleBillDetail(' . $bIdx . ')"' : '' ?>>
<td style="padding:10px;"> <td style="padding:10px;">
<?= e($item['label']) ?> <?= e($item['label']) ?>
<?php if (!empty($item['included_note'])): ?> <?php if (!empty($item['included_note'])): ?>
<small style="display:block;color:#059669;font-size:11px;"><?= e($item['included_note']) ?></small> <small style="display:block;color:#059669;font-size:11px;"><?= e($item['included_note']) ?></small>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($item['breakdown'])): ?>
<small style="display:block;color:#0D7377;font-size:11px;">&#x25BC; اضغط لعرض التفاصيل</small>
<?php endif; ?>
</td> </td>
<td style="padding:10px;text-align:left;font-weight:600;direction:ltr;<?= $item['included'] ? 'color:#9CA3AF;text-decoration:line-through;' : '' ?>"> <td style="padding:10px;text-align:left;font-weight:600;direction:ltr;<?= $item['included'] ? 'color:#9CA3AF;text-decoration:line-through;' : '' ?>">
<?= money($item['amount']) ?> <?= money($item['amount']) ?>
...@@ -135,6 +138,23 @@ $pendingAdditions ??= []; ...@@ -135,6 +138,23 @@ $pendingAdditions ??= [];
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
<?php if (!empty($item['breakdown'])): ?>
<tr id="bill-detail-<?= $bIdx ?>" style="display:none;">
<td colspan="3" style="padding:0 10px 10px;">
<div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:10px 14px;font-size:12px;">
<?php foreach ($item['breakdown'] as $line): ?>
<?php if (str_contains($line, '═══')): ?>
<hr style="border:0;border-top:1px solid #D1D5DB;margin:4px 0;">
<?php elseif (str_starts_with($line, '💵')): ?>
<div style="font-weight:700;color:#059669;font-size:13px;"><?= e($line) ?></div>
<?php else: ?>
<div style="color:#374151;padding:1px 0;"><?= e($line) ?></div>
<?php endif; ?>
<?php endforeach; ?>
</div>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
<tfoot> <tfoot>
...@@ -651,4 +671,12 @@ $categoryLabels = [ ...@@ -651,4 +671,12 @@ $categoryLabels = [
</div> </div>
</div> </div>
<script>
function toggleBillDetail(idx) {
var el = document.getElementById('bill-detail-' + idx);
if (!el) return;
el.style.display = el.style.display === 'none' ? '' : 'none';
}
</script>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
\ No newline at end of file
...@@ -171,6 +171,7 @@ class TemporaryController extends Controller ...@@ -171,6 +171,7 @@ class TemporaryController extends Controller
'related_entity_type' => 'temporary_members', 'related_entity_type' => 'temporary_members',
'related_entity_id' => (int) $temp->id, 'related_entity_id' => (int) $temp->id,
'description_ar' => 'رسوم إضافة عضو مؤقت (' . $catLabel . ') — ' . trim($data['full_name_ar']), 'description_ar' => 'رسوم إضافة عضو مؤقت (' . $catLabel . ') — ' . trim($data['full_name_ar']),
'notes' => json_encode(['fee_breakdown' => $feeCalc['breakdown'] ?? []], JSON_UNESCAPED_UNICODE),
]); ]);
if ($payResult['success']) { if ($payResult['success']) {
......
...@@ -45,16 +45,37 @@ final class TemporaryFeeCalculator ...@@ -45,16 +45,37 @@ final class TemporaryFeeCalculator
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2); $fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
$formFee = FormFeeService::getFormFee($memberId, $member); $formFee = FormFeeService::getFormFee($memberId, $member);
$totalFee = bcadd($fee, $formFee, 2);
$category = $tempData['category'] ?? '—';
$catLabels = [
'parent' => 'والد/ة', 'special_needs' => 'ذوي احتياجات خاصة',
'unmarried_daughter' => 'ابنة غير متزوجة', 'sister' => 'شقيقة',
'stepchild' => 'ابن/ة زوج', 'orphan' => 'يتيم',
'disabled_sibling' => 'شقيق معاق', 'nanny' => 'مربية',
];
$breakdown = [];
$breakdown[] = '📋 القاعدة المطبقة: TEMP_MEMBER_FEE';
$breakdown[] = '💰 قيمة العضوية: ' . money($membershipValue);
$breakdown[] = '📌 الفئة: ' . ($catLabels[$category] ?? $category);
$breakdown[] = '📊 نسبة ' . $pct . '% × ' . money($membershipValue) . ' = ' . money($fee);
if (bccomp($formFee, '0', 2) > 0) {
$breakdown[] = '📝 رسوم استمارة إضافة: ' . money($formFee);
}
$breakdown[] = '═══════════════════════════';
$breakdown[] = '💵 الإجمالي المطلوب: ' . money($totalFee);
return [ return [
'membership_value' => $membershipValue, 'membership_value' => $membershipValue,
'fee' => $fee, 'fee' => $fee,
'percentage' => $pct, 'percentage' => $pct,
'form_fee' => $formFee, 'form_fee' => $formFee,
'total_fee' => bcadd($fee, $formFee, 2), 'total_fee' => $totalFee,
'rule_applied' => 'TEMP_MEMBER_FEE', 'rule_applied' => 'TEMP_MEMBER_FEE',
'exempt' => false, 'exempt' => false,
'error' => null, 'error' => null,
'breakdown' => $breakdown,
]; ];
} }
......
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