Commit b7b56e1c authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(transfers): add qualification to separation create + NID conflict validation

- Add qualification dropdown to first screen of child separation — the
  fee is a percentage of new membership value which varies by qualification
- Pass selected qualification_code to SeparationFeeCalculator (was null)
- Add /api/members/check-nid endpoint for real-time NID conflict detection
- Add client-side NID conflict check on both form_fill and create pages
- Block submission when active member has same NID (show clear error)
- Warn (orange) when potential/inactive member exists (will auto-archive)
- Server-side validation: reject if active NID conflict in store/saveFillForm
- Validate qualification_code required for child_separation in store()
- Add phone format validation on form_fill submit
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 1e65e84e
......@@ -140,4 +140,38 @@ class MemberApiController extends Controller
$result = \App\Modules\Waiver\Services\WaiverProcessor::checkDebtsComprehensive((int) $id);
return $this->json($result);
}
public function checkNid(Request $request): Response
{
$nid = trim($request->post('national_id', ''));
if (strlen($nid) !== 14) {
return $this->json(['conflict' => false]);
}
$db = App::getInstance()->db();
$existing = $db->selectOne(
"SELECT id, full_name_ar, status FROM members WHERE national_id = ? AND is_archived = 0",
[$nid]
);
if (!$existing) {
return $this->json(['conflict' => false]);
}
$statusLabels = [
'active' => 'فعّال',
'potential' => 'محتمل',
'inactive' => 'غير فعّال',
'pending' => 'قيد الانتظار',
'suspended' => 'موقوف',
];
return $this->json([
'conflict' => true,
'member_id' => (int) $existing['id'],
'member_name' => $existing['full_name_ar'],
'status' => $existing['status'],
'status_ar' => $statusLabels[$existing['status']] ?? $existing['status'],
]);
}
}
\ No newline at end of file
......@@ -26,6 +26,7 @@ return [
['GET', '/members/{id}/changelog', 'Members\Controllers\MemberController@changelog', ['auth'], 'member.view'],
['GET', '/members/{id}/insurance-record', 'Members\Controllers\MemberController@insuranceRecord', ['auth'], 'member.view'],
['POST', '/api/members/parse-nid', 'Members\Controllers\MemberApiController@parseNid', ['auth'], 'member.create'],
['POST', '/api/members/check-nid', 'Members\Controllers\MemberApiController@checkNid', ['auth'], 'member.view'],
['GET', '/api/members/search', 'Members\Controllers\MemberApiController@searchGet', ['auth'], 'member.view'],
['POST', '/api/members/search', 'Members\Controllers\MemberApiController@search', ['auth'], 'member.view'],
['GET', '/api/members/{id}/debts', 'Members\Controllers\MemberApiController@debts', ['auth'], 'member.view'],
......
......@@ -44,11 +44,13 @@ class TransferController extends Controller
$children = $db->select("SELECT * FROM children WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [(int) $memberId]);
$spouses = $db->select("SELECT * FROM spouses WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [(int) $memberId]);
$qualifications = $db->select("SELECT id, code, name_ar FROM qualifications WHERE is_active = 1 ORDER BY sort_order");
return $this->view('Transfers.Views.create', [
'member' => $member,
'children' => $children,
'spouses' => $spouses,
'qualifications' => $qualifications,
]);
}
......@@ -172,7 +174,11 @@ class TransferController extends Controller
}
// Calculate fees
$qualCode = null;
$qualCode = trim($request->post('qualification_code', '')) ?: null;
if ($transferType === 'child_separation' && !$qualCode) {
return $this->redirect("/transfers/create/{$memberId}")->withError('يجب اختيار المؤهل لحساب رسوم الفصل');
}
if ($transferType === 'child_separation' && $child && $effectiveTransferDate) {
$feeCalc = SeparationFeeCalculator::calculateForChildSeparation(
......@@ -241,6 +247,15 @@ class TransferController extends Controller
$recipientData['date_of_birth'] = $parsed['dob'];
$recipientData['gender'] = $parsed['gender'];
}
$activeConflict = $db->selectOne(
"SELECT id, full_name_ar FROM members WHERE national_id = ? AND status = 'active' AND is_archived = 0",
[$rNid]
);
if ($activeConflict) {
return $this->redirect("/transfers/create/{$memberId}")->withError(
'يوجد عضو فعّال بنفس الرقم القومي للمستلم: ' . $activeConflict['full_name_ar'] . ' (#' . $activeConflict['id'] . ')'
);
}
}
}
......@@ -535,6 +550,18 @@ class TransferController extends Controller
}
}
if ($nid !== '' && strlen($nid) === 14) {
$activeConflict = $db->selectOne(
"SELECT id, full_name_ar FROM members WHERE national_id = ? AND status = 'active' AND is_archived = 0",
[$nid]
);
if ($activeConflict) {
return $this->redirect("/transfers/{$id}/fill-form")->withError(
'يوجد عضو فعّال بنفس الرقم القومي: ' . $activeConflict['full_name_ar'] . ' (#' . $activeConflict['id'] . ') — لا يمكن إتمام الفصل'
);
}
}
// Store form data in notes JSON
$existingNotes = !empty($transfer['notes']) ? json_decode($transfer['notes'], true) : [];
if (!is_array($existingNotes)) $existingNotes = [];
......
......@@ -51,9 +51,21 @@
<div style="padding:20px;display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<!-- Form Number (manual input) -->
<div class="form-group" style="grid-column:1/-1;margin-bottom:15px;">
<div class="form-group" style="margin-bottom:15px;">
<label class="form-label">رقم الاستمارة <span style="color:#DC2626;">*</span></label>
<input type="text" name="form_number" id="form_number" class="form-input" required placeholder="أدخل رقم الاستمارة" style="max-width:300px;">
<input type="text" name="form_number" id="form_number" class="form-input" required placeholder="أدخل رقم الاستمارة">
</div>
<!-- Qualification (determines new membership price → fee) -->
<div class="form-group" style="margin-bottom:15px;">
<label class="form-label">المؤهل <span style="color:#DC2626;">*</span></label>
<select name="qualification_code" id="qualification_code" class="form-select" required>
<option value="">-- اختر المؤهل --</option>
<?php foreach ($qualifications as $q): ?>
<option value="<?= e($q['code']) ?>"><?= e($q['name_ar']) ?></option>
<?php endforeach; ?>
</select>
<small style="color:#6B7280;font-size:11px;">المؤهل يحدد قيمة العضوية الجديدة التي تُحسب نسبة الفصل منها</small>
</div>
<!-- MALE-only fields -->
......@@ -507,7 +519,9 @@
var memberId = <?= (int) $member['id'] ?>;
var childId = childSelect.value || '';
var qualCode = document.getElementById('qualification_code') ? document.getElementById('qualification_code').value : '';
var body = 'member_id=' + memberId + '&child_id=' + childId + '&transfer_type=' + encodeURIComponent(type);
if (qualCode) body += '&qualification_code=' + encodeURIComponent(qualCode);
if (type === 'child_separation') {
var childGender = getSelectedChildGender() || 'male';
......@@ -585,6 +599,8 @@
}
transferType.addEventListener('change', updateUI);
var qualSelect = document.getElementById('qualification_code');
if (qualSelect) qualSelect.addEventListener('change', fetchFeePreview);
childSelect.addEventListener('change', function() {
date25Input.removeAttribute('data-user-override');
updateDate25FromChild();
......@@ -620,8 +636,33 @@
var rGenderHidden = document.getElementById('recipientGenderHidden');
var rStatus = document.getElementById('recipientNidStatus');
var recipientNidConflict = false;
function checkRecipientNidConflict(nid) {
var csrfToken = document.querySelector('input[name="_csrf_token"]');
fetch('/api/members/check-nid', {
method: 'POST',
headers: {'Content-Type':'application/x-www-form-urlencoded'},
body: 'national_id=' + encodeURIComponent(nid) + (csrfToken ? '&_csrf_token=' + csrfToken.value : '')
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.conflict && data.status === 'active') {
recipientNidConflict = true;
rStatus.style.color = '#DC2626';
rStatus.textContent = '⚠️ يوجد عضو فعّال بنفس الرقم: ' + (data.member_name || '') + ' — لا يمكن المتابعة';
} else if (data.conflict) {
recipientNidConflict = false;
rStatus.style.color = '#D97706';
rStatus.textContent += ' | عضو (' + (data.status_ar || '') + ') سيُؤرشف تلقائياً';
} else {
recipientNidConflict = false;
}
}).catch(function() { recipientNidConflict = false; });
}
function parseRecipientNid(val) {
if (val.length !== 14) { rStatus.style.display = 'none'; rDob.readOnly = false; rDob.style.background = ''; rGenderSelect.disabled = false; return; }
if (val.length !== 14) { rStatus.style.display = 'none'; rDob.readOnly = false; rDob.style.background = ''; rGenderSelect.disabled = false; recipientNidConflict = false; return; }
var formData = new FormData();
formData.append('national_id', val);
var csrfToken = document.querySelector('input[name="_csrf_token"]');
......@@ -642,7 +683,9 @@
rStatus.style.display = 'block';
rStatus.style.color = '#059669';
rStatus.textContent = (p.governorate_name_ar || '') + ' — ' + (p.age_years || '') + ' سنة';
checkRecipientNidConflict(val);
} else {
recipientNidConflict = false;
rStatus.style.display = 'block';
rStatus.style.color = '#DC2626';
rStatus.textContent = (p && p.errors) ? p.errors[0] : 'رقم قومي غير صحيح';
......@@ -652,6 +695,16 @@
rNid.addEventListener('input', function(){ parseRecipientNid(this.value.trim()); });
rGenderSelect.addEventListener('change', function(){ rGenderHidden.value = this.value; });
// Block form submission if active NID conflict on recipient
document.querySelector('form').addEventListener('submit', function(e) {
if (recipientNidConflict) {
e.preventDefault();
alert('لا يمكن المتابعة — يوجد عضو فعّال بنفس الرقم القومي للمستلم');
rNid.focus();
return false;
}
});
})();
</script>
<?php $__template->endSection(); ?>
\ No newline at end of file
......@@ -141,6 +141,7 @@ document.addEventListener('DOMContentLoaded', function() {
var dobInput = document.getElementById('dobInput');
var genderDisplay = document.getElementById('genderDisplay');
var genderHidden = document.getElementById('genderHidden');
var nidConflict = false;
function parseNid(v) {
if (v.length !== 14) return;
......@@ -160,7 +161,9 @@ document.addEventListener('DOMContentLoaded', function() {
nidStatus.style.display = 'block';
nidStatus.style.color = '#059669';
nidStatus.textContent = '✓ ' + (data.parsed.governorate_name_ar || '') + ' — ' + data.parsed.age_years + ' سنة';
checkNidConflict(v);
} else {
nidConflict = false;
nidStatus.style.display = 'block';
nidStatus.style.color = '#DC2626';
nidStatus.textContent = (data.parsed && data.parsed.errors) ? data.parsed.errors[0] : 'رقم قومي غير صالح';
......@@ -168,15 +171,67 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
function checkNidConflict(nid) {
var csrf = document.querySelector('input[name="_csrf_token"]');
fetch('/api/members/check-nid', {
method: 'POST',
headers: {'Content-Type':'application/x-www-form-urlencoded','X-Requested-With':'XMLHttpRequest'},
body: 'national_id=' + encodeURIComponent(nid) + (csrf ? '&_csrf_token=' + csrf.value : '')
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.conflict) {
nidConflict = true;
var msg = '⚠️ يوجد عضو بنفس الرقم القومي: ' + (data.member_name || '') + ' (' + (data.status_ar || data.status) + ')';
if (data.status === 'active') {
nidStatus.style.color = '#DC2626';
msg += ' — لا يمكن المتابعة';
} else {
nidStatus.style.color = '#D97706';
nidConflict = false;
msg += ' — سيتم أرشفته تلقائياً';
}
nidStatus.textContent = msg;
} else {
nidConflict = false;
}
}).catch(function() { nidConflict = false; });
}
if (nidInput) {
nidInput.addEventListener('input', function() {
var v = this.value.replace(/\D/g, '');
this.value = v;
if (v.length === 14) parseNid(v);
else nidStatus.style.display = 'none';
else { nidStatus.style.display = 'none'; nidConflict = false; }
});
if (nidInput.value.length === 14) parseNid(nidInput.value);
}
// Form validation before submit
var form = document.querySelector('form');
form.addEventListener('submit', function(e) {
var nid = nidInput.value.replace(/\D/g, '');
if (nid.length !== 14) {
e.preventDefault();
alert('الرقم القومي يجب أن يكون 14 رقم');
nidInput.focus();
return false;
}
if (nidConflict) {
e.preventDefault();
alert('لا يمكن المتابعة — يوجد عضو فعّال بنفس الرقم القومي');
nidInput.focus();
return false;
}
var phone = document.querySelector('[name="phone_mobile"]').value.trim();
if (phone && !/^01[0-9]{9}$/.test(phone)) {
e.preventDefault();
alert('رقم الهاتف المحمول غير صحيح — يجب أن يبدأ بـ 01 ويتكون من 11 رقم');
document.querySelector('[name="phone_mobile"]').focus();
return false;
}
});
});
</script>
<?php $__template->endSection(); ?>
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