Commit 30062682 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix 5 support ticket bugs: AJAX errors, capacity validation, audit export,...

Fix 5 support ticket bugs: AJAX errors, capacity validation, audit export, player lookup, fee display

TKT-59: ExceptionHandler now returns JSON for AJAX/XHR requests instead of
HTML error pages. Booking wizard JS improved to show actual error messages.

TKT-57: Pricing rule creation now validates group_size_max against the
facility unit's max_capacity, preventing over-capacity rules.

TKT-46: Added audit log CSV export with all current filters applied.
New route GET /audit/export and export button in the filter bar.

TKT-55: Player registration member lookup now uses only the DB id
(set by AJAX member lookup) instead of ambiguous membership_number/id
fallback. National ID field locked when auto-filled from member record.

TKT-50: Fee breakdown text now shows base amount and development fee
separately (e.g. "اشتراك سنوي: 222.00 + تنمية: 35.00 = 257.00")
instead of just the combined total.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 12230ae0
......@@ -47,6 +47,23 @@ final class ExceptionHandler
}
$httpCode = in_array($e->getCode(), [401, 403, 404], true) ? $e->getCode() : 500;
$isAjax = (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest')
|| (isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'))
|| (isset($_SERVER['CONTENT_TYPE']) && str_contains($_SERVER['CONTENT_TYPE'], 'json'));
if ($isAjax) {
http_response_code($httpCode);
header('Content-Type: application/json; charset=utf-8');
$payload = ['success' => false, 'error' => 'حدث خطأ في الخادم'];
if ($debug) {
$payload['error'] = $e->getMessage();
$payload['file'] = $e->getFile() . ':' . $e->getLine();
}
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit(1);
}
http_response_code($httpCode);
header('Content-Type: text/html; charset=utf-8');
......
......@@ -68,6 +68,59 @@ class AuditController extends Controller
]);
}
public function export(Request $request): Response
{
$db = App::getInstance()->db();
$filters = [
'employee_id' => $request->get('employee_id', ''),
'action' => $request->get('action', ''),
'entity_type' => $request->get('entity_type', ''),
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'search' => trim((string) $request->get('q', '')),
];
$where = '1=1';
$params = [];
if (!empty($filters['employee_id'])) { $where .= ' AND a.employee_id = ?'; $params[] = (int) $filters['employee_id']; }
if (!empty($filters['action'])) { $where .= ' AND a.action = ?'; $params[] = $filters['action']; }
if (!empty($filters['entity_type'])) { $where .= ' AND a.entity_type = ?'; $params[] = $filters['entity_type']; }
if (!empty($filters['date_from'])) { $where .= ' AND a.created_at >= ?'; $params[] = $filters['date_from'] . ' 00:00:00'; }
if (!empty($filters['date_to'])) { $where .= ' AND a.created_at <= ?'; $params[] = $filters['date_to'] . ' 23:59:59'; }
if (!empty($filters['search'])) {
$where .= ' AND (a.entity_label LIKE ? OR a.employee_name LIKE ? OR a.notes LIKE ?)';
$s = '%' . $filters['search'] . '%'; $params[] = $s; $params[] = $s; $params[] = $s;
}
$rows = $db->select(
"SELECT a.created_at, a.employee_name, a.action, a.entity_type, a.entity_label, a.notes, a.ip_address
FROM audit_trail a WHERE {$where} ORDER BY a.created_at DESC LIMIT 10000",
$params
);
$filename = 'audit_log_' . date('Y-m-d_His') . '.csv';
$response = new Response();
$response->header('Content-Type', 'text/csv; charset=utf-8');
$response->header('Content-Disposition', 'attachment; filename="' . $filename . '"');
$output = "\xEF\xBB\xBF";
$output .= "التاريخ,المستخدم,الإجراء,الوحدة,العنصر,الملاحظات,IP\n";
foreach ($rows as $row) {
$output .= '"' . str_replace('"', '""', $row['created_at'] ?? '') . '",';
$output .= '"' . str_replace('"', '""', $row['employee_name'] ?? '') . '",';
$output .= '"' . str_replace('"', '""', $row['action'] ?? '') . '",';
$output .= '"' . str_replace('"', '""', $row['entity_type'] ?? '') . '",';
$output .= '"' . str_replace('"', '""', $row['entity_label'] ?? '') . '",';
$output .= '"' . str_replace('"', '""', $row['notes'] ?? '') . '",';
$output .= '"' . str_replace('"', '""', $row['ip_address'] ?? '') . '"' . "\n";
}
return $response->html($output);
}
public function entityHistory(Request $request, string $type, string $id): Response
{
$history = AuditTrail::getEntityHistory($type, (int) $id);
......
......@@ -3,6 +3,7 @@ declare(strict_types=1);
return [
['GET', '/audit', 'Audit\Controllers\AuditController@index', ['auth'], 'report.view_audit'],
['GET', '/audit/export', 'Audit\Controllers\AuditController@export', ['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'],
];
......@@ -48,6 +48,9 @@ use App\Modules\Audit\Services\AuditService;
</div>
<button type="submit" class="btn btn-outline">بحث</button>
<a href="/audit" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
<a href="/audit/export?<?= http_build_query(array_filter($filters)) ?>" class="btn btn-sm btn-outline" style="color:#059669;margin-right:auto;">
<i data-lucide="download" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> تصدير Excel
</a>
</form>
</div>
......
......@@ -112,7 +112,8 @@ final class ChildFeeCalculator
$breakdown[] = '📝 رسوم استمارة إضافة: ' . money($formFeeOnly);
}
if (bccomp($annualSubscription, '0', 2) > 0) {
$breakdown[] = '📅 اشتراك سنوي + تنمية: ' . money($annualSubscription);
$devFee = RuleEngine::get('DEVELOPMENT_FEE')['amount'] ?? '35.00';
$breakdown[] = '📅 اشتراك سنوي: ' . money(bcsub($annualSubscription, $devFee, 2)) . ' + تنمية: ' . money($devFee) . ' = ' . money($annualSubscription);
}
} else {
if (bccomp($childFee, '0', 2) > 0) {
......@@ -122,7 +123,8 @@ final class ChildFeeCalculator
$breakdown[] = '📝 رسوم استمارة إضافة: ' . money($formFeeOnly);
}
if (bccomp($annualSubscription, '0', 2) > 0) {
$breakdown[] = '📅 اشتراك سنوي + تنمية: ' . money($annualSubscription);
$devFee = RuleEngine::get('DEVELOPMENT_FEE')['amount'] ?? '35.00';
$breakdown[] = '📅 اشتراك سنوي: ' . money(bcsub($annualSubscription, $devFee, 2)) . ' + تنمية: ' . money($devFee) . ' = ' . money($annualSubscription);
}
}
$breakdown[] = '═══════════════════════════';
......
......@@ -120,18 +120,12 @@ class PlayerController extends Controller
$guardianRelationship = trim((string) $request->post('guardian_relationship', ''));
$notes = trim((string) $request->post('notes', ''));
// For members: auto-fill from member record
// For members: auto-fill from member record (member_id is the DB id set by AJAX lookup)
if ($playerType === 'member' && $memberId > 0) {
$member = $db->selectOne(
"SELECT full_name_ar, national_id, date_of_birth, gender FROM members WHERE membership_number = ? AND is_archived = 0",
[$memberId]
);
if (!$member) {
$member = $db->selectOne(
"SELECT full_name_ar, national_id, date_of_birth, gender FROM members WHERE id = ? AND is_archived = 0",
[$memberId]
);
}
if ($member) {
if ($fullNameAr === '') {
$fullNameAr = $member['full_name_ar'] ?? '';
......
......@@ -106,6 +106,12 @@ class PricingController extends Controller
if ($data['time_bracket_id'] <= 0) $errors[] = 'الفترة الزمنية مطلوبة';
if ($data['group_size_min'] < 1) $errors[] = 'الحد الأدنى للمجموعة يجب أن يكون 1 على الأقل';
if ($data['group_size_max'] < $data['group_size_min']) $errors[] = 'الحد الأقصى يجب أن يكون أكبر أو يساوي الحد الأدنى';
if ($data['facility_unit_id'] > 0) {
$unitCap = $db->selectOne("SELECT max_capacity FROM sa_facility_units WHERE id = ?", [$data['facility_unit_id']]);
if ($unitCap && (int) $unitCap['max_capacity'] > 0 && $data['group_size_max'] > (int) $unitCap['max_capacity']) {
$errors[] = 'الحد الأقصى للمجموعة (' . $data['group_size_max'] . ') يتجاوز سعة الوحدة (' . $unitCap['max_capacity'] . ')';
}
}
if ((float) $data['price_per_person_member'] <= 0) $errors[] = 'سعر العضو مطلوب';
if ((float) $data['price_per_person_nonmember'] <= 0) $errors[] = 'سعر غير العضو مطلوب';
if ($data['effective_from'] === '') $errors[] = 'تاريخ البداية مطلوب';
......
......@@ -533,7 +533,10 @@
member_id: state.memberId || 0,
_csrf_token: csrfToken
})
}).then(function(r) { return r.json(); }).then(function(data) {
}).then(function(r) {
if (!r.ok && r.status === 419) throw new Error('انتهت صلاحية الجلسة — يرجى تحديث الصفحة');
return r.json();
}).then(function(data) {
if (data.success) {
document.getElementById('successBookingNumber').textContent = 'رقم الحجز: ' + data.booking_number;
showSuccess();
......@@ -544,8 +547,8 @@
btn.innerHTML = '<i data-lucide="banknote" style="width:20px;height:20px;vertical-align:middle;margin-left:6px;"></i> تأكيد وإرسال للخزينة';
if (window.lucide) lucide.createIcons();
}
}).catch(function() {
document.getElementById('step4Error').textContent = 'خطأ في الاتصال';
}).catch(function(err) {
document.getElementById('step4Error').textContent = err.message || 'خطأ في الاتصال — يرجى تحديث الصفحة والمحاولة مرة أخرى';
document.getElementById('step4Error').style.display = '';
btn.disabled = false;
btn.innerHTML = '<i data-lucide="banknote" style="width:20px;height:20px;vertical-align:middle;margin-left:6px;"></i> تأكيد وإرسال للخزينة';
......
......@@ -166,8 +166,10 @@ document.addEventListener('DOMContentLoaded', function() {
var nidField = document.getElementById('nidInput');
if (data.name && nameInput && !nameInput.value.trim()) nameInput.value = data.name;
if (data.phone && phoneInput && !phoneInput.value.trim()) phoneInput.value = data.phone;
if (data.national_id && nidField && !nidField.value.trim()) {
if (data.national_id && nidField) {
nidField.value = data.national_id;
nidField.readOnly = true;
nidField.style.background = '#F9FAFB';
nidField.dispatchEvent(new Event('input'));
}
} else {
......@@ -175,6 +177,8 @@ document.addEventListener('DOMContentLoaded', function() {
memberLookupStatus.style.background = '#FEF2F2';
memberLookupStatus.style.color = '#DC2626';
memberLookupStatus.textContent = data.reason || 'رقم العضوية غير موجود';
var nf = document.getElementById('nidInput');
if (nf) { nf.readOnly = false; nf.style.background = ''; }
}
});
}, 400);
......
......@@ -281,7 +281,8 @@ final class SpouseFeeCalculator
$lines[] = '📝 رسوم استمارة إضافة: ' . money($formFee);
}
if (bccomp($annualSubscription, '0', 2) > 0) {
$lines[] = '📅 اشتراك سنوي + تنمية: ' . money($annualSubscription);
$devFee = RuleEngine::get('DEVELOPMENT_FEE')['amount'] ?? '35.00';
$lines[] = '📅 اشتراك سنوي: ' . money(bcsub($annualSubscription, $devFee, 2)) . ' + تنمية: ' . money($devFee) . ' = ' . money($annualSubscription);
}
} else {
if (bccomp($pctFee, '0', 2) > 0) {
......@@ -295,7 +296,8 @@ final class SpouseFeeCalculator
$lines[] = '📝 رسوم استمارة إضافة: ' . money($formFee);
}
if (bccomp($annualSubscription, '0', 2) > 0) {
$lines[] = '📅 اشتراك سنوي + تنمية: ' . money($annualSubscription);
$devFee = RuleEngine::get('DEVELOPMENT_FEE')['amount'] ?? '35.00';
$lines[] = '📅 اشتراك سنوي: ' . money(bcsub($annualSubscription, $devFee, 2)) . ' + تنمية: ' . money($devFee) . ' = ' . money($annualSubscription);
}
}
......
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