Commit 4eea7873 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(members): add Archive screen for browsing archived members

New member-centric archive view inside شئون العضوية:
- Searchable listing with filters (name, number, NID, phone, status, date, operator)
- Detailed show page with member data, dependents, financials, number chain
- Linked member navigation (old  new after transfer/waiver/death)
- Audit trail display with before/after field changes
- Sidebar with archive info, snapshots, and number chain timeline
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 552ca604
<?php
declare(strict_types=1);
namespace App\Modules\Members\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Archive\Services\ArchiveService;
class MemberArchiveController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$filters = [
'search' => trim((string) $request->get('q', '')),
'status' => $request->get('status', ''),
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'number' => trim((string) $request->get('number', '')),
'nid' => trim((string) $request->get('nid', '')),
'phone' => trim((string) $request->get('phone', '')),
'operator' => trim((string) $request->get('operator', '')),
];
$where = ['m.is_archived = 1'];
$params = [];
if ($filters['search'] !== '') {
$where[] = "(m.full_name_ar LIKE ? OR m.full_name_en LIKE ?)";
$params[] = '%' . $filters['search'] . '%';
$params[] = '%' . $filters['search'] . '%';
}
if ($filters['status'] !== '') {
$where[] = "m.status = ?";
$params[] = $filters['status'];
}
if ($filters['date_from'] !== '') {
$where[] = "m.archived_at >= ?";
$params[] = $filters['date_from'] . ' 00:00:00';
}
if ($filters['date_to'] !== '') {
$where[] = "m.archived_at <= ?";
$params[] = $filters['date_to'] . ' 23:59:59';
}
if ($filters['number'] !== '') {
$where[] = "(a.membership_number = ? OR m.membership_number = ?)";
$params[] = $filters['number'];
$params[] = $filters['number'];
}
if ($filters['nid'] !== '') {
$where[] = "m.national_id LIKE ?";
$params[] = '%' . $filters['nid'] . '%';
}
if ($filters['phone'] !== '') {
$where[] = "(m.phone_mobile LIKE ? OR m.phone_home LIKE ?)";
$params[] = '%' . $filters['phone'] . '%';
$params[] = '%' . $filters['phone'] . '%';
}
if ($filters['operator'] !== '') {
$where[] = "e.full_name_ar LIKE ?";
$params[] = '%' . $filters['operator'] . '%';
}
$whereClause = implode(' AND ', $where);
$page = max(1, (int) $request->get('page', 1));
$perPage = 25;
$offset = ($page - 1) * $perPage;
$countSql = "SELECT COUNT(*) as total FROM members m
LEFT JOIN archive_snapshots a ON a.entity_type = 'members' AND a.entity_id = m.id
LEFT JOIN employees e ON e.id = m.archived_by
WHERE {$whereClause}";
$total = (int) ($db->selectOne($countSql, $params)['total'] ?? 0);
$sql = "SELECT m.id, m.full_name_ar, m.national_id, m.phone_mobile, m.membership_type, m.status,
m.archived_at, m.archived_by,
a.membership_number as archived_membership_number, a.snapshot_reason, a.id as snapshot_id,
e.full_name_ar as operator_name
FROM members m
LEFT JOIN archive_snapshots a ON a.entity_type = 'members' AND a.entity_id = m.id
LEFT JOIN employees e ON e.id = m.archived_by
WHERE {$whereClause}
GROUP BY m.id
ORDER BY m.archived_at DESC
LIMIT {$perPage} OFFSET {$offset}";
$rows = $db->select($sql, $params);
$lastPage = max(1, (int) ceil($total / $perPage));
$pagination = [
'current_page' => $page,
'last_page' => $lastPage,
'total' => $total,
'has_prev' => $page > 1,
'has_next' => $page < $lastPage,
'prev_page' => $page - 1,
'next_page' => $page + 1,
'pages' => self::paginateRange($page, $lastPage),
];
return $this->view('Members.Views.archive_index', [
'rows' => $rows,
'pagination' => $pagination,
'filters' => $filters,
'total' => $total,
]);
}
public function show(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 1", [(int) $id]);
if (!$member) return $this->redirect('/members/archive')->withError('العضو المؤرشف غير موجود');
// Get all snapshots for this member
$snapshots = $db->select(
"SELECT * FROM archive_snapshots WHERE entity_type = 'members' AND entity_id = ? ORDER BY snapshot_taken_at DESC",
[(int) $id]
);
// Primary snapshot (most recent)
$primarySnapshot = null;
if (!empty($snapshots)) {
$primarySnapshot = ArchiveService::getSnapshot((int) $snapshots[0]['id']);
}
// Get number chain if membership number exists in snapshot
$numberChain = [];
$membershipNumber = $primarySnapshot['membership_number'] ?? null;
if ($membershipNumber) {
$numberChain = ArchiveService::getNumberChain($membershipNumber);
}
// Get linked new member (who received the number)
$linkedMember = null;
if ($membershipNumber) {
$currentHolder = ArchiveService::getCurrentHolder($membershipNumber);
if ($currentHolder && (int) $currentHolder['holder_entity_id'] !== (int) $id) {
$linkedMember = $db->selectOne(
"SELECT id, full_name_ar, membership_number, status FROM members WHERE id = ?",
[(int) $currentHolder['holder_entity_id']]
);
}
}
// Get audit trail for this member
$auditTrail = $db->select(
"SELECT * FROM audit_trail WHERE entity_type = 'members' AND entity_id = ? ORDER BY created_at DESC LIMIT 50",
[(int) $id]
);
// Dependents from snapshot related_data or live data
$dependents = [
'spouses' => $db->select("SELECT * FROM spouses WHERE member_id = ?", [(int) $id]),
'children' => $db->select("SELECT * FROM children WHERE member_id = ?", [(int) $id]),
'temporary' => $db->select("SELECT * FROM temporary_members WHERE member_id = ?", [(int) $id]),
];
// Operator info
$operator = null;
if ($member['archived_by']) {
$operator = $db->selectOne("SELECT id, full_name_ar FROM employees WHERE id = ?", [(int) $member['archived_by']]);
}
return $this->view('Members.Views.archive_show', [
'member' => $member,
'snapshots' => $snapshots,
'primarySnapshot' => $primarySnapshot,
'numberChain' => $numberChain,
'linkedMember' => $linkedMember,
'auditTrail' => $auditTrail,
'dependents' => $dependents,
'operator' => $operator,
'membershipNumber' => $membershipNumber,
]);
}
private static function paginateRange(int $current, int $last): array
{
if ($last <= 7) return range(1, $last);
$pages = [1];
$start = max(2, $current - 2);
$end = min($last - 1, $current + 2);
if ($start > 2) $pages[] = '...';
for ($i = $start; $i <= $end; $i++) $pages[] = $i;
if ($end < $last - 1) $pages[] = '...';
$pages[] = $last;
return $pages;
}
}
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
declare(strict_types=1); declare(strict_types=1);
return [ return [
['GET', '/members/archive', 'Members\Controllers\MemberArchiveController@index', ['auth'], 'member.archive'],
['GET', '/members/archive/{id:\d+}', 'Members\Controllers\MemberArchiveController@show', ['auth'], 'member.archive'],
['GET', '/members', 'Members\Controllers\MemberController@index', ['auth'], 'member.view'], ['GET', '/members', 'Members\Controllers\MemberController@index', ['auth'], 'member.view'],
['GET', '/members/create', 'Members\Controllers\MemberController@create', ['auth'], 'member.create'], ['GET', '/members/create', 'Members\Controllers\MemberController@create', ['auth'], 'member.create'],
['POST', '/members', 'Members\Controllers\MemberController@store', ['auth', 'csrf'], 'member.create'], ['POST', '/members', 'Members\Controllers\MemberController@store', ['auth', 'csrf'], 'member.create'],
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>أرشيف الأعضاء<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$statusLabels = ['deceased' => 'وفاة', 'transferred' => 'تحويل', 'waived' => 'تنازل', 'divorced' => 'طلاق', 'cancelled' => 'إلغاء'];
$statusColors = ['deceased' => '#6B7280', 'transferred' => '#0284C7', 'waived' => '#7C3AED', 'divorced' => '#DC2626', 'cancelled' => '#D97706'];
?>
<!-- Header -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<div>
<h2 style="margin:0;color:#1A1A2E;">أرشيف الأعضاء</h2>
<p style="margin:4px 0 0;font-size:13px;color:#6B7280;">جميع العضويات المؤرشفة — وفاة، تنازل، تحويل، طلاق، إلغاء (<?= number_format($total) ?> سجل)</p>
</div>
</div>
<!-- Search & Filters -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/members/archive" 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['search'] ?? '') ?>" placeholder="بحث بالاسم..." class="form-input" style="min-width:150px;">
</div>
<div>
<label class="form-label" style="font-size:12px;">رقم العضوية</label>
<input type="text" name="number" value="<?= e($filters['number'] ?? '') ?>" placeholder="1001" class="form-input" style="min-width:100px;">
</div>
<div>
<label class="form-label" style="font-size:12px;">الرقم القومي</label>
<input type="text" name="nid" value="<?= e($filters['nid'] ?? '') ?>" placeholder="الرقم القومي" class="form-input" style="min-width:130px;">
</div>
<div>
<label class="form-label" style="font-size:12px;">الهاتف</label>
<input type="text" name="phone" value="<?= e($filters['phone'] ?? '') ?>" placeholder="رقم الهاتف" class="form-input" style="min-width:110px;">
</div>
<div>
<label class="form-label" style="font-size:12px;">نوع الأرشفة</label>
<select name="status" class="form-select" style="min-width:100px;">
<option value="">الكل</option>
<?php foreach ($statusLabels as $k => $v): ?>
<option value="<?= e($k) ?>" <?= ($filters['status'] ?? '') === $k ? 'selected' : '' ?>><?= e($v) ?></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>
<div>
<label class="form-label" style="font-size:12px;">الموظف المنفذ</label>
<input type="text" name="operator" value="<?= e($filters['operator'] ?? '') ?>" placeholder="اسم الموظف" class="form-input" style="min-width:120px;">
</div>
<button type="submit" class="btn btn-primary">بحث</button>
<a href="/members/archive" class="btn btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<!-- Results Table -->
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th style="width:50px;">#</th>
<th>العضو</th>
<th>رقم العضوية</th>
<th>الرقم القومي</th>
<th>نوع الأرشفة</th>
<th>السبب</th>
<th>تاريخ الأرشفة</th>
<th>بواسطة</th>
<th style="width:100px;">الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $r): ?>
<?php $color = $statusColors[$r['status']] ?? '#6B7280'; ?>
<tr>
<td><?= (int) $r['id'] ?></td>
<td>
<a href="/members/archive/<?= (int) $r['id'] ?>" style="color:#0D7377;font-weight:600;text-decoration:none;">
<?= e($r['full_name_ar'] ?? '') ?>
</a>
<?php if ($r['phone_mobile']): ?>
<div style="font-size:11px;color:#6B7280;"><?= e($r['phone_mobile']) ?></div>
<?php endif; ?>
</td>
<td>
<?php if ($r['archived_membership_number']): ?>
<a href="/archive/number-chain/<?= urlencode($r['archived_membership_number']) ?>" style="color:#0D7377;font-weight:700;"><?= e($r['archived_membership_number']) ?></a>
<?php else: ?>
<span style="color:#9CA3AF;"></span>
<?php endif; ?>
</td>
<td style="font-size:12px;font-family:monospace;"><?= e($r['national_id'] ?? '—') ?></td>
<td>
<span style="color:<?= $color ?>;font-weight:600;font-size:13px;display:inline-flex;align-items:center;gap:4px;">
<span style="width:8px;height:8px;border-radius:50%;background:<?= $color ?>;display:inline-block;"></span>
<?= e($statusLabels[$r['status']] ?? $r['status']) ?>
</span>
</td>
<td style="font-size:12px;color:#6B7280;"><?= e($r['snapshot_reason'] ?? '—') ?></td>
<td style="font-size:12px;white-space:nowrap;"><?= $r['archived_at'] ? arabic_date($r['archived_at']) : '—' ?></td>
<td style="font-size:12px;"><?= e($r['operator_name'] ?? 'النظام') ?></td>
<td>
<a href="/members/archive/<?= (int) $r['id'] ?>" class="btn btn-sm btn-outline">عرض</a>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?>
<tr><td colspan="9" style="text-align:center;padding:50px;color:#6B7280;">لا توجد سجلات مؤرشفة مطابقة لمعايير البحث</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if (($pagination['last_page'] ?? 1) > 1): ?>
<div style="padding:15px;display:flex;justify-content:center;">
<nav>
<ul style="display:flex;gap:5px;list-style:none;padding:0;margin:0;">
<?php if ($pagination['has_prev'] ?? false): ?>
<li><a href="?page=<?= $pagination['prev_page'] ?>&<?= http_build_query(array_diff_key($filters, ['page' => ''])) ?>" class="btn btn-sm btn-outline">السابق</a></li>
<?php endif; ?>
<?php foreach ($pagination['pages'] ?? [] as $p): ?>
<?php if ($p === '...'): ?>
<li style="padding:6px 10px;color:#6B7280;">...</li>
<?php else: ?>
<li><a href="?page=<?= $p ?>&<?= http_build_query(array_diff_key($filters, ['page' => ''])) ?>" class="btn btn-sm <?= $p === ($pagination['current_page'] ?? 1) ? 'btn-primary' : 'btn-outline' ?>"><?= $p ?></a></li>
<?php endif; ?>
<?php endforeach; ?>
<?php if ($pagination['has_next'] ?? false): ?>
<li><a href="?page=<?= $pagination['next_page'] ?>&<?= http_build_query(array_diff_key($filters, ['page' => ''])) ?>" class="btn btn-sm btn-outline">التالي</a></li>
<?php endif; ?>
</ul>
</nav>
</div>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
This diff is collapsed.
...@@ -38,6 +38,8 @@ MenuRegistry::register('membership', [ ...@@ -38,6 +38,8 @@ MenuRegistry::register('membership', [
['label_ar' => 'حالات الطلاق', 'label_en' => 'Divorce Cases', 'route' => '/divorce', 'permission' => 'transfer.view', 'order' => 41], ['label_ar' => 'حالات الطلاق', 'label_en' => 'Divorce Cases', 'route' => '/divorce', 'permission' => 'transfer.view', 'order' => 41],
['label_ar' => 'حالات الوفاة', 'label_en' => 'Death Cases', 'route' => '/death', 'permission' => 'transfer.view', 'order' => 42], ['label_ar' => 'حالات الوفاة', 'label_en' => 'Death Cases', 'route' => '/death', 'permission' => 'transfer.view', 'order' => 42],
['label_ar' => 'طلبات التنازل', 'label_en' => 'Waiver Requests', 'route' => '/waivers', 'permission' => 'waiver.view', 'order' => 43], ['label_ar' => 'طلبات التنازل', 'label_en' => 'Waiver Requests', 'route' => '/waivers', 'permission' => 'waiver.view', 'order' => 43],
// ── Archive ─────────────────────────────────
['label_ar' => 'الأرشيف', 'label_en' => 'Archive', 'route' => '/members/archive', 'permission' => 'member.archive', 'order' => 45],
// ── Reports ───────────────────────────────── // ── Reports ─────────────────────────────────
['label_ar' => 'التقارير', 'label_en' => 'Reports', 'route' => '/reports', 'permission' => 'member.reports', 'order' => 50], ['label_ar' => 'التقارير', 'label_en' => 'Reports', 'route' => '/reports', 'permission' => 'member.reports', 'order' => 50],
], ],
......
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