Commit 34e2e781 authored by Administrator's avatar Administrator

Update 21 files via Son of Anton

parent c8b79155
<?php
declare(strict_types=1);
namespace App\Modules\Members\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\Members\Services\NationalIdParser;
use App\Modules\Members\Services\MemberSearchService;
class MemberApiController extends Controller
{
public function parseNid(Request $request): Response
{
$nid = trim((string) $request->post('national_id', ''));
if ($nid === '') {
return $this->json(['error' => 'الرقم القومي مطلوب'], 422);
}
$result = NationalIdParser::parse($nid);
// Also check duplicate
$duplicate = null;
if ($result['is_valid']) {
$dup = NationalIdParser::checkDuplicate($nid);
if ($dup) {
$duplicate = [
'id' => (int) $dup['id'],
'full_name_ar' => $dup['full_name_ar'],
'membership_number' => $dup['membership_number'],
'status' => $dup['status'],
'is_archived' => (bool) $dup['is_archived'],
];
}
}
return $this->json([
'parsed' => $result,
'duplicate' => $duplicate,
]);
}
public function quickSearch(Request $request): Response
{
$query = trim((string) $request->post('q', ''));
if (mb_strlen($query) < 2) {
return $this->json(['results' => []]);
}
$results = MemberSearchService::quickSearch($query, 10);
return $this->json(['results' => $results]);
}
public function checkDuplicate(Request $request): Response
{
$nid = trim((string) $request->post('national_id', ''));
$excludeId = $request->post('exclude_id', null);
if ($nid === '') {
return $this->json(['exists' => false]);
}
$dup = NationalIdParser::checkDuplicate($nid, $excludeId ? (int) $excludeId : null);
return $this->json([
'exists' => $dup !== null,
'member' => $dup,
]);
}
}
\ No newline at end of file
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\Members\Models;
use App\Core\Model;
use App\Core\App;
class Member extends Model
{
protected static string $table = 'members';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $dispatchEvents = true;
protected static array $fillable = [
'membership_number', 'form_number', 'form_date', 'form_submission_id',
'branch_id', 'membership_type', 'member_category', 'status',
'full_name_ar', 'full_name_en', 'national_id', 'passport_number',
'id_type', 'id_issue_date', 'id_expiry_date',
'date_of_birth', 'age_years', 'age_months', 'gender',
'place_of_birth', 'nationality', 'governorate_code',
'religion', 'qualification_id', 'marital_status',
'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',
'membership_value', 'payment_method', 'referral_source', 'photo_path',
'workflow_instance_id',
];
public function getBranchName(): string
{
if (!$this->branch_id) return '—';
$db = App::getInstance()->db();
$row = $db->selectOne("SELECT name_ar FROM branches WHERE id = ?", [$this->branch_id]);
return $row['name_ar'] ?? '—';
}
public function getQualificationName(): string
{
if (!$this->qualification_id) return '—';
$db = App::getInstance()->db();
$row = $db->selectOne("SELECT name_ar FROM qualifications WHERE id = ?", [$this->qualification_id]);
return $row['name_ar'] ?? '—';
}
public function getStatusLabel(): string
{
$labels = [
'potential' => 'عضوية محتملة',
'under_review' => 'تحت المراجعة',
'interview_scheduled'=> 'في انتظار المقابلة',
'accepted' => 'مقبول',
'rejected' => 'مرفوض',
'payment_pending' => 'في انتظار السداد',
'active' => 'فعال',
'frozen' => 'مجمد',
'suspended' => 'موقوف',
'dropped' => 'مسقط',
'expired' => 'منتهي',
'terminated' => 'منتهي بقرار',
];
return $labels[$this->status] ?? $this->status;
}
public function getStatusColor(): string
{
$colors = [
'potential' => '#6B7280',
'under_review' => '#D97706',
'interview_scheduled' => '#0284C7',
'accepted' => '#059669',
'rejected' => '#DC2626',
'payment_pending' => '#D97706',
'active' => '#059669',
'frozen' => '#6B7280',
'suspended' => '#DC2626',
'dropped' => '#DC2626',
'expired' => '#9CA3AF',
'terminated' => '#DC2626',
];
return $colors[$this->status] ?? '#6B7280';
}
public static function getStatusOptions(): array
{
return [
'potential' => 'عضوية محتملة',
'under_review' => 'تحت المراجعة',
'interview_scheduled' => 'في انتظار المقابلة',
'accepted' => 'مقبول',
'rejected' => 'مرفوض',
'payment_pending' => 'في انتظار السداد',
'active' => 'فعال',
'frozen' => 'مجمد',
'suspended' => 'موقوف',
'dropped' => 'مسقط',
'expired' => 'منتهي',
];
}
public static function getMembershipTypes(): array
{
return [
'working' => 'عامل',
'seasonal' => 'موسمي',
'sports' => 'رياضي',
'honorary' => 'شرفي',
'foreign' => 'أجنبي',
];
}
public function getNotes(): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT mn.*, e.full_name_ar as employee_name FROM member_notes mn LEFT JOIN employees e ON e.id = mn.created_by WHERE mn.member_id = ? ORDER BY mn.created_at DESC",
[$this->id]
);
}
public function getGenderLabel(): string
{
return $this->gender === 'male' ? 'ذكر' : ($this->gender === 'female' ? 'أنثى' : $this->gender);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Members\Models;
use App\Core\Model;
class MemberNote extends Model
{
protected static string $table = 'member_notes';
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static array $fillable = ['member_id', 'note_text'];
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/members', 'Members\Controllers\MemberController@index', ['auth'], 'member.view'],
['GET', '/members/search', 'Members\Controllers\MemberController@search', ['auth'], 'member.search'],
['GET', '/members/create', 'Members\Controllers\MemberController@create', ['auth'], 'member.create'],
['POST', '/members', 'Members\Controllers\MemberController@store', ['auth'], 'member.create'],
['GET', '/members/{id}', 'Members\Controllers\MemberController@show', ['auth'], 'member.view'],
['GET', '/members/{id}/edit', 'Members\Controllers\MemberController@edit', ['auth'], 'member.edit'],
['POST', '/members/{id}', 'Members\Controllers\MemberController@update', ['auth'], 'member.edit'],
// API endpoints
['POST', '/api/members/parse-nid', 'Members\Controllers\MemberApiController@parseNid', ['auth'], 'member.create'],
['POST', '/api/members/quick-search', 'Members\Controllers\MemberApiController@quickSearch', ['auth'], 'member.search'],
['POST', '/api/members/check-duplicate', 'Members\Controllers\MemberApiController@checkDuplicate', ['auth'], 'member.create'],
];
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Members\Services;
use App\Core\App;
final class MemberNumberGenerator
{
/**
* Generate the next available membership number.
* Sequential, gap-free, atomic to prevent duplicates.
* Only called after payment confirmation.
*/
public static function next(): string
{
$db = App::getInstance()->db();
// Use a transaction with locking to prevent concurrent duplicates
$db->beginTransaction();
try {
// Get the current maximum membership number
$row = $db->selectOne(
"SELECT MAX(CAST(membership_number AS UNSIGNED)) as max_num FROM members WHERE membership_number IS NOT NULL AND membership_number REGEXP '^[0-9]+$' FOR UPDATE"
);
$startNumber = 10012; // Default starting number (1001/2 per spec — interpreted as 10012)
// Check system config for starting number
$configRow = $db->selectOne("SELECT config_value FROM system_config WHERE config_key = 'membership_start_number'");
if ($configRow && $configRow['config_value']) {
$startNumber = (int) $configRow['config_value'];
}
$maxNum = $row['max_num'] ? (int) $row['max_num'] : ($startNumber - 1);
$nextNum = max($maxNum + 1, $startNumber);
$db->commit();
return (string) $nextNum;
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
}
/**
* Assign a membership number to a member.
* Called only after payment is confirmed (Phase 11).
*/
public static function assign(int $memberId): string
{
$db = App::getInstance()->db();
// Check member doesn't already have a number
$member = $db->selectOne("SELECT membership_number FROM members WHERE id = ?", [$memberId]);
if ($member && $member['membership_number']) {
return $member['membership_number'];
}
$number = self::next();
$db->update('members', [
'membership_number' => $number,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$memberId]);
return $number;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Members\Services;
use App\Core\App;
use App\Core\Pagination;
final class MemberSearchService
{
/**
* Search members with multiple criteria.
*/
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = "m.is_archived = 0";
$params = [];
if (!empty($filters['q'])) {
$q = trim($filters['q']);
$where .= " AND (m.full_name_ar LIKE ? OR m.full_name_en LIKE ? OR m.national_id LIKE ? OR m.membership_number LIKE ? OR m.phone_mobile LIKE ? OR m.form_number LIKE ? OR m.passport_number LIKE ?)";
$s = "%{$q}%";
$params = array_merge($params, [$s, $s, $s, $s, $s, $s, $s]);
}
if (!empty($filters['status'])) {
$where .= " AND m.status = ?";
$params[] = $filters['status'];
}
if (!empty($filters['branch_id'])) {
$where .= " AND m.branch_id = ?";
$params[] = (int) $filters['branch_id'];
}
if (!empty($filters['membership_type'])) {
$where .= " AND m.membership_type = ?";
$params[] = $filters['membership_type'];
}
if (!empty($filters['gender'])) {
$where .= " AND m.gender = ?";
$params[] = $filters['gender'];
}
if (!empty($filters['date_from'])) {
$where .= " AND m.created_at >= ?";
$params[] = $filters['date_from'] . ' 00:00:00';
}
if (!empty($filters['date_to'])) {
$where .= " AND m.created_at <= ?";
$params[] = $filters['date_to'] . ' 23:59:59';
}
if (!empty($filters['national_id'])) {
$where .= " AND m.national_id = ?";
$params[] = $filters['national_id'];
}
if (!empty($filters['membership_number'])) {
$where .= " AND m.membership_number = ?";
$params[] = $filters['membership_number'];
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM members m WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT m.*, b.name_ar as branch_name, q.name_ar as qualification_name
FROM members m
LEFT JOIN branches b ON b.id = m.branch_id
LEFT JOIN qualifications q ON q.id = m.qualification_id
WHERE {$where}
ORDER BY m.created_at DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
$pagination = Pagination::paginate($total, $perPage, $page);
return ['data' => $rows, 'pagination' => $pagination];
}
/**
* Quick search for AJAX autocomplete (header search bar).
*/
public static function quickSearch(string $query, int $limit = 10): array
{
$db = App::getInstance()->db();
$s = "%{$query}%";
return $db->select(
"SELECT id, membership_number, full_name_ar, national_id, phone_mobile, status
FROM members
WHERE is_archived = 0
AND (full_name_ar LIKE ? OR membership_number LIKE ? OR national_id LIKE ? OR phone_mobile LIKE ?)
ORDER BY
CASE WHEN membership_number = ? THEN 0
WHEN national_id = ? THEN 1
ELSE 2 END,
full_name_ar ASC
LIMIT ?",
[$s, $s, $s, $s, $query, $query, $limit]
);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Members\Services;
use App\Core\App;
final class NationalIdParser
{
private static array $governorateMap = [
'01' => ['ar' => 'القاهرة', 'en' => 'Cairo'],
'02' => ['ar' => 'الإسكندرية', 'en' => 'Alexandria'],
'03' => ['ar' => 'بورسعيد', 'en' => 'Port Said'],
'04' => ['ar' => 'السويس', 'en' => 'Suez'],
'11' => ['ar' => 'دمياط', 'en' => 'Damietta'],
'12' => ['ar' => 'الدقهلية', 'en' => 'Dakahlia'],
'13' => ['ar' => 'الشرقية', 'en' => 'Sharqia'],
'14' => ['ar' => 'القليوبية', 'en' => 'Qalyubia'],
'15' => ['ar' => 'كفر الشيخ', 'en' => 'Kafr El Sheikh'],
'16' => ['ar' => 'الغربية', 'en' => 'Gharbia'],
'17' => ['ar' => 'المنوفية', 'en' => 'Menoufia'],
'18' => ['ar' => 'البحيرة', 'en' => 'Beheira'],
'19' => ['ar' => 'الإسماعيلية', 'en' => 'Ismailia'],
'21' => ['ar' => 'الجيزة', 'en' => 'Giza'],
'22' => ['ar' => 'بني سويف', 'en' => 'Beni Suef'],
'23' => ['ar' => 'الفيوم', 'en' => 'Fayoum'],
'24' => ['ar' => 'المنيا', 'en' => 'Minya'],
'25' => ['ar' => 'أسيوط', 'en' => 'Assiut'],
'26' => ['ar' => 'سوهاج', 'en' => 'Sohag'],
'27' => ['ar' => 'قنا', 'en' => 'Qena'],
'28' => ['ar' => 'أسوان', 'en' => 'Aswan'],
'29' => ['ar' => 'الأقصر', 'en' => 'Luxor'],
'31' => ['ar' => 'البحر الأحمر', 'en' => 'Red Sea'],
'32' => ['ar' => 'الوادي الجديد', 'en' => 'New Valley'],
'33' => ['ar' => 'مطروح', 'en' => 'Matrouh'],
'34' => ['ar' => 'شمال سيناء', 'en' => 'North Sinai'],
'35' => ['ar' => 'جنوب سيناء', 'en' => 'South Sinai'],
'88' => ['ar' => 'خارج الجمهورية', 'en' => 'Born Abroad'],
];
/**
* Parse an Egyptian 14-digit National ID and extract all encoded data.
*
* @return array{is_valid: bool, errors: string[], dob: ?string, age_years: ?int, age_months: ?int, gender: ?string, governorate_code: ?string, governorate_name_ar: ?string, governorate_name_en: ?string}
*/
public static function parse(string $nid): array
{
$result = [
'is_valid' => false,
'errors' => [],
'dob' => null,
'age_years' => null,
'age_months' => null,
'gender' => null,
'governorate_code' => null,
'governorate_name_ar' => null,
'governorate_name_en' => null,
];
// Must be exactly 14 digits
if (!preg_match('/^\d{14}$/', $nid)) {
$result['errors'][] = 'الرقم القومي يجب أن يتكون من 14 رقم فقط';
return $result;
}
// Position 1: Century code
$centuryCode = (int) $nid[0];
if ($centuryCode !== 2 && $centuryCode !== 3) {
$result['errors'][] = 'رمز القرن غير صالح (يجب أن يكون 2 أو 3)';
return $result;
}
$centuryBase = ($centuryCode === 2) ? 1900 : 2000;
// Positions 2-3: Year
$year = $centuryBase + (int) substr($nid, 1, 2);
// Positions 4-5: Month
$month = (int) substr($nid, 3, 2);
if ($month < 1 || $month > 12) {
$result['errors'][] = 'الشهر غير صالح (يجب أن يكون بين 01 و 12)';
return $result;
}
// Positions 6-7: Day
$day = (int) substr($nid, 5, 2);
if ($day < 1 || $day > 31) {
$result['errors'][] = 'اليوم غير صالح (يجب أن يكون بين 01 و 31)';
return $result;
}
// Validate the full date
if (!checkdate($month, $day, $year)) {
$result['errors'][] = 'تاريخ الميلاد غير صالح';
return $result;
}
$dob = sprintf('%04d-%02d-%02d', $year, $month, $day);
// Ensure DOB is not in the future
$dobDate = new \DateTime($dob);
$now = new \DateTime();
if ($dobDate > $now) {
$result['errors'][] = 'تاريخ الميلاد لا يمكن أن يكون في المستقبل';
return $result;
}
// Positions 8-9: Governorate
$govCode = substr($nid, 7, 2);
$govData = self::$governorateMap[$govCode] ?? null;
// Also try from DB
if ($govData === null) {
$db = App::getInstance()->db();
$govRow = $db->selectOne("SELECT name_ar, name_en FROM governorates WHERE code = ? AND is_active = 1", [$govCode]);
if ($govRow) {
$govData = ['ar' => $govRow['name_ar'], 'en' => $govRow['name_en']];
}
}
if ($govData === null) {
$result['errors'][] = 'كود المحافظة غير معروف: ' . $govCode;
return $result;
}
// Positions 10-13: Sequential registration number
$seqNum = (int) substr($nid, 9, 4);
// Gender: positions 12-13 (the last two of the 4-digit sequence)
// Actually per spec: "Positions 12-13 as integer" odd=male, even=female
// But positions are 1-indexed in the spec. Position 12-13 of the NID = index 11-12
// The sequence is positions 10-13 (indices 9-12). The gender digit is the last digit of the sequence.
// Per spec: "If (Positions 12-13 as integer) is ODD → Male. If EVEN → Female."
// Positions 12-13 = indices 11-12 = 2-digit number
$genderDigits = (int) substr($nid, 11, 2);
// Actually, re-reading spec: it says positions 10-13 are sequential, and gender from odd/even of those.
// Some implementations use just the last digit of the sequence (position 13, index 12).
// The spec says "Positions 12-13 as integer" - let me use that literally
// Wait, it says "10-13 Sequential Registration Number" and "Odd number = Male, Even number = Female"
// So the ENTIRE 4-digit sequence number's parity determines gender.
$gender = ($seqNum % 2 !== 0) ? 'male' : 'female';
// Position 14: Check digit (Luhn-like validation)
$checkDigit = (int) $nid[13];
if (!self::validateCheckDigit($nid)) {
$result['errors'][] = 'رقم التحقق غير صالح';
return $result;
}
// Calculate age
$diff = $now->diff($dobDate);
$ageYears = $diff->y;
$ageMonths = $diff->m;
// Sanity check
if ($ageYears > 120 || $ageYears < 0) {
$result['errors'][] = 'العمر المحسوب غير منطقي';
return $result;
}
$result['is_valid'] = true;
$result['dob'] = $dob;
$result['age_years'] = $ageYears;
$result['age_months'] = $ageMonths;
$result['gender'] = $gender;
$result['governorate_code'] = $govCode;
$result['governorate_name_ar'] = $govData['ar'];
$result['governorate_name_en'] = $govData['en'];
return $result;
}
/**
* Validate the check digit using Luhn algorithm variant.
*/
private static function validateCheckDigit(string $nid): bool
{
$weights = [2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2];
$sum = 0;
for ($i = 0; $i < 13; $i++) {
$product = (int) $nid[$i] * $weights[$i];
if ($product >= 10) {
$product = (int) floor($product / 10) + ($product % 10);
}
$sum += $product;
}
$expectedCheck = (10 - ($sum % 10)) % 10;
return $expectedCheck === (int) $nid[13];
}
/**
* Check if a National ID already exists in the system for an active member.
*/
public static function checkDuplicate(string $nid, ?int $excludeMemberId = null): ?array
{
$db = App::getInstance()->db();
$sql = "SELECT id, membership_number, full_name_ar, status, is_archived FROM members WHERE national_id = ?";
$params = [$nid];
if ($excludeMemberId !== null) {
$sql .= " AND id != ?";
$params[] = $excludeMemberId;
}
$existing = $db->selectOne($sql, $params);
return $existing ?: null;
}
}
\ No newline at end of file
<?php $auditHistory = $auditHistory ?? []; ?>
<?php if (empty($auditHistory)): ?>
<p style="color:#6B7280;text-align:center;padding:20px;">لا يوجد سجل نشاط</p>
<?php else: ?>
<?php foreach ($auditHistory as $entry): ?>
<div style="display:flex;gap:10px;padding:8px 0;border-bottom:1px solid #F3F4F6;font-size:13px;">
<div style="width:8px;height:8px;border-radius:50%;background:#0D7377;margin-top:5px;flex-shrink:0;"></div>
<div><strong><?= e($entry['action']) ?></strong><?= e($entry['employee_name'] ?? 'النظام') ?><br><span style="color:#9CA3AF;font-size:11px;"><?= e($entry['created_at']) ?></span></div>
</div>
<?php endforeach; ?>
<?php endif; ?>
\ No newline at end of file
<?php $documents = $documents ?? []; ?>
<?php if (empty($documents)): ?>
<p style="color:#6B7280;text-align:center;padding:20px;">لا توجد مستندات مرفوعة</p>
<?php endif; ?>
\ No newline at end of file
<?php
$spouses = $spouses ?? [];
$children = $children ?? [];
$temporary = $temporary ?? [];
?>
<div style="margin-bottom:20px;">
<h4 style="color:#0D7377;">الزوجات (<?= count($spouses) ?>)</h4>
<?php if (empty($spouses)): ?>
<p style="color:#6B7280;">لا توجد زوجات مسجلة</p>
<?php endif; ?>
</div>
<div style="margin-bottom:20px;">
<h4 style="color:#0D7377;">الأبناء (<?= count($children) ?>)</h4>
<?php if (empty($children)): ?>
<p style="color:#6B7280;">لا يوجد أبناء</p>
<?php endif; ?>
</div>
\ No newline at end of file
<?php
$payments = $payments ?? [];
$installments = $installments ?? [];
$subscriptions = $subscriptions ?? [];
?>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:15px;margin-bottom:20px;">
<div style="background:#F0FDF4;padding:15px;border-radius:8px;text-align:center;"><div style="font-size:24px;font-weight:700;color:#059669;">0</div><div style="color:#6B7280;font-size:12px;">إجمالي المدفوع</div></div>
<div style="background:#FEF2F2;padding:15px;border-radius:8px;text-align:center;"><div style="font-size:24px;font-weight:700;color:#DC2626;">0</div><div style="color:#6B7280;font-size:12px;">المتبقي</div></div>
<div style="background:#EFF6FF;padding:15px;border-radius:8px;text-align:center;"><div style="font-size:24px;font-weight:700;color:#0284C7;">0</div><div style="color:#6B7280;font-size:12px;">الأقساط</div></div>
<div style="background:#FFF7ED;padding:15px;border-radius:8px;text-align:center;"><div style="font-size:24px;font-weight:700;color:#D97706;">0</div><div style="color:#6B7280;font-size:12px;">الاشتراكات</div></div>
</div>
\ No newline at end of file
<?php
/** @var \App\Modules\Members\Models\Member $member */
?>
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<h2 style="margin:0;"><?= e($member->full_name_ar) ?></h2>
<div style="color:#6B7280;margin-top:4px;">رقم العضوية: <strong><?= e($member->membership_number ?: 'لم يُحدد') ?></strong></div>
</div>
<span style="padding:6px 16px;border-radius:20px;font-weight:700;color:#fff;background:<?= $member->getStatusColor() ?>;"><?= e($member->getStatusLabel()) ?></span>
</div>
\ No newline at end of file
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تعديل العضو: <?= e($member->full_name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/members/<?= (int) $member->id ?>">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;padding:20px;">
<h3 style="color:#0D7377;margin-bottom:15px;">البيانات الأساسية (للقراءة فقط)</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group"><label class="form-label">الاسم بالعربي</label><input type="text" value="<?= e($member->full_name_ar) ?>" class="form-input" disabled style="background:#F3F4F6;"></div>
<div class="form-group"><label class="form-label">الرقم القومي</label><input type="text" value="<?= e($member->national_id ?: '—') ?>" class="form-input" disabled style="background:#F3F4F6;direction:ltr;text-align:left;"></div>
<div class="form-group"><label class="form-label">تاريخ الميلاد</label><input type="text" value="<?= e($member->date_of_birth) ?>" class="form-input" disabled style="background:#F3F4F6;"></div>
<div class="form-group"><label class="form-label">النوع</label><input type="text" value="<?= e($member->getGenderLabel()) ?>" class="form-input" disabled style="background:#F3F4F6;"></div>
</div>
</div>
<div class="card" style="margin-bottom:20px;padding:20px;">
<h3 style="color:#0D7377;margin-bottom:15px;">البيانات القابلة للتعديل</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group"><label class="form-label">الاسم بالإنجليزي</label><input type="text" name="full_name_en" value="<?= e($member->full_name_en ?? '') ?>" class="form-input"></div>
<div class="form-group"><label class="form-label">الحالة الاجتماعية</label>
<select name="marital_status" class="form-select"><option value=""></option><option value="single" <?= $member->marital_status === 'single' ? 'selected' : '' ?>>أعزب</option><option value="married" <?= $member->marital_status === 'married' ? 'selected' : '' ?>>متزوج</option><option value="divorced" <?= $member->marital_status === 'divorced' ? 'selected' : '' ?>>مطلق</option><option value="widowed" <?= $member->marital_status === 'widowed' ? 'selected' : '' ?>>أرمل</option></select>
</div>
<div class="form-group"><label class="form-label">رقم المحمول <span style="color:#DC2626;">*</span></label><input type="tel" name="phone_mobile" value="<?= e($member->phone_mobile) ?>" class="form-input" required style="direction:ltr;text-align:left;"></div>
<div class="form-group"><label class="form-label">تليفون المنزل</label><input type="tel" name="phone_home" value="<?= e($member->phone_home ?? '') ?>" class="form-input" style="direction:ltr;text-align:left;"></div>
<div class="form-group"><label class="form-label">البريد الإلكتروني</label><input type="email" name="email" value="<?= e($member->email ?? '') ?>" class="form-input" style="direction:ltr;text-align:left;"></div>
<div class="form-group"><label class="form-label">شخص للطوارئ</label><input type="text" name="emergency_name" value="<?= e($member->emergency_name ?? '') ?>" class="form-input"></div>
<div class="form-group"><label class="form-label">هاتف الطوارئ</label><input type="tel" name="emergency_phone" value="<?= e($member->emergency_phone ?? '') ?>" class="form-input" style="direction:ltr;text-align:left;"></div>
<div class="form-group"><label class="form-label">المهنة</label><input type="text" name="occupation" value="<?= e($member->occupation ?? '') ?>" class="form-input"></div>
<div class="form-group"><label class="form-label">المركز الوظيفي</label><input type="text" name="job_title" value="<?= e($member->job_title ?? '') ?>" class="form-input"></div>
<div class="form-group" style="grid-column:1/-1;"><label class="form-label">عنوان السكن</label><textarea name="residence_address" class="form-textarea" rows="2"><?= e($member->residence_address ?? '') ?></textarea></div>
<div class="form-group">
<label class="form-label">المحافظة</label>
<select name="governorate" class="form-select"><option value=""></option>
<?php foreach ($governorates as $g): ?><option value="<?= e($g['name_ar']) ?>" <?= $member->governorate === $g['name_ar'] ? 'selected' : '' ?>><?= e($g['name_ar']) ?></option><?php endforeach; ?>
</select>
</div>
<div class="form-group"><label class="form-label">المنطقة</label><input type="text" name="area" value="<?= e($member->area ?? '') ?>" class="form-input"></div>
</div>
</div>
<div class="card" style="margin-bottom:20px;padding:20px;">
<h3 style="color:#0D7377;margin-bottom:15px;">إضافة ملاحظة</h3>
<textarea name="new_note" class="form-textarea" rows="3" placeholder="أضف ملاحظة جديدة..."></textarea>
</div>
<button type="submit" class="btn btn-primary">حفظ التعديلات</button>
<a href="/members/<?= (int) $member->id ?>" class="btn btn-outline">إلغاء</a>
</form>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إدارة الأعضاء<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/members/create" class="btn btn-primary">+ عضو جديد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/members" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="q" value="<?= e($filters['q'] ?? '') ?>" placeholder="الاسم، الرقم القومي، رقم العضوية..." class="form-input" style="min-width:250px;">
</div>
<div>
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-select" style="min-width:120px;">
<option value="">الكل</option>
<?php foreach ($statuses as $val => $label): ?>
<option value="<?= e($val) ?>" <?= ($filters['status'] ?? '') === $val ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">الفرع</label>
<select name="branch_id" class="form-select" style="min-width:120px;">
<option value="">الكل</option>
<?php foreach ($branches as $b): ?>
<option value="<?= (int) $b['id'] ?>" <?= ($filters['branch_id'] ?? '') == $b['id'] ? 'selected' : '' ?>><?= e($b['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">من</label>
<input type="date" name="date_from" value="<?= e($filters['date_from'] ?? '') ?>" class="form-input">
</div>
<div>
<label class="form-label" style="font-size:12px;">إلى</label>
<input type="date" name="date_to" value="<?= e($filters['date_to'] ?? '') ?>" class="form-input">
</div>
<button type="submit" class="btn btn-outline">بحث</button>
<a href="/members" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>رقم العضوية</th>
<th>الاسم</th>
<th>الرقم القومي</th>
<th>الهاتف</th>
<th>الفرع</th>
<th>الحالة</th>
<th>تاريخ التسجيل</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($members as $m): ?>
<tr>
<td style="font-weight:600;"><?= e($m['membership_number'] ?? '—') ?></td>
<td>
<a href="/members/<?= (int) $m['id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($m['full_name_ar']) ?></a>
<?php if ($m['full_name_en']): ?>
<div style="font-size:11px;color:#9CA3AF;"><?= e($m['full_name_en']) ?></div>
<?php endif; ?>
</td>
<td style="direction:ltr;text-align:right;font-size:13px;"><?= e($m['national_id'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;font-size:13px;"><?= e($m['phone_mobile']) ?></td>
<td style="font-size:13px;"><?= e($m['branch_name'] ?? '—') ?></td>
<td>
<?php
$statusLabels = [
'potential' => 'محتمل', 'under_review' => 'مراجعة', 'interview_scheduled' => 'مقابلة',
'accepted' => 'مقبول', 'rejected' => 'مرفوض', 'payment_pending' => 'انتظار سداد',
'active' => 'فعال', 'frozen' => 'مجمد', 'suspended' => 'موقوف',
'dropped' => 'مسقط', 'expired' => 'منتهي',
];
$statusColors = [
'potential' => '#6B7280', 'under_review' => '#D97706', 'interview_scheduled' => '#0284C7',
'accepted' => '#059669', 'rejected' => '#DC2626', 'payment_pending' => '#D97706',
'active' => '#059669', 'frozen' => '#6B7280', 'suspended' => '#DC2626',
'dropped' => '#DC2626', 'expired' => '#9CA3AF',
];
$color = $statusColors[$m['status']] ?? '#6B7280';
$label = $statusLabels[$m['status']] ?? $m['status'];
?>
<span style="color:<?= $color ?>;font-weight:600;font-size:13px;"><?= e($label) ?></span>
</td>
<td style="font-size:12px;"><?= e(substr($m['created_at'], 0, 10)) ?></td>
<td>
<div style="display:flex;gap:5px;">
<a href="/members/<?= (int) $m['id'] ?>" class="btn btn-sm btn-outline">عرض</a>
<a href="/members/<?= (int) $m['id'] ?>/edit" class="btn btn-sm btn-outline">تعديل</a>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($members)): ?>
<tr><td colspan="8" style="text-align:center;padding:40px;color:#6B7280;">لا يوجد أعضاء</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if (isset($pagination) && ($pagination['last_page'] ?? 1) > 1): ?>
<div style="padding:15px;">
<?php $__template->include('Shared.Components.pagination', ['pagination' => $pagination]); ?>
</div>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>بحث الأعضاء<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="padding:20px;">
<form method="GET" action="/members" style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group" style="grid-column:1/-1;">
<label class="form-label">بحث شامل</label>
<input type="text" name="q" class="form-input" placeholder="الاسم بالعربي، الرقم القومي، رقم العضوية، رقم المحمول، رقم الاستمارة..." style="font-size:16px;padding:12px;" autofocus>
</div>
<div class="form-group">
<label class="form-label">الحالة</label>
<select name="status" class="form-select"><option value="">الكل</option>
<?php foreach ($statuses as $val => $label): ?><option value="<?= e($val) ?>"><?= e($label) ?></option><?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">الفرع</label>
<select name="branch_id" class="form-select"><option value="">الكل</option>
<?php foreach ($branches as $b): ?><option value="<?= (int) $b['id'] ?>"><?= e($b['name_ar']) ?></option><?php endforeach; ?>
</select>
</div>
<div class="form-group"><label class="form-label">من تاريخ</label><input type="date" name="date_from" class="form-input"></div>
<div class="form-group"><label class="form-label">إلى تاريخ</label><input type="date" name="date_to" class="form-input"></div>
<div style="grid-column:1/-1;"><button type="submit" class="btn btn-primary">بحث</button></div>
</form>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
This diff is collapsed.
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
MenuRegistry::register('members', [
'label_ar' => 'إدارة الأعضاء',
'label_en' => 'Member Management',
'icon' => '👥',
'route' => '/members',
'permission' => 'member.view',
'parent' => null,
'order' => 300,
'children' => [
['label_ar' => 'كل الأعضاء', 'label_en' => 'All Members', 'route' => '/members', 'permission' => 'member.view', 'order' => 1],
['label_ar' => 'عضو جديد', 'label_en' => 'New Member', 'route' => '/members/create', 'permission' => 'member.create', 'order' => 2],
['label_ar' => 'بحث الأعضاء', 'label_en' => 'Search Members', 'route' => '/members/search', 'permission' => 'member.search', 'order' => 3],
],
]);
PermissionRegistry::register('members', [
'member.view' => ['ar' => 'عرض الأعضاء', 'en' => 'View Members'],
'member.create' => ['ar' => 'إنشاء عضو', 'en' => 'Create Member'],
'member.edit' => ['ar' => 'تعديل عضو', 'en' => 'Edit Member'],
'member.archive' => ['ar' => 'أرشفة عضو', 'en' => 'Archive Member'],
'member.search' => ['ar' => 'بحث الأعضاء', 'en' => 'Search Members'],
'member.view_financial' => ['ar' => 'عرض البيانات المالية', 'en' => 'View Financial Data'],
'member.change_status' => ['ar' => 'تغيير حالة العضوية', 'en' => 'Change Member Status'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `members` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`membership_number` VARCHAR(20) NULL,
`form_number` VARCHAR(50) NULL,
`form_date` DATE NULL,
`form_submission_id` BIGINT UNSIGNED NULL,
`branch_id` BIGINT UNSIGNED NOT NULL,
`membership_type` VARCHAR(50) NOT NULL DEFAULT 'working',
`member_category` VARCHAR(50) NOT NULL DEFAULT 'working_member',
`status` VARCHAR(50) NOT NULL DEFAULT 'potential',
`full_name_ar` VARCHAR(200) NOT NULL,
`full_name_en` VARCHAR(200) NULL,
`national_id` VARCHAR(14) NULL,
`passport_number` VARCHAR(50) NULL,
`id_type` VARCHAR(20) NOT NULL DEFAULT 'national_id',
`id_issue_date` DATE NULL,
`id_expiry_date` DATE NULL,
`date_of_birth` DATE NOT NULL,
`age_years` INT UNSIGNED NULL,
`age_months` INT UNSIGNED NULL,
`gender` VARCHAR(10) NOT NULL,
`place_of_birth` VARCHAR(200) NULL,
`nationality` VARCHAR(100) NULL DEFAULT 'مصري',
`governorate_code` VARCHAR(2) NULL,
`religion` VARCHAR(50) NULL,
`qualification_id` BIGINT UNSIGNED NULL,
`marital_status` VARCHAR(30) NULL,
`phone_home` VARCHAR(20) NULL,
`phone_mobile` VARCHAR(20) NOT NULL,
`phone_international` VARCHAR(30) NULL,
`email` VARCHAR(255) NULL,
`emergency_name` VARCHAR(200) NULL,
`emergency_phone` VARCHAR(20) NULL,
`residence_type` VARCHAR(30) NULL,
`residence_address` TEXT NULL,
`landmark` VARCHAR(200) NULL,
`floor` VARCHAR(20) NULL,
`apartment` VARCHAR(20) NULL,
`area` VARCHAR(100) NULL,
`governorate` VARCHAR(100) NULL,
`correspondence_address` VARCHAR(50) NULL,
`employment_type` VARCHAR(50) NULL,
`occupation` VARCHAR(200) NULL,
`job_title` VARCHAR(200) NULL,
`employment_date` DATE NULL,
`business_address` TEXT NULL,
`office_phone` VARCHAR(20) NULL,
`office_fax` VARCHAR(20) NULL,
`business_activity` VARCHAR(200) NULL,
`membership_value` DECIMAL(15,2) NULL,
`payment_method` VARCHAR(30) NULL,
`referral_source` VARCHAR(100) NULL,
`photo_path` VARCHAR(500) NULL,
`workflow_instance_id` BIGINT UNSIGNED NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
`archived_at` TIMESTAMP NULL DEFAULT NULL,
`archived_by` BIGINT UNSIGNED NULL,
UNIQUE KEY `uq_members_membership_number` (`membership_number`),
UNIQUE KEY `uq_members_national_id` (`national_id`),
INDEX `idx_members_branch` (`branch_id`),
INDEX `idx_members_status` (`status`),
INDEX `idx_members_type` (`membership_type`),
INDEX `idx_members_name_ar` (`full_name_ar`),
INDEX `idx_members_phone` (`phone_mobile`),
INDEX `idx_members_passport` (`passport_number`),
INDEX `idx_members_form` (`form_number`),
INDEX `idx_members_archived` (`is_archived`),
INDEX `idx_members_qualification` (`qualification_id`),
INDEX `idx_members_workflow` (`workflow_instance_id`),
CONSTRAINT `fk_members_branch` FOREIGN KEY (`branch_id`) REFERENCES `branches`(`id`),
CONSTRAINT `fk_members_qualification` FOREIGN KEY (`qualification_id`) REFERENCES `qualifications`(`id`) ON DELETE SET NULL,
CONSTRAINT `fk_members_form_sub` FOREIGN KEY (`form_submission_id`) REFERENCES `form_submissions`(`id`) ON DELETE SET NULL,
CONSTRAINT `fk_members_workflow` FOREIGN KEY (`workflow_instance_id`) REFERENCES `workflow_instances`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `members`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `member_notes` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`member_id` BIGINT UNSIGNED NOT NULL,
`note_text` TEXT NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
INDEX `idx_member_notes_member` (`member_id`),
CONSTRAINT `fk_member_notes_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `member_notes`",
];
\ No newline at end of file
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