Commit 71387a23 authored by Mahmoud Aglan's avatar Mahmoud Aglan

ASfsdg

parent 79079d7a
...@@ -70,6 +70,11 @@ final class CarnetPrintService ...@@ -70,6 +70,11 @@ final class CarnetPrintService
} }
} }
// Check profile image uploaded
if (empty($member['photo_path'])) {
$reasons[] = 'يجب رفع صورة شخصية قبل إصدار الكارنيه';
}
return $reasons; return $reasons;
} }
......
...@@ -87,9 +87,13 @@ EventBus::listen('payment_request.completed', function (array $data) { ...@@ -87,9 +87,13 @@ EventBus::listen('payment_request.completed', function (array $data) {
$memberId, $paymentId, $paymentType, $requestData $memberId, $paymentId, $paymentType, $requestData
); );
if ($result['success'] && empty($result['already_active'])) { if ($result['success'] && empty($result['already_active']) && empty($result['already_pending_cheques'])) {
$membershipNumber = $result['membership_number'] ?? null; $membershipNumber = $result['membership_number'] ?? null;
EventBus::dispatch('member.activated', ['member_id' => $memberId, 'membership_number' => $membershipNumber]); if ($paymentType === 'down_payment') {
EventBus::dispatch('member.pending_cheques', ['member_id' => $memberId]);
} else {
EventBus::dispatch('member.activated', ['member_id' => $memberId, 'membership_number' => $membershipNumber]);
}
} }
} }
......
...@@ -233,28 +233,30 @@ function addCertRow() { ...@@ -233,28 +233,30 @@ function addCertRow() {
function parseNid(val) { function parseNid(val) {
if (val.length !== 14) { status.style.display = 'none'; dob.readOnly = false; dob.style.background = ''; genderSelect.disabled = false; return; } if (val.length !== 14) { status.style.display = 'none'; dob.readOnly = false; dob.style.background = ''; genderSelect.disabled = false; return; }
fetch('/api/members/parse-nid', { var formData = new FormData();
method: 'POST', formData.append('national_id', val);
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest'}, var csrfToken = document.querySelector('input[name="_csrf_token"]');
body: JSON.stringify({national_id: val}) if (csrfToken) formData.append('_csrf_token', csrfToken.value);
})
fetch('/api/members/parse-nid', {method: 'POST', body: formData})
.then(function(r){ return r.json(); }) .then(function(r){ return r.json(); })
.then(function(d){ .then(function(d){
if (d.success && d.data) { var p = d.parsed;
dob.value = d.data.dob || ''; if (p && p.is_valid) {
dob.value = p.dob || '';
dob.readOnly = true; dob.readOnly = true;
dob.style.background = '#F9FAFB'; dob.style.background = '#F9FAFB';
var g = d.data.gender || ''; var g = p.gender || '';
genderSelect.value = g; genderSelect.value = g;
genderSelect.disabled = true; genderSelect.disabled = true;
genderHidden.value = g; genderHidden.value = g;
status.style.display = 'block'; status.style.display = 'block';
status.style.color = '#059669'; status.style.color = '#059669';
status.textContent = (d.data.governorate || '') + ' — ' + (d.data.age || '') + ' سنة'; status.textContent = (p.governorate_name_ar || '') + ' — ' + (p.age_years || '') + ' سنة';
} else { } else {
status.style.display = 'block'; status.style.display = 'block';
status.style.color = '#DC2626'; status.style.color = '#DC2626';
status.textContent = d.message || 'رقم قومي غير صحيح'; status.textContent = (p && p.errors) ? p.errors[0] : 'رقم قومي غير صحيح';
} }
}).catch(function(){}); }).catch(function(){});
} }
......
...@@ -53,7 +53,7 @@ class FineController extends Controller ...@@ -53,7 +53,7 @@ class FineController extends Controller
if ($penaltyType === 'fine') { if ($penaltyType === 'fine') {
$minData = RuleEngine::get('VIOLATION_FINE_MIN'); $minData = RuleEngine::get('VIOLATION_FINE_MIN');
$maxData = RuleEngine::get('VIOLATION_FINE_MAX'); $maxData = RuleEngine::get('VIOLATION_FINE_MAX');
$min = $minData['amount'] ?? '1000.00'; $min = $minData['amount'] ?? '200.00';
$max = $maxData['amount'] ?? '10000.00'; $max = $maxData['amount'] ?? '10000.00';
if (bccomp($amount, $min, 2) < 0 || bccomp($amount, $max, 2) > 0) { if (bccomp($amount, $min, 2) < 0 || bccomp($amount, $max, 2) > 0) {
$errors[] = "مبلغ الغرامة يجب أن يكون بين {$min} و {$max} ج.م"; $errors[] = "مبلغ الغرامة يجب أن يكون بين {$min} و {$max} ج.م";
......
...@@ -228,6 +228,7 @@ class MemberController extends Controller ...@@ -228,6 +228,7 @@ class MemberController extends Controller
'formFee' => MemberNumberGenerator::getFormFee(), 'formFee' => MemberNumberGenerator::getFormFee(),
'formFilled' => $formFilled, 'formFilled' => $formFilled,
'specialDiscount' => $specialDiscount, 'specialDiscount' => $specialDiscount,
'availableDiscounts' => SpecialDiscount::allActive(),
'pendingFormFee' => $pendingFormFee, 'pendingFormFee' => $pendingFormFee,
'pendingMembership' => $pendingMembership, 'pendingMembership' => $pendingMembership,
'pendingAdditions' => $pendingAdditions, 'pendingAdditions' => $pendingAdditions,
...@@ -267,6 +268,51 @@ class MemberController extends Controller ...@@ -267,6 +268,51 @@ class MemberController extends Controller
return $this->redirect('/members/' . $id)->withSuccess('تم إرسال طلب الدفع للخزينة — رقم الطلب: ' . $result['request_number']); return $this->redirect('/members/' . $id)->withSuccess('تم إرسال طلب الدفع للخزينة — رقم الطلب: ' . $result['request_number']);
} }
public function applyDiscount(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$member = Member::find((int) $id);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
$discountId = (int) $request->post('special_discount_id', 0);
if ($discountId <= 0) {
$db->update('members', [
'special_discount_id' => null,
'discount_amount' => null,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
return $this->redirect("/members/{$id}")->withSuccess('تم إزالة الخصم');
}
$discount = $db->selectOne("SELECT * FROM special_discounts WHERE id = ? AND is_active = 1", [$discountId]);
if (!$discount) {
return $this->redirect("/members/{$id}")->withError('الخصم غير موجود');
}
$membershipValue = $member->membership_value ?? '0.00';
$discountAmount = bcdiv(bcmul($membershipValue, $discount['discount_percentage'], 4), '100', 2);
$updateData = [
'special_discount_id' => $discountId,
'discount_amount' => $discountAmount,
'updated_at' => date('Y-m-d H:i:s'),
];
if ($discount['requires_document'] && !empty($_FILES['discount_document']['tmp_name'])) {
$file = $_FILES['discount_document'];
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$storedName = 'disc_' . $id . '_' . date('Ymd_His') . '.' . $ext;
$destDir = __DIR__ . '/../../../../storage/uploads/discounts';
if (!is_dir($destDir)) mkdir($destDir, 0755, true);
move_uploaded_file($file['tmp_name'], $destDir . '/' . $storedName);
$updateData['special_discount_document'] = 'storage/uploads/discounts/' . $storedName;
}
$db->update('members', $updateData, '`id` = ?', [(int) $id]);
return $this->redirect("/members/{$id}")->withSuccess('تم تطبيق الخصم: ' . $discount['name_ar'] . ' (' . $discount['discount_percentage'] . '%)');
}
public function payMembership(Request $request, string $id): Response public function payMembership(Request $request, string $id): Response
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
......
...@@ -12,6 +12,7 @@ return [ ...@@ -12,6 +12,7 @@ return [
['POST', '/members/{id}/status', 'Members\Controllers\MemberController@changeStatus', ['auth', 'csrf'], 'member.change_status'], ['POST', '/members/{id}/status', 'Members\Controllers\MemberController@changeStatus', ['auth', 'csrf'], 'member.change_status'],
['POST', '/members/{id}/pay-form-fee', 'Members\Controllers\MemberController@payFormFee', ['auth', 'csrf'], 'member.pay_form_fee'], ['POST', '/members/{id}/pay-form-fee', 'Members\Controllers\MemberController@payFormFee', ['auth', 'csrf'], 'member.pay_form_fee'],
['POST', '/members/{id}/pay-membership', 'Members\Controllers\MemberController@payMembership',['auth', 'csrf'], 'member.pay_membership'], ['POST', '/members/{id}/pay-membership', 'Members\Controllers\MemberController@payMembership',['auth', 'csrf'], 'member.pay_membership'],
['POST', '/members/{id}/apply-discount','Members\Controllers\MemberController@applyDiscount', ['auth', 'csrf'], 'member.edit'],
['POST', '/members/{id}/pay-addition', 'Members\Controllers\MemberController@payAdditionFee', ['auth', 'csrf'], 'member.pay_membership'], ['POST', '/members/{id}/pay-addition', 'Members\Controllers\MemberController@payAdditionFee', ['auth', 'csrf'], 'member.pay_membership'],
['GET', '/members/{id}/fill-form', 'Members\Controllers\MemberController@fillForm', ['auth'], 'member.fill_form'], ['GET', '/members/{id}/fill-form', 'Members\Controllers\MemberController@fillForm', ['auth'], 'member.fill_form'],
['POST', '/members/{id}/fill-form', 'Members\Controllers\MemberController@saveFillForm', ['auth', 'csrf'], 'member.fill_form'], ['POST', '/members/{id}/fill-form', 'Members\Controllers\MemberController@saveFillForm', ['auth', 'csrf'], 'member.fill_form'],
......
...@@ -236,6 +236,40 @@ $canEdit = can('member.edit') && (!$isLocked || ($isSuperAdmin ?? false)); ...@@ -236,6 +236,40 @@ $canEdit = can('member.edit') && (!$isLocked || ($isSuperAdmin ?? false));
<a href="/members/<?= (int) $member->id ?>/temporary/create" class="btn btn-sm btn-outline">👤 عضو مؤقت</a> <a href="/members/<?= (int) $member->id ?>/temporary/create" class="btn btn-sm btn-outline">👤 عضو مؤقت</a>
</div> </div>
<!-- Special Discount Section -->
<?php if (!empty($availableDiscounts) && in_array($member->status, ['accepted', 'payment_pending']) && empty($pendingMembership)): ?>
<div style="padding:15px 20px;border-top:1px solid #E5E7EB;">
<?php if (!empty($specialDiscount)): ?>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<span style="color:#059669;font-weight:600;font-size:13px;">&#x2705; خصم مُطبق: <?= e($specialDiscount['name_ar']) ?> (<?= e($specialDiscount['discount_percentage']) ?>%) = -<?= money($member->discount_amount ?? '0') ?></span>
<form method="POST" action="/members/<?= (int) $member->id ?>/apply-discount" style="display:inline;">
<?= csrf_field() ?>
<input type="hidden" name="special_discount_id" value="0">
<button type="submit" class="btn btn-sm btn-outline" style="color:#DC2626;border-color:#DC2626;" onclick="return confirm('إزالة الخصم؟')">&#x274c; إزالة</button>
</form>
</div>
<?php else: ?>
<form method="POST" action="/members/<?= (int) $member->id ?>/apply-discount" enctype="multipart/form-data" style="display:flex;align-items:end;gap:10px;flex-wrap:wrap;">
<?= csrf_field() ?>
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">&#x1f3f7;&#xfe0f; خصم خاص</label>
<select name="special_discount_id" class="form-select" style="min-width:200px;" id="discountSelect" onchange="document.getElementById('discountDoc').style.display = this.selectedOptions[0].dataset.doc === '1' ? 'block' : 'none'">
<option value="">-- اختر خصم --</option>
<?php foreach ($availableDiscounts as $disc): ?>
<option value="<?= (int) $disc['id'] ?>" data-doc="<?= $disc['requires_document'] ? '1' : '0' ?>"><?= e($disc['name_ar']) ?> (<?= e($disc['discount_percentage']) ?>%)</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="margin:0;display:none;" id="discountDoc">
<label class="form-label" style="font-size:12px;">مستند الإثبات</label>
<input type="file" name="discount_document" class="form-input" accept="image/*,.pdf">
</div>
<button type="submit" class="btn btn-sm btn-primary">تطبيق الخصم</button>
</form>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Payment Section --> <!-- Payment Section -->
<?php if (bccomp($bill['total_pending'], '0', 2) > 0 && in_array($member->status, ['accepted', 'payment_pending'])): ?> <?php if (bccomp($bill['total_pending'], '0', 2) > 0 && in_array($member->status, ['accepted', 'payment_pending'])): ?>
<?php if (!empty($pendingMembership)): ?> <?php if (!empty($pendingMembership)): ?>
......
...@@ -23,19 +23,54 @@ final class PaymentLifecycleService ...@@ -23,19 +23,54 @@ final class PaymentLifecycleService
*/ */
public static function onMembershipPaymentCompleted(int $memberId, int $paymentId, string $paymentType, array $requestData = []): array public static function onMembershipPaymentCompleted(int $memberId, int $paymentId, string $paymentType, array $requestData = []): array
{ {
if ($paymentType === 'down_payment') {
$result = self::transitionToPendingCheques($memberId, $paymentId);
if ($result['success']) {
self::createInstallmentPlan($memberId, $paymentId, $requestData);
}
return $result;
}
$result = MembershipPaymentGuard::activateMember($memberId, $paymentId); $result = MembershipPaymentGuard::activateMember($memberId, $paymentId);
// Only auto-activate dependents for full cash payment. if ($result['success']) {
// Installment members must pay addition_fee separately for each dependent.
if ($result['success'] && $paymentType !== 'down_payment') {
MembershipPaymentGuard::activateIncludedDependents($memberId, $paymentId); MembershipPaymentGuard::activateIncludedDependents($memberId, $paymentId);
} }
if ($result['success'] && $paymentType === 'down_payment') { return $result;
self::createInstallmentPlan($memberId, $paymentId, $requestData); }
/**
* Transition member to pending_cheques after installment down-payment.
* Member stays in this state until all cheques are submitted via ChequeService.
*/
private static function transitionToPendingCheques(int $memberId, int $paymentId): array
{
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [$memberId]);
if (!$member) {
return ['success' => false, 'error' => 'العضو غير موجود'];
}
$validStatuses = ['potential', 'payment_pending', 'accepted', 'under_review'];
if (!in_array($member['status'], $validStatuses, true)) {
if ($member['status'] === 'pending_cheques') {
return ['success' => true, 'already_pending_cheques' => true];
}
if ($member['status'] === 'active') {
return ['success' => true, 'already_active' => true];
}
return ['success' => false, 'error' => 'حالة العضو لا تسمح بالانتقال: ' . $member['status']];
} }
return $result; $db->update('members', [
'status' => 'pending_cheques',
'activated_by_payment_id'=> $paymentId,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$memberId]);
Logger::info("PaymentLifecycleService: member #{$memberId} transitioned to pending_cheques after down-payment #{$paymentId}");
return ['success' => true];
} }
/** /**
......
...@@ -219,28 +219,30 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -219,28 +219,30 @@ document.addEventListener('DOMContentLoaded', function() {
function parseNid(val) { function parseNid(val) {
if (val.length !== 14) { nidStatus.style.display = 'none'; dob.readOnly = false; dob.style.background = ''; genderSelect.disabled = false; return; } if (val.length !== 14) { nidStatus.style.display = 'none'; dob.readOnly = false; dob.style.background = ''; genderSelect.disabled = false; return; }
fetch('/api/members/parse-nid', { var formData = new FormData();
method: 'POST', formData.append('national_id', val);
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest'}, var csrfToken = document.querySelector('input[name="_csrf_token"]');
body: JSON.stringify({national_id: val}) if (csrfToken) formData.append('_csrf_token', csrfToken.value);
})
fetch('/api/members/parse-nid', {method: 'POST', body: formData})
.then(function(r){ return r.json(); }) .then(function(r){ return r.json(); })
.then(function(d){ .then(function(d){
if (d.success && d.data) { var p = d.parsed;
dob.value = d.data.dob || ''; if (p && p.is_valid) {
dob.value = p.dob || '';
dob.readOnly = true; dob.readOnly = true;
dob.style.background = '#F9FAFB'; dob.style.background = '#F9FAFB';
var g = d.data.gender || ''; var g = p.gender || '';
genderSelect.value = g; genderSelect.value = g;
genderSelect.disabled = true; genderSelect.disabled = true;
genderHidden.value = g; genderHidden.value = g;
nidStatus.style.display = 'block'; nidStatus.style.display = 'block';
nidStatus.style.color = '#059669'; nidStatus.style.color = '#059669';
nidStatus.textContent = (d.data.governorate || '') + ' — ' + (d.data.age || '') + ' سنة'; nidStatus.textContent = (p.governorate_name_ar || '') + ' — ' + (p.age_years || '') + ' سنة';
} else { } else {
nidStatus.style.display = 'block'; nidStatus.style.display = 'block';
nidStatus.style.color = '#DC2626'; nidStatus.style.color = '#DC2626';
nidStatus.textContent = d.message || 'رقم قومي غير صحيح'; nidStatus.textContent = (p && p.errors) ? p.errors[0] : 'رقم قومي غير صحيح';
} }
}).catch(function(){}); }).catch(function(){});
} }
......
...@@ -251,28 +251,30 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -251,28 +251,30 @@ document.addEventListener('DOMContentLoaded', function() {
function parseNid(val) { function parseNid(val) {
if (val.length !== 14) { nidStatus.style.display = 'none'; dob.readOnly = false; dob.style.background = ''; genderSelect.disabled = false; return; } if (val.length !== 14) { nidStatus.style.display = 'none'; dob.readOnly = false; dob.style.background = ''; genderSelect.disabled = false; return; }
fetch('/api/members/parse-nid', { var formData = new FormData();
method: 'POST', formData.append('national_id', val);
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest'}, var csrfToken = document.querySelector('input[name="_csrf_token"]');
body: JSON.stringify({national_id: val}) if (csrfToken) formData.append('_csrf_token', csrfToken.value);
})
fetch('/api/members/parse-nid', {method: 'POST', body: formData})
.then(function(r){ return r.json(); }) .then(function(r){ return r.json(); })
.then(function(d){ .then(function(d){
if (d.success && d.data) { var p = d.parsed;
dob.value = d.data.dob || ''; if (p && p.is_valid) {
dob.value = p.dob || '';
dob.readOnly = true; dob.readOnly = true;
dob.style.background = '#F9FAFB'; dob.style.background = '#F9FAFB';
var g = d.data.gender || ''; var g = p.gender || '';
genderSelect.value = g; genderSelect.value = g;
genderSelect.disabled = true; genderSelect.disabled = true;
genderHidden.value = g; genderHidden.value = g;
nidStatus.style.display = 'block'; nidStatus.style.display = 'block';
nidStatus.style.color = '#059669'; nidStatus.style.color = '#059669';
nidStatus.textContent = (d.data.governorate || '') + ' — ' + (d.data.age || '') + ' سنة'; nidStatus.textContent = (p.governorate_name_ar || '') + ' — ' + (p.age_years || '') + ' سنة';
} else { } else {
nidStatus.style.display = 'block'; nidStatus.style.display = 'block';
nidStatus.style.color = '#DC2626'; nidStatus.style.color = '#DC2626';
nidStatus.textContent = d.message || 'رقم قومي غير صحيح'; nidStatus.textContent = (p && p.errors) ? p.errors[0] : 'رقم قومي غير صحيح';
} }
}).catch(function(){}); }).catch(function(){});
} }
......
...@@ -140,28 +140,30 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -140,28 +140,30 @@ document.addEventListener('DOMContentLoaded', function() {
function parseNid(val) { function parseNid(val) {
if (val.length !== 14) { status.style.display = 'none'; dob.readOnly = false; dob.style.background = ''; genderSelect.disabled = false; return; } if (val.length !== 14) { status.style.display = 'none'; dob.readOnly = false; dob.style.background = ''; genderSelect.disabled = false; return; }
fetch('/api/members/parse-nid', { var formData = new FormData();
method: 'POST', formData.append('national_id', val);
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest'}, var csrfToken = document.querySelector('input[name="_csrf_token"]');
body: JSON.stringify({national_id: val}) if (csrfToken) formData.append('_csrf_token', csrfToken.value);
})
fetch('/api/members/parse-nid', {method: 'POST', body: formData})
.then(function(r){ return r.json(); }) .then(function(r){ return r.json(); })
.then(function(d){ .then(function(d){
if (d.success && d.data) { var p = d.parsed;
dob.value = d.data.dob || ''; if (p && p.is_valid) {
dob.value = p.dob || '';
dob.readOnly = true; dob.readOnly = true;
dob.style.background = '#F9FAFB'; dob.style.background = '#F9FAFB';
var g = d.data.gender || ''; var g = p.gender || '';
genderSelect.value = g; genderSelect.value = g;
genderSelect.disabled = true; genderSelect.disabled = true;
genderHidden.value = g; genderHidden.value = g;
status.style.display = 'block'; status.style.display = 'block';
status.style.color = '#059669'; status.style.color = '#059669';
status.textContent = (d.data.governorate || '') + ' — ' + (d.data.age || '') + ' سنة'; status.textContent = (p.governorate_name_ar || '') + ' — ' + (p.age_years || '') + ' سنة';
} else { } else {
status.style.display = 'block'; status.style.display = 'block';
status.style.color = '#DC2626'; status.style.color = '#DC2626';
status.textContent = d.message || 'رقم قومي غير صحيح'; status.textContent = (p && p.errors) ? p.errors[0] : 'رقم قومي غير صحيح';
} }
}).catch(function(){}); }).catch(function(){});
} }
......
...@@ -88,28 +88,30 @@ ...@@ -88,28 +88,30 @@
function parseNid(val) { function parseNid(val) {
if (val.length !== 14) { status.style.display = 'none'; dob.readOnly = false; dob.style.background = ''; genderSelect.disabled = false; return; } if (val.length !== 14) { status.style.display = 'none'; dob.readOnly = false; dob.style.background = ''; genderSelect.disabled = false; return; }
fetch('/api/members/parse-nid', { var formData = new FormData();
method: 'POST', formData.append('national_id', val);
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest'}, var csrfToken = document.querySelector('input[name="_csrf_token"]');
body: JSON.stringify({national_id: val}) if (csrfToken) formData.append('_csrf_token', csrfToken.value);
})
fetch('/api/members/parse-nid', {method: 'POST', body: formData})
.then(function(r){ return r.json(); }) .then(function(r){ return r.json(); })
.then(function(d){ .then(function(d){
if (d.success && d.data) { var p = d.parsed;
dob.value = d.data.dob || ''; if (p && p.is_valid) {
dob.value = p.dob || '';
dob.readOnly = true; dob.readOnly = true;
dob.style.background = '#F9FAFB'; dob.style.background = '#F9FAFB';
var g = d.data.gender || ''; var g = p.gender || '';
genderSelect.value = g; genderSelect.value = g;
genderSelect.disabled = true; genderSelect.disabled = true;
genderHidden.value = g; genderHidden.value = g;
status.style.display = 'block'; status.style.display = 'block';
status.style.color = '#059669'; status.style.color = '#059669';
status.textContent = (d.data.governorate || '') + ' — ' + (d.data.age || '') + ' سنة'; status.textContent = (p.governorate_name_ar || '') + ' — ' + (p.age_years || '') + ' سنة';
} else { } else {
status.style.display = 'block'; status.style.display = 'block';
status.style.color = '#DC2626'; status.style.color = '#DC2626';
status.textContent = d.message || 'رقم قومي غير صحيح'; status.textContent = (p && p.errors) ? p.errors[0] : 'رقم قومي غير صحيح';
} }
}).catch(function(){}); }).catch(function(){});
} }
......
...@@ -248,8 +248,8 @@ class TransferController extends Controller ...@@ -248,8 +248,8 @@ class TransferController extends Controller
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$transfer = $db->selectOne("SELECT * FROM transfer_requests WHERE id = ?", [(int) $id]); $transfer = $db->selectOne("SELECT * FROM transfer_requests WHERE id = ?", [(int) $id]);
if (!$transfer) return $this->redirect('/transfers')->withError('الطلب غير موجود'); if (!$transfer) return $this->redirect('/transfers')->withError('الطلب غير موجود');
if (in_array($transfer['status'], ['completed', 'fee_paid', 'rejected'])) { if ($transfer['status'] !== 'approved') {
return $this->redirect("/transfers/{$id}")->withError('لا يمكن الدفع لهذا الطلب'); return $this->redirect("/transfers/{$id}")->withError('يجب اعتماد الطلب أولاً قبل الدفع');
} }
$amount = $transfer['total_fee'] ?? '0.00'; $amount = $transfer['total_fee'] ?? '0.00';
......
...@@ -96,6 +96,15 @@ final class TransferProcessor ...@@ -96,6 +96,15 @@ final class TransferProcessor
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $sourceMember['id']]); ], '`id` = ?', [(int) $sourceMember['id']]);
// Verify number is actually released (no other member holds it)
$numberConflict = $db->selectOne(
"SELECT id FROM members WHERE membership_number = ? AND id != ?",
[$sameNumber, (int) $sourceMember['id']]
);
if ($numberConflict) {
$sameNumber = MemberNumberGenerator::generateNext();
}
// 4. Create new member record with SAME membership number // 4. Create new member record with SAME membership number
$newMemberData = [ $newMemberData = [
'full_name_ar' => $recipientData['full_name_ar'] ?? $subjectData['full_name_ar'] ?? $subjectName, 'full_name_ar' => $recipientData['full_name_ar'] ?? $subjectData['full_name_ar'] ?? $subjectName,
......
...@@ -264,28 +264,30 @@ ...@@ -264,28 +264,30 @@
function parseRecipientNid(val) { 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; return; }
fetch('/api/members/parse-nid', { var formData = new FormData();
method: 'POST', formData.append('national_id', val);
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest'}, var csrfToken = document.querySelector('input[name="_csrf_token"]');
body: JSON.stringify({national_id: val}) if (csrfToken) formData.append('_csrf_token', csrfToken.value);
})
fetch('/api/members/parse-nid', {method: 'POST', body: formData})
.then(function(r){ return r.json(); }) .then(function(r){ return r.json(); })
.then(function(d){ .then(function(d){
if (d.success && d.data) { var p = d.parsed;
rDob.value = d.data.dob || ''; if (p && p.is_valid) {
rDob.value = p.dob || '';
rDob.readOnly = true; rDob.readOnly = true;
rDob.style.background = '#F9FAFB'; rDob.style.background = '#F9FAFB';
var g = d.data.gender || ''; var g = p.gender || '';
rGenderSelect.value = g; rGenderSelect.value = g;
rGenderSelect.disabled = true; rGenderSelect.disabled = true;
rGenderHidden.value = g; rGenderHidden.value = g;
rStatus.style.display = 'block'; rStatus.style.display = 'block';
rStatus.style.color = '#059669'; rStatus.style.color = '#059669';
rStatus.textContent = (d.data.governorate || '') + ' — ' + (d.data.age || '') + ' سنة'; rStatus.textContent = (p.governorate_name_ar || '') + ' — ' + (p.age_years || '') + ' سنة';
} else { } else {
rStatus.style.display = 'block'; rStatus.style.display = 'block';
rStatus.style.color = '#DC2626'; rStatus.style.color = '#DC2626';
rStatus.textContent = d.message || 'رقم قومي غير صحيح'; rStatus.textContent = (p && p.errors) ? p.errors[0] : 'رقم قومي غير صحيح';
} }
}).catch(function(){}); }).catch(function(){});
} }
......
...@@ -99,7 +99,25 @@ if ($transfer['transfer_type'] === 'full_transfer' && !empty($transfer['notes']) ...@@ -99,7 +99,25 @@ if ($transfer['transfer_type'] === 'full_transfer' && !empty($transfer['notes'])
</div> </div>
</div> </div>
<?php if (in_array($transfer['status'], ['requested', 'approved']) && bccomp($transfer['total_fee'] ?? '0', '0', 2) > 0): ?> <?php if ($transfer['status'] === 'requested'): ?>
<?php if (can('transfer.approve')): ?>
<div class="card" style="padding:20px;margin-bottom:20px;background:#EFF6FF;border:2px solid #3B82F6;">
<h4 style="margin:0 0 15px;color:#1D4ED8;">اعتماد الطلب</h4>
<div style="display:flex;gap:10px;">
<form method="POST" action="/transfers/<?= (int) $transfer['id'] ?>/approve">
<?= csrf_field() ?>
<button type="submit" class="btn btn-primary" onclick="return confirm('اعتماد طلب التحويل؟')">✅ اعتماد</button>
</form>
<form method="POST" action="/transfers/<?= (int) $transfer['id'] ?>/reject">
<?= csrf_field() ?>
<button type="submit" class="btn btn-danger" onclick="return confirm('رفض طلب التحويل؟')">❌ رفض</button>
</form>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
<?php if ($transfer['status'] === 'approved' && bccomp($transfer['total_fee'] ?? '0', '0', 2) > 0): ?>
<?php if (can('transfer.approve')): ?> <?php if (can('transfer.approve')): ?>
<div class="card" style="padding:20px;margin-bottom:20px;background:#FFF7ED;border:2px solid #F59E0B;"> <div class="card" style="padding:20px;margin-bottom:20px;background:#FFF7ED;border:2px solid #F59E0B;">
<h4 style="margin:0 0 15px;color:#D97706;">💰 دفع رسوم التحويل/الفصل</h4> <h4 style="margin:0 0 15px;color:#D97706;">💰 دفع رسوم التحويل/الفصل</h4>
...@@ -116,7 +134,7 @@ if ($transfer['transfer_type'] === 'full_transfer' && !empty($transfer['notes']) ...@@ -116,7 +134,7 @@ if ($transfer['transfer_type'] === 'full_transfer' && !empty($transfer['notes'])
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
<?php if (in_array($transfer['status'], ['requested', 'approved', 'fee_paid'])): ?> <?php if (in_array($transfer['status'], ['approved', 'fee_paid'])): ?>
<?php if (can('transfer.approve')): ?> <?php if (can('transfer.approve')): ?>
<form method="POST" action="/transfers/<?= (int) $transfer['id'] ?>/complete" style="margin-top:15px;"> <form method="POST" action="/transfers/<?= (int) $transfer['id'] ?>/complete" style="margin-top:15px;">
<?= csrf_field() ?> <?= csrf_field() ?>
......
...@@ -15,17 +15,13 @@ class SessionController extends Controller ...@@ -15,17 +15,13 @@ class SessionController extends Controller
public function index(Request $request): Response public function index(Request $request): Response
{ {
$this->authorize('treasury.view_sessions'); $this->authorize('treasury.view_sessions');
$treasury = TreasuryService::getSubTreasury(); $employee = App::getInstance()->currentEmployee();
if (!$treasury) {
return $this->redirect('/treasury')->withError('لم يتم تعريف الخزنة الفرعية');
}
$sessions = SessionService::getSessionHistory((int) $treasury['id']); $sessions = SessionService::getAllSessionHistory((int) $employee->id);
return $this->view('Treasury.Views.sessions.index', [ return $this->view('Treasury.Views.sessions.index', [
'sessions' => $sessions, 'sessions' => $sessions,
'treasury' => $treasury, 'treasury' => TreasuryService::getSubTreasury(),
]); ]);
} }
...@@ -33,18 +29,15 @@ class SessionController extends Controller ...@@ -33,18 +29,15 @@ class SessionController extends Controller
{ {
$this->authorize('treasury.open_session'); $this->authorize('treasury.open_session');
$employee = App::getInstance()->currentEmployee(); $employee = App::getInstance()->currentEmployee();
$treasury = TreasuryService::getSubTreasury();
if (!$treasury) {
return $this->redirect('/treasury')->withError('لم يتم تعريف الخزنة الفرعية');
}
$session = SessionService::getCurrentSession((int) $treasury['id'], (int) $employee->id); $session = SessionService::getAnyCurrentSession((int) $employee->id);
if (!$session) { if (!$session) {
return $this->redirect('/treasury')->withWarning('لا توجد وردية مفتوحة حالياً'); return $this->redirect('/treasury')->withWarning('لا توجد وردية مفتوحة حالياً');
} }
$treasury = TreasuryService::find((int) $session['treasury_id']);
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$payments = $db->select( $payments = $db->select(
"SELECT p.*, m.full_name_ar as member_name, r.receipt_number "SELECT p.*, m.full_name_ar as member_name, r.receipt_number
......
...@@ -118,6 +118,20 @@ final class SessionService ...@@ -118,6 +118,20 @@ final class SessionService
return $db->selectOne($sql, $params); return $db->selectOne($sql, $params);
} }
public static function getAnyCurrentSession(int $cashierId): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT ts.*, e.full_name_ar as cashier_name, t.name_ar as treasury_name
FROM treasury_sessions ts
LEFT JOIN employees e ON e.id = ts.cashier_id
LEFT JOIN treasuries t ON t.id = ts.treasury_id
WHERE ts.cashier_id = ? AND ts.status = 'open'
LIMIT 1",
[$cashierId]
);
}
public static function getSessionHistory(int $treasuryId, int $limit = 50): array public static function getSessionHistory(int $treasuryId, int $limit = 50): array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
...@@ -132,6 +146,21 @@ final class SessionService ...@@ -132,6 +146,21 @@ final class SessionService
); );
} }
public static function getAllSessionHistory(int $cashierId, int $limit = 50): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT ts.*, e.full_name_ar as cashier_name, t.name_ar as treasury_name
FROM treasury_sessions ts
LEFT JOIN employees e ON e.id = ts.cashier_id
LEFT JOIN treasuries t ON t.id = ts.treasury_id
WHERE ts.cashier_id = ?
ORDER BY ts.id DESC
LIMIT ?",
[$cashierId, $limit]
);
}
public static function find(int $sessionId): ?array public static function find(int $sessionId): ?array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
......
<?php
declare(strict_types=1);
return [
'up' => "UPDATE business_rules SET current_value_json = '{\"amount\":\"200.00\"}', updated_at = NOW() WHERE rule_code = 'VIOLATION_FINE_MIN'",
'down' => "UPDATE business_rules SET current_value_json = '{\"amount\":\"1000.00\"}', updated_at = NOW() WHERE rule_code = 'VIOLATION_FINE_MIN'",
];
...@@ -74,7 +74,7 @@ return function (Database $db): void { ...@@ -74,7 +74,7 @@ return function (Database $db): void {
['rule_code' => 'LATE_SUB_FINE_YEAR_3', 'category' => 'penalty', 'name_ar' => 'غرامة تأخير سنة ثالثة', 'name_en' => 'Late Fine Year 3', 'data_type' => 'percentage', 'current_value_json' => '{"percentage_of_subscription":"300.00"}', 'parameters_json' => '{"percentage_of_subscription":"decimal"}'], ['rule_code' => 'LATE_SUB_FINE_YEAR_3', 'category' => 'penalty', 'name_ar' => 'غرامة تأخير سنة ثالثة', 'name_en' => 'Late Fine Year 3', 'data_type' => 'percentage', 'current_value_json' => '{"percentage_of_subscription":"300.00"}', 'parameters_json' => '{"percentage_of_subscription":"decimal"}'],
['rule_code' => 'LATE_SUB_FINE_MAX_YEARS', 'category' => 'penalty', 'name_ar' => 'أقصى سنوات تراكم غرامات', 'name_en' => 'Max Fine Years', 'data_type' => 'integer', 'current_value_json' => '{"years":5}', 'parameters_json' => '{"years":"integer"}'], ['rule_code' => 'LATE_SUB_FINE_MAX_YEARS', 'category' => 'penalty', 'name_ar' => 'أقصى سنوات تراكم غرامات', 'name_en' => 'Max Fine Years', 'data_type' => 'integer', 'current_value_json' => '{"years":5}', 'parameters_json' => '{"years":"integer"}'],
['rule_code' => 'LATE_SUB_DROP_YEARS', 'category' => 'penalty', 'name_ar' => 'سنوات إسقاط العضوية', 'name_en' => 'Drop Years', 'data_type' => 'integer', 'current_value_json' => '{"years":5}', 'parameters_json' => '{"years":"integer"}'], ['rule_code' => 'LATE_SUB_DROP_YEARS', 'category' => 'penalty', 'name_ar' => 'سنوات إسقاط العضوية', 'name_en' => 'Drop Years', 'data_type' => 'integer', 'current_value_json' => '{"years":5}', 'parameters_json' => '{"years":"integer"}'],
['rule_code' => 'VIOLATION_FINE_MIN', 'category' => 'penalty', 'name_ar' => 'الحد الأدنى للغرامة', 'name_en' => 'Min Violation Fine', 'data_type' => 'amount', 'current_value_json' => '{"amount":"1000.00"}', 'parameters_json' => '{"amount":"decimal"}'], ['rule_code' => 'VIOLATION_FINE_MIN', 'category' => 'penalty', 'name_ar' => 'الحد الأدنى للغرامة', 'name_en' => 'Min Violation Fine', 'data_type' => 'amount', 'current_value_json' => '{"amount":"200.00"}', 'parameters_json' => '{"amount":"decimal"}'],
['rule_code' => 'VIOLATION_FINE_MAX', 'category' => 'penalty', 'name_ar' => 'الحد الأقصى للغرامة', 'name_en' => 'Max Violation Fine', 'data_type' => 'amount', 'current_value_json' => '{"amount":"10000.00"}', 'parameters_json' => '{"amount":"decimal"}'], ['rule_code' => 'VIOLATION_FINE_MAX', 'category' => 'penalty', 'name_ar' => 'الحد الأقصى للغرامة', 'name_en' => 'Max Violation Fine', 'data_type' => 'amount', 'current_value_json' => '{"amount":"10000.00"}', 'parameters_json' => '{"amount":"decimal"}'],
['rule_code' => 'APPEAL_WINDOW', 'category' => 'penalty', 'name_ar' => 'مهلة التظلم', 'name_en' => 'Appeal Window', 'data_type' => 'integer', 'current_value_json' => '{"days":15}', 'parameters_json' => '{"days":"integer"}'], ['rule_code' => 'APPEAL_WINDOW', 'category' => 'penalty', 'name_ar' => 'مهلة التظلم', 'name_en' => 'Appeal Window', 'data_type' => 'integer', 'current_value_json' => '{"days":15}', 'parameters_json' => '{"days":"integer"}'],
['rule_code' => 'REVIEW_AFTER_EXPULSION', 'category' => 'penalty', 'name_ar' => 'إعادة النظر بعد الفصل', 'name_en' => 'Review After Expulsion', 'data_type' => 'integer', 'current_value_json' => '{"months":6}', 'parameters_json' => '{"months":"integer"}'], ['rule_code' => 'REVIEW_AFTER_EXPULSION', 'category' => 'penalty', 'name_ar' => 'إعادة النظر بعد الفصل', 'name_en' => 'Review After Expulsion', 'data_type' => 'integer', 'current_value_json' => '{"months":6}', 'parameters_json' => '{"months":"integer"}'],
......
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