Commit 509f2b81 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Enforce validation rules: gender, spouse classification, and post-activation lock

- Gender: strictly enforce male/female only in member, spouse, child, temporary
  creation (validated from NID or manual input)
- Spouse classification: under 21 = عضو تابع (dependent), 21+ = عضو عامل (working)
- Post-activation lock: after membership payment (membership_number assigned),
  all member/dependent data becomes non-editable except by super admin
  - Locks: create, edit, update, archive on spouses, children, temporaries
  - Locks: edit, fill-form, update on member itself
  - UI: hides add/edit buttons when locked, shows lock indicator
- Marriage rules already enforced: male→4 females max, female→1 male max
  (via Spouse::getMaxSpouses and gender cross-check from NID)
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent cd8ded02
......@@ -18,6 +18,23 @@ use App\Modules\Cashier\Services\PaymentRequestService;
class ChildController extends Controller
{
private static function isSuperAdmin(): bool
{
$employee = App::getInstance()->currentEmployee();
if (!$employee) return false;
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT 1 FROM employee_roles er JOIN roles r ON r.id = er.role_id WHERE er.employee_id = ? AND r.role_code = 'super_admin' AND er.is_active = 1 LIMIT 1",
[(int) $employee->id]
);
return $row !== null;
}
private static function isLocked(array $member): bool
{
return !empty($member['membership_number']);
}
public function create(Request $request, string $memberId): Response
{
$db = App::getInstance()->db();
......@@ -26,6 +43,10 @@ class ChildController extends Controller
return $this->redirect('/members')->withError('العضو غير موجود');
}
if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة أبناء بعد تفعيل العضوية — تواصل مع المشرف العام');
}
$countries = $db->select("SELECT id, nationality_ar FROM countries WHERE is_active = 1 ORDER BY nationality_ar");
return $this->view('Children.Views.create', [
......@@ -43,6 +64,10 @@ class ChildController extends Controller
return $this->redirect('/members')->withError('العضو غير موجود');
}
if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة أبناء بعد تفعيل العضوية — تواصل مع المشرف العام');
}
$data = $request->all();
unset($data['_csrf_token']);
......@@ -51,6 +76,7 @@ class ChildController extends Controller
if (empty(trim($data['full_name_ar'] ?? ''))) $errors[] = 'اسم الابن/الابنة مطلوب';
if (empty($data['date_of_birth'] ?? '')) $errors[] = 'تاريخ الميلاد مطلوب';
if (empty($data['gender'] ?? '')) $errors[] = 'النوع مطلوب';
elseif (!in_array($data['gender'], ['male', 'female'], true)) $errors[] = 'النوع يجب أن يكون ذكر أو أنثى';
if (empty($data['relationship'] ?? '')) $errors[] = 'نوع القرابة مطلوب';
// Parse NID if provided
......@@ -217,6 +243,10 @@ class ChildController extends Controller
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $memberId]);
if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن تعديل البيانات بعد تفعيل العضوية — تواصل مع المشرف العام');
}
return $this->view('Children.Views.edit', ['member' => $member, 'child' => $child]);
}
......@@ -227,6 +257,12 @@ class ChildController extends Controller
return $this->redirect("/members/{$memberId}")->withError('بيانات الابن غير موجودة');
}
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $memberId]);
if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن تعديل البيانات بعد تفعيل العضوية — تواصل مع المشرف العام');
}
$data = $request->all();
unset($data['_csrf_token']);
......@@ -252,13 +288,18 @@ class ChildController extends Controller
return $this->redirect("/members/{$memberId}")->withError('بيانات الابن غير موجودة');
}
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $memberId]);
if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن إزالة التابعين بعد تفعيل العضوية — تواصل مع المشرف العام');
}
$reason = trim((string) $request->post('reason', ''));
if ($reason === '') {
return $this->redirect("/members/{$memberId}")->withError('يجب إدخال سبب الإزالة');
}
$employee = App::getInstance()->currentEmployee();
$db = App::getInstance()->db();
$db->update('children', [
'is_archived' => 1,
......
......@@ -104,6 +104,9 @@ class MemberController extends Controller
if ($dob) { $age = age_from_dob($dob); $ageYears = $age['years']; $ageMonths = $age['months']; }
$idType = 'passport';
}
if ($gender !== null && !in_array($gender, ['male', 'female'], true)) {
$errors[] = 'النوع يجب أن يكون ذكر أو أنثى';
}
$workingMinAge = (int) (RuleEngine::getValue('WORKING_MEMBER_MIN_AGE', 'value') ?? 21);
if ($ageYears !== null && $ageYears < $workingMinAge) {
$errors[] = 'الحد الأدنى لسن العضوية العاملة ' . $workingMinAge . ' سنة (السن الحالي: ' . $ageYears . ')';
......@@ -213,6 +216,7 @@ class MemberController extends Controller
'pendingFormFee' => $pendingFormFee,
'pendingMembership' => $pendingMembership,
'pendingAdditions' => $pendingAdditions,
'isSuperAdmin' => self::isSuperAdmin(),
]);
}
......@@ -367,6 +371,9 @@ class MemberController extends Controller
$db = App::getInstance()->db();
$member = Member::find((int) $id);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
if (!empty($member->membership_number) && !self::isSuperAdmin()) {
return $this->redirect('/members/' . $id)->withError('لا يمكن تعديل الاستمارة بعد تفعيل العضوية — تواصل مع المشرف العام');
}
$formFeePaid = $db->selectOne("SELECT id FROM payments WHERE member_id = ? AND payment_type = 'form_fee' AND is_voided = 0 LIMIT 1", [(int) $id]);
if (!$formFeePaid) return $this->redirect('/members/' . $id)->withError('⚠ يجب دفع رسوم الاستمارة أولاً');
......@@ -386,6 +393,9 @@ class MemberController extends Controller
$db = App::getInstance()->db();
$member = Member::find((int) $id);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
if (!empty($member->membership_number) && !self::isSuperAdmin()) {
return $this->redirect('/members/' . $id)->withError('لا يمكن تعديل الاستمارة بعد تفعيل العضوية — تواصل مع المشرف العام');
}
$formFeePaid = $db->selectOne("SELECT id FROM payments WHERE member_id = ? AND payment_type = 'form_fee' AND is_voided = 0 LIMIT 1", [(int) $id]);
if (!$formFeePaid) return $this->redirect('/members/' . $id)->withError('⚠ يجب دفع رسوم الاستمارة أولاً');
......@@ -442,6 +452,11 @@ class MemberController extends Controller
$db = App::getInstance()->db();
$member = Member::find((int) $id);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
if (!empty($member->membership_number) && !self::isSuperAdmin()) {
return $this->redirect('/members/' . $id)->withError('لا يمكن تعديل بيانات العضو بعد تفعيل العضوية — تواصل مع المشرف العام');
}
return $this->view('Members.Views.edit', [
'member' => $member,
'branches' => $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1"),
......@@ -459,11 +474,15 @@ class MemberController extends Controller
$member = Member::find((int) $id);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
$isSuperAdmin = self::isSuperAdmin();
if (!empty($member->membership_number) && !$isSuperAdmin) {
return $this->redirect('/members/' . $id)->withError('لا يمكن تعديل بيانات العضو بعد تفعيل العضوية — تواصل مع المشرف العام');
}
$data = $request->all();
unset($data['_csrf_token']);
$isSuperAdmin = self::isSuperAdmin();
$allowed = ['full_name_en','phone_home','phone_mobile','phone_international','email','emergency_name','emergency_phone','residence_type','residence_address','landmark','floor','apartment','area','governorate','correspondence_address','employment_type','occupation','job_title','employment_date','business_address','office_phone','office_fax','business_activity','referral_source','religion','marital_status'];
$update = [];
......
......@@ -2,7 +2,9 @@
<?php $__template->section('title'); ?><?= e($member->full_name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<?php if (empty($member->membership_number) || ($isSuperAdmin ?? false)): ?>
<a href="/members/<?= (int) $member->id ?>/edit" class="btn btn-outline">✏️ تعديل</a>
<?php endif; ?>
<a href="/audit/entity/members/<?= (int) $member->id ?>" class="btn btn-outline">📜 سجل المراجعة</a>
<a href="/members" class="btn btn-outline">← العودة</a>
<?php $__template->endSection(); ?>
......@@ -15,6 +17,8 @@ $statusColor = $member->getStatusColor();
$isInitialPhase = in_array($member->status, ['potential', 'under_review', 'interview_scheduled', 'accepted', 'payment_pending']);
$isActive = ($member->status === 'active');
$pendingAdditions ??= [];
$isLocked = !empty($member->membership_number);
$canEdit = !$isLocked || ($isSuperAdmin ?? false);
?>
<!-- ═══════════════════════════════════════ -->
......@@ -484,7 +488,7 @@ $hasIncomplete = !empty(array_filter($missingSteps, fn($s) => !$s['done']));
<?php endif; ?>
<!-- Family Management — available once form fee is paid (during filling) or after -->
<?php if ($bill['form_fee_paid'] || $formFilled || $isActive): ?>
<?php if (($bill['form_fee_paid'] || $formFilled || $isActive) && $canEdit): ?>
<div style="margin-bottom:15px;">
<div style="font-size:12px;color:#6B7280;font-weight:600;margin-bottom:8px;text-transform:uppercase;">👨‍👩‍👧‍👦 الأسرة</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
......@@ -596,11 +600,15 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;background:linear-gradient(135deg, #F0FDF4, #ECFDF5);border-bottom:2px solid #A7F3D0;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;color:#065F46;font-size:16px;">&#x1f468;&#x200d;&#x1f469;&#x200d;&#x1f467;&#x200d;&#x1f466; الشجرة العائلية (<?= $totalDependants ?> تابع)</h3>
<?php if ($canEdit): ?>
<div style="display:flex;gap:8px;">
<a href="/members/<?= (int) $member->id ?>/spouses/create" class="btn btn-sm btn-outline">+ زوج/ة</a>
<a href="/members/<?= (int) $member->id ?>/children/create" class="btn btn-sm btn-outline">+ ابن/ة</a>
<a href="/members/<?= (int) $member->id ?>/temporary/create" class="btn btn-sm btn-outline">+ عضو مؤقت</a>
</div>
<?php elseif ($isLocked): ?>
<span style="font-size:12px;color:#6B7280;">🔒 البيانات مقفلة بعد تفعيل العضوية</span>
<?php endif; ?>
</div>
<?php if ($totalDependants === 0): ?>
......@@ -626,7 +634,8 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
<td><?= (int) $s['spouse_order'] ?></td>
<td style="font-weight:600;"><?= e($s['full_name_ar']) ?></td>
<td><?php
$sTypeLabel = (bccomp($sFee, '0', 2) <= 0 && (int) $s['spouse_order'] === 1) ? 'تابع مشمول' : 'تابع برسوم';
$sClassification = $s['classification'] ?? 'working';
$sTypeLabel = match($sClassification) { 'dependent' => 'عضو تابع', 'working' => 'عضو عامل', default => 'عضو عامل' };
?><span style="background:#E0F2FE;color:#0369A1;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;"><?= $sTypeLabel ?></span></td>
<td style="direction:ltr;text-align:right;font-size:12px;"><?= e($s['national_id'] ?? '—') ?></td>
<td style="font-size:12px;"><?= $s['join_date'] ? e($s['join_date']) : '<span style="color:#D97706;">لم يُحدد بعد</span>' ?></td>
......
......@@ -17,6 +17,23 @@ use App\Modules\Cashier\Services\PaymentRequestService;
class SpouseController extends Controller
{
private static function isSuperAdmin(): bool
{
$employee = App::getInstance()->currentEmployee();
if (!$employee) return false;
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT 1 FROM employee_roles er JOIN roles r ON r.id = er.role_id WHERE er.employee_id = ? AND r.role_code = 'super_admin' AND er.is_active = 1 LIMIT 1",
[(int) $employee->id]
);
return $row !== null;
}
private static function isLocked(array $member): bool
{
return !empty($member['membership_number']);
}
public function create(Request $request, string $memberId): Response
{
$db = App::getInstance()->db();
......@@ -25,6 +42,10 @@ class SpouseController extends Controller
return $this->redirect('/members')->withError('العضو غير موجود');
}
if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة زوج/ة بعد تفعيل العضوية — تواصل مع المشرف العام');
}
$currentCount = Spouse::countActiveForMember((int) $memberId);
$maxAllowed = Spouse::getMaxSpouses($member['gender']);
......@@ -57,6 +78,10 @@ class SpouseController extends Controller
return $this->redirect('/members')->withError('العضو غير موجود');
}
if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة زوج/ة بعد تفعيل العضوية — تواصل مع المشرف العام');
}
$data = $request->all();
unset($data['_csrf_token']);
......@@ -65,6 +90,7 @@ class SpouseController extends Controller
if (empty(trim($data['full_name_ar'] ?? ''))) $errors[] = 'اسم الزوج/الزوجة مطلوب';
if (empty($data['date_of_birth'] ?? '')) $errors[] = 'تاريخ الميلاد مطلوب';
if (empty($data['gender'] ?? '')) $errors[] = 'النوع مطلوب';
elseif (!in_array($data['gender'], ['male', 'female'], true)) $errors[] = 'النوع يجب أن يكون ذكر أو أنثى';
if (empty($data['marriage_date'] ?? '')) $errors[] = 'تاريخ الزواج مطلوب';
// ── Gender & count validation ──
......@@ -173,7 +199,7 @@ class SpouseController extends Controller
'mobile' => $data['mobile'] ?? null,
'marriage_date' => $data['marriage_date'],
'join_date' => date('Y-m-d'),
'classification' => 'working',
'classification' => ((int) ($data['age_years'] ?? 0) >= 21) ? 'working' : 'dependent',
'addition_fee' => $totalFee,
'fee_breakdown_json' => $breakdownJson,
'status' => $hasFee ? 'pending_payment' : 'active',
......@@ -234,6 +260,11 @@ class SpouseController extends Controller
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $memberId]);
if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن تعديل البيانات بعد تفعيل العضوية — تواصل مع المشرف العام');
}
$qualifications = $db->select("SELECT id, name_ar FROM qualifications WHERE is_active = 1 ORDER BY sort_order");
return $this->view('Spouses.Views.edit', [
......@@ -250,6 +281,12 @@ class SpouseController extends Controller
return $this->redirect("/members/{$memberId}")->withError('بيانات الزوج/الزوجة غير موجودة');
}
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $memberId]);
if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن تعديل البيانات بعد تفعيل العضوية — تواصل مع المشرف العام');
}
$data = $request->all();
unset($data['_csrf_token']);
......@@ -278,6 +315,12 @@ class SpouseController extends Controller
return $this->redirect("/members/{$memberId}")->withError('بيانات الزوج/الزوجة غير موجودة');
}
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $memberId]);
if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن إزالة التابعين بعد تفعيل العضوية — تواصل مع المشرف العام');
}
$reason = trim((string) $request->post('reason', ''));
if ($reason === '') {
return $this->redirect("/members/{$memberId}")->withError('يجب إدخال سبب الإزالة');
......
......@@ -87,9 +87,10 @@ class Spouse extends Model
public function getClassificationLabel(): string
{
return match ($this->classification) {
'working' => 'عامل',
'working' => 'عضو عامل',
'dependent' => 'عضو تابع',
'seasonal' => 'موسمي',
default => $this->classification ?? 'عامل',
default => $this->classification ?? 'عضو عامل',
};
}
......
......@@ -16,6 +16,23 @@ use App\Modules\Cashier\Services\PaymentRequestService;
class TemporaryController extends Controller
{
private static function isSuperAdmin(): bool
{
$employee = App::getInstance()->currentEmployee();
if (!$employee) return false;
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT 1 FROM employee_roles er JOIN roles r ON r.id = er.role_id WHERE er.employee_id = ? AND r.role_code = 'super_admin' AND er.is_active = 1 LIMIT 1",
[(int) $employee->id]
);
return $row !== null;
}
private static function isLocked(array $member): bool
{
return !empty($member['membership_number']);
}
public function index(Request $request): Response
{
$db = App::getInstance()->db();
......@@ -67,6 +84,10 @@ class TemporaryController extends Controller
return $this->redirect('/members')->withError('العضو غير موجود');
}
if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة أعضاء مؤقتين بعد تفعيل العضوية — تواصل مع المشرف العام');
}
return $this->view('Temporary.Views.create', [
'member' => $member,
'categories' => TemporaryMember::getCategories(),
......@@ -81,6 +102,10 @@ class TemporaryController extends Controller
return $this->redirect('/members')->withError('العضو غير موجود');
}
if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة أعضاء مؤقتين بعد تفعيل العضوية — تواصل مع المشرف العام');
}
$data = $request->all();
unset($data['_csrf_token']);
......@@ -89,6 +114,7 @@ class TemporaryController extends Controller
if (empty($data['category'] ?? '')) $errors[] = 'الفئة مطلوبة';
if (empty($data['date_of_birth'] ?? '')) $errors[] = 'تاريخ الميلاد مطلوب';
if (empty($data['gender'] ?? '')) $errors[] = 'النوع مطلوب';
elseif (!in_array($data['gender'], ['male', 'female'], true)) $errors[] = 'النوع يجب أن يكون ذكر أو أنثى';
$nid = trim($data['national_id'] ?? '');
if ($nid !== '') {
......@@ -210,13 +236,18 @@ class TemporaryController extends Controller
return $this->redirect("/members/{$memberId}")->withError('العضو المؤقت غير موجود');
}
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $memberId]);
if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن إزالة التابعين بعد تفعيل العضوية — تواصل مع المشرف العام');
}
$reason = trim((string) $request->post('reason', ''));
if ($reason === '') {
return $this->redirect("/members/{$memberId}")->withError('يجب إدخال سبب الإزالة');
}
$employee = App::getInstance()->currentEmployee();
$db = App::getInstance()->db();
$db->update('temporary_members', [
'is_archived' => 1,
......
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