Commit b7522599 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(members): unified people search across members and dependents

Overhaul /members/search so one bar finds anyone in the club — the member,
a spouse, a child or a temporary member — and add an advanced filter panel.

MemberSearchService becomes the single source of truth for people search:

- UNIONs members + spouses + children + temporary_members into one normalised
  row per matched PERSON (person_type, relation, parent membership, rank).
- Token AND matching on names, so word order no longer matters:
  "محمود احمد" finds "أحمد سيد محمود".
- Arabic orthographic folding (أ إ آ ٱ→ا, ى→ي, ة→ه, ؤ→و, ئ→ي) applied to both
  the query and the column, so "احمد" matches "أحمد".
- Arabic-Indic and Persian digits folded to ASCII before identifier matching.
- Relevance ranking: exact membership number / national id, then name prefix,
  then substring. LIKE wildcards in user input are escaped.

Scopes (member/spouse/child/temporary) and fields (name, membership number,
national id, phone, form number, passport) are selectable; branch, membership
status and membership type filter on the parent membership. A scope whose table
lacks the requested field is skipped rather than matching nothing.

The legacy search() keeps its exact signature and output shape, so the three
existing API consumers are untouched.

Also:
- Split Member::getStatusOptions() (statuses an employee may ASSIGN) from
  getAllStatusLabels() (every status, for display/filtering). deceased,
  transferred and waived exist in live data but were missing from the list, so
  they could not be filtered on; they are deliberately kept out of the
  assignable set because the Death, Transfer and Waiver workflows own those
  transitions.
- Dependent deep links honour spouse.view / child.view / temp.view and fall
  back to the membership file when denied.
- Map children.relationship (son/daughter) and temporary_members.category
  (nanny/parent/unmarried_daughter) to Arabic for display.
- The search form submitted to /members, dropping most of what was typed; it
  now posts back to /members/search.
- Sidebar declared member.search while the route requires member.view; aligned.

Architecture Map and Dependency Graph updated per project protocol, including
the placeholder-ordering constraint in buildScopeQuery() and the three inline
member-search SQL blocks that remain unconsolidated.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent c39af584
......@@ -65,7 +65,7 @@ class MemberController extends Controller
$members = $db->select("SELECT m.*, b.name_ar as branch_name FROM members m LEFT JOIN branches b ON b.id = m.branch_id WHERE {$where} ORDER BY m.id DESC LIMIT {$perPage} OFFSET {$offset}", $params);
$pagination = \App\Core\Pagination::paginate($total, $perPage, $page);
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
return $this->view('Members.Views.index', ['members' => $members, 'branches' => $branches, 'statuses' => Member::getStatusOptions(), 'filters' => $filters, 'pagination' => $pagination]);
return $this->view('Members.Views.index', ['members' => $members, 'branches' => $branches, 'statuses' => Member::getAllStatusLabels(), 'filters' => $filters, 'pagination' => $pagination]);
}
public function create(Request $request): Response
......@@ -1141,8 +1141,56 @@ class MemberController extends Controller
public function search(Request $request): Response
{
$db = App::getInstance()->db();
$q = trim((string) $request->get('q', ''));
return $this->view('Members.Views.search', ['query' => $q, 'results' => ($q !== '' && mb_strlen($q) >= 2) ? MemberSearchService::search($q, 50) : []]);
$requestedScopes = $request->get('scopes', []);
$requestedScopes = is_array($requestedScopes) ? $requestedScopes : [];
$scopes = array_values(array_intersect(MemberSearchService::SCOPES, $requestedScopes));
$field = (string) $request->get('field', 'all');
if (!in_array($field, MemberSearchService::FIELDS, true)) {
$field = 'all';
}
$filters = [
'q' => $q,
// Empty means "search every scope"; the view renders that as all chips on.
'scopes' => $scopes,
'field' => $field,
'branch_id' => (string) $request->get('branch_id', ''),
'status' => (string) $request->get('status', ''),
'membership_type' => (string) $request->get('membership_type', ''),
];
$results = [];
if ($q !== '' && mb_strlen($q) >= 2) {
$results = MemberSearchService::searchPeople($q, [
'scopes' => $scopes === [] ? MemberSearchService::SCOPES : $scopes,
'field' => $field,
'branch_id' => $filters['branch_id'],
'status' => $filters['status'],
'membership_type' => $filters['membership_type'],
'limit' => 200,
]);
}
// Drives the "advanced search is active" state on the toggle button.
$advancedActive = $scopes !== []
|| $field !== 'all'
|| $filters['branch_id'] !== ''
|| $filters['status'] !== ''
|| $filters['membership_type'] !== '';
return $this->view('Members.Views.search', [
'query' => $q,
'filters' => $filters,
'results' => $results,
'advancedActive' => $advancedActive,
'branches' => $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar"),
'statuses' => Member::getAllStatusLabels(),
'membershipTypes' => Member::getMembershipTypes(),
]);
}
public function changelog(Request $request, string $id): Response
......
......@@ -89,6 +89,13 @@ class Member extends Model
return $colors[$this->status] ?? '#6B7280';
}
/**
* Statuses an employee may assign by hand.
*
* Deliberately EXCLUDES deceased/transferred/waived: those are owned by the
* Death, Transfer and Waiver workflows and must not be settable from a
* dropdown, or the workflow stops being the source of truth.
*/
public static function getStatusOptions(): array
{
return [
......@@ -107,6 +114,19 @@ class Member extends Model
];
}
/**
* Every status that can appear on a member, including the workflow-managed
* ones. Use for DISPLAY and FILTERING only - never as an assignment whitelist.
*/
public static function getAllStatusLabels(): array
{
return self::getStatusOptions() + [
'deceased' => 'متوفى',
'transferred' => 'منقولة',
'waived' => 'متنازل عنها',
];
}
public static function getMembershipTypes(): array
{
return [
......
......@@ -5,37 +5,391 @@ namespace App\Modules\Members\Services;
use App\Core\App;
/**
* Unified people-search engine for the membership module.
*
* Searches members and their dependents (spouses, children, temporary members)
* in one pass and returns a single normalised row shape per matched PERSON.
*
* This is the single source of truth for "find a person" inside Members.
* The legacy search() wrapper is kept intact for existing API consumers.
*/
final class MemberSearchService
{
/** Person scopes that can be searched. */
public const SCOPES = ['member', 'spouse', 'child', 'temporary'];
/** Fields the query may be matched against. */
public const FIELDS = ['all', 'name', 'membership_number', 'national_id', 'phone', 'form_number', 'passport'];
public const SCOPE_LABELS = [
'member' => 'الأعضاء',
'spouse' => 'الزوجات',
'child' => 'الأبناء',
'temporary' => 'الأعضاء المؤقتون',
];
public const FIELD_LABELS = [
'all' => 'كل الحقول',
'name' => 'الاسم',
'membership_number' => 'رقم العضوية',
'national_id' => 'الرقم القومي',
'phone' => 'رقم المحمول',
'form_number' => 'رقم الاستمارة',
'passport' => 'جواز السفر',
];
/**
* Backward-compatible member-only search.
*
* Preserves the exact column set and ordering the existing API consumers
* (MemberApiController@search and the members search screen) rely on.
*/
public static function search(string $query, int $limit = 25): array
{
$db = App::getInstance()->db();
$query = trim($query);
$rows = self::searchPeople($query, ['scopes' => ['member'], 'limit' => $limit]);
return array_map(static fn (array $r): array => [
'id' => $r['member_id'],
'full_name_ar' => $r['person_name_ar'],
'full_name_en' => $r['person_name_en'],
'national_id' => $r['national_id'],
'membership_number' => $r['membership_number'],
'phone_mobile' => $r['phone'],
'status' => $r['member_status'],
'form_number' => $r['form_number'],
'branch_name' => $r['branch_name'],
], $rows);
}
/**
* Search across members and dependents.
*
* @param array{
* scopes?: string[], field?: string, branch_id?: int|string,
* status?: string, membership_type?: string, limit?: int
* } $options
* @return array<int, array<string, mixed>> One row per matched person.
*/
public static function searchPeople(string $query, array $options = []): array
{
$query = trim($query);
if ($query === '') {
return [];
}
$like = '%' . $query . '%';
$scopes = $options['scopes'] ?? self::SCOPES;
$scopes = array_values(array_intersect(self::SCOPES, (array) $scopes));
if ($scopes === []) {
$scopes = self::SCOPES;
}
$field = (string) ($options['field'] ?? 'all');
if (!in_array($field, self::FIELDS, true)) {
$field = 'all';
}
$limit = (int) ($options['limit'] ?? 100);
$limit = max(1, min(500, $limit));
$parts = [];
$params = [];
foreach ($scopes as $scope) {
[$sql, $scopeParams] = self::buildScopeQuery($scope, $query, $field, $options);
if ($sql === null) {
continue;
}
$parts[] = $sql;
$params = array_merge($params, $scopeParams);
}
if ($parts === []) {
return [];
}
$sql = implode("\nUNION ALL\n", $parts)
. "\nORDER BY match_rank ASC, person_name_ar ASC\nLIMIT " . $limit;
return App::getInstance()->db()->select($sql, $params);
}
/**
* Build the SELECT for one scope, or null when the requested field does not
* exist on that scope's table (e.g. form_number only exists on members).
*
* @return array{0: string|null, 1: array<int, mixed>}
*/
private static function buildScopeQuery(string $scope, string $query, string $field, array $options): array
{
$config = self::scopeConfig($scope);
[$matchSql, $matchParams] = self::buildMatch($config, $query, $field);
if ($matchSql === null) {
return [null, []];
}
[$rankSql, $rankParams] = self::buildRank($config, $query);
$where = [$config['alias'] . '.is_archived = 0'];
$params = [];
// Dependents inherit the membership-level filters from their parent member.
if ($scope !== 'member') {
$where[] = 'm.is_archived = 0';
}
$filters = [
'm.branch_id' => $options['branch_id'] ?? '',
'm.status' => $options['status'] ?? '',
'm.membership_type' => $options['membership_type'] ?? '',
];
foreach ($filters as $column => $value) {
if ($value === '' || $value === null) {
continue;
}
$where[] = $column . ' = ?';
$params[] = $column === 'm.branch_id' ? (int) $value : (string) $value;
}
return $db->select(
"SELECT m.id, m.full_name_ar, m.full_name_en, m.national_id,
m.membership_number, m.phone_mobile, m.status, m.form_number,
b.name_ar as branch_name
FROM members m
$sql = "(SELECT
{$config['type_expr']} AS person_type,
{$config['alias']}.id AS person_id,
{$config['alias']}.full_name_ar AS person_name_ar,
{$config['alias']}.full_name_en AS person_name_en,
{$config['alias']}.national_id AS national_id,
{$config['alias']}.status AS person_status,
{$config['relation_expr']} AS relation_label,
m.id AS member_id,
m.membership_number AS membership_number,
m.full_name_ar AS member_name_ar,
m.status AS member_status,
m.membership_type AS membership_type,
m.branch_id AS branch_id,
b.name_ar AS branch_name,
{$config['phone_expr']} AS phone,
m.form_number AS form_number,
{$rankSql} AS match_rank
FROM {$config['table']} {$config['alias']}
{$config['join']}
LEFT JOIN branches b ON b.id = m.branch_id
WHERE m.is_archived = 0
AND (
m.full_name_ar LIKE ?
OR m.national_id LIKE ?
OR m.membership_number LIKE ?
OR m.phone_mobile LIKE ?
OR m.form_number LIKE ?
OR m.full_name_en LIKE ?
)
ORDER BY m.full_name_ar
LIMIT ?",
[$like, $like, $like, $like, $like, $like, $limit]
WHERE " . implode(' AND ', $where) . "
AND ({$matchSql}))";
// Order must mirror the placeholder order inside the SQL above:
// rank params (SELECT list) precede where/filter params, which precede match params.
return [$sql, array_merge($rankParams, $params, $matchParams)];
}
/**
* Per-scope table, join and projection differences.
*
* @return array<string, mixed>
*/
private static function scopeConfig(string $scope): array
{
return match ($scope) {
'member' => [
'table' => 'members',
'alias' => 'm',
'join' => '',
'type_expr' => "'member'",
'relation_expr' => "'العضو الأساسي'",
'phone_expr' => 'm.phone_mobile',
'has' => ['name', 'national_id', 'membership_number', 'phone', 'form_number', 'passport'],
'phone_col' => 'm.phone_mobile',
'extra_id_cols' => [],
],
'spouse' => [
'table' => 'spouses',
'alias' => 's',
'join' => 'INNER JOIN members m ON m.id = s.member_id',
'type_expr' => "'spouse'",
'relation_expr' => "CONCAT('الزوجة رقم ', s.spouse_order)",
'phone_expr' => 's.mobile',
'has' => ['name', 'national_id', 'phone', 'passport'],
'phone_col' => 's.mobile',
'extra_id_cols' => [],
],
'child' => [
'table' => 'children',
'alias' => 'c',
'join' => 'INNER JOIN members m ON m.id = c.member_id',
'type_expr' => "'child'",
'relation_expr' => 'c.relationship',
'phone_expr' => 'NULL',
'has' => ['name', 'national_id', 'passport'],
'phone_col' => null,
'extra_id_cols' => ['c.birth_certificate_number'],
],
'temporary' => [
'table' => 'temporary_members',
'alias' => 't',
'join' => 'INNER JOIN members m ON m.id = t.member_id',
'type_expr' => "'temporary'",
'relation_expr' => 't.category',
'phone_expr' => 'NULL',
'has' => ['name', 'national_id', 'passport'],
'phone_col' => null,
'extra_id_cols' => [],
],
default => throw new \InvalidArgumentException("Unknown search scope: {$scope}"),
};
}
/**
* Build the match condition for a scope.
*
* Names are matched token-by-token with AND, so "احمد محمود" matches
* "أحمد سيد محمود" and word order does not matter. Identifier fields are
* matched against the whole query.
*
* @return array{0: string|null, 1: array<int, mixed>}
*/
private static function buildMatch(array $config, string $query, string $field): array
{
$alias = $config['alias'];
$groups = [];
$params = [];
$wantsName = $field === 'all' || $field === 'name';
if ($wantsName) {
$tokens = preg_split('/\s+/u', self::normalize($query), -1, PREG_SPLIT_NO_EMPTY) ?: [];
if ($tokens !== []) {
$nameExpr = self::normalizeSql("CONCAT_WS(' ', {$alias}.full_name_ar, {$alias}.full_name_en)");
$conds = [];
foreach ($tokens as $token) {
$conds[] = "{$nameExpr} LIKE ?";
$params[] = '%' . self::escapeLike($token) . '%';
}
$groups[] = '(' . implode(' AND ', $conds) . ')';
}
}
// Identifier fields: match the query as a whole, digits normalised.
$idColumns = [];
$digits = self::normalizeDigits($query);
$wants = static fn (string $f): bool => $field === 'all' || $field === $f;
if ($wants('national_id') && in_array('national_id', $config['has'], true)) {
$idColumns[] = "{$alias}.national_id";
foreach ($config['extra_id_cols'] as $extra) {
$idColumns[] = $extra;
}
}
if ($wants('passport') && in_array('passport', $config['has'], true)) {
$idColumns[] = "{$alias}.passport_number";
}
if ($wants('membership_number') && in_array('membership_number', $config['has'], true)) {
$idColumns[] = 'm.membership_number';
}
if ($wants('form_number') && in_array('form_number', $config['has'], true)) {
$idColumns[] = 'm.form_number';
}
if ($wants('phone') && $config['phone_col'] !== null) {
$idColumns[] = $config['phone_col'];
}
foreach ($idColumns as $column) {
$groups[] = "{$column} LIKE ?";
$params[] = '%' . self::escapeLike($digits) . '%';
}
if ($groups === []) {
return [null, []];
}
return ['(' . implode(' OR ', $groups) . ')', $params];
}
/**
* Relevance rank: 0 = exact identifier hit, 1 = name starts with the query,
* 2 = anything else. Keeps the row the operator wants at the top.
*
* @return array{0: string, 1: array<int, mixed>}
*/
private static function buildRank(array $config, string $query): array
{
$alias = $config['alias'];
$digits = self::normalizeDigits($query);
$nameExpr = self::normalizeSql("{$alias}.full_name_ar");
$exactCols = ["{$alias}.national_id"];
if (in_array('membership_number', $config['has'], true)) {
$exactCols[] = 'm.membership_number';
}
$exact = implode(' OR ', array_map(static fn (string $c): string => "{$c} = ?", $exactCols));
$params = array_fill(0, count($exactCols), $digits);
$params[] = self::escapeLike(self::normalize($query)) . '%';
return ["CASE WHEN ({$exact}) THEN 0 WHEN {$nameExpr} LIKE ? THEN 1 ELSE 2 END", $params];
}
/** Raw relationship/category values as stored, mapped to Arabic for display. */
public const RELATION_LABELS = [
'son' => 'ابن',
'daughter' => 'ابنة',
'nanny' => 'مربية',
'parent' => 'والد/والدة',
'unmarried_daughter' => 'ابنة غير متزوجة',
];
/**
* Arabic display label for a matched person's relationship to the membership.
* Unknown values pass through unchanged so free-text stays readable.
*/
public static function relationLabel(string $personType, ?string $raw): string
{
if ($personType === 'member') {
return 'العضو الأساسي';
}
$raw = trim((string) $raw);
if ($raw === '') {
return $personType === 'child' ? 'ابن/ابنة' : 'تابع';
}
return self::RELATION_LABELS[$raw] ?? $raw;
}
/** Fold Arabic orthographic variants so "احمد" matches "أحمد". */
public static function normalize(string $value): string
{
$map = ['أ' => 'ا', 'إ' => 'ا', 'آ' => 'ا', 'ٱ' => 'ا', 'ى' => 'ي', 'ة' => 'ه', 'ؤ' => 'و', 'ئ' => 'ي'];
return str_replace(array_keys($map), array_values($map), self::normalizeDigits($value));
}
/** The same folding, expressed against a SQL column. */
private static function normalizeSql(string $column): string
{
$map = ['أ' => 'ا', 'إ' => 'ا', 'آ' => 'ا', 'ٱ' => 'ا', 'ى' => 'ي', 'ة' => 'ه', 'ؤ' => 'و', 'ئ' => 'ي'];
$expr = $column;
foreach ($map as $from => $to) {
$expr = "REPLACE({$expr}, '{$from}', '{$to}')";
}
return $expr;
}
/** Convert Arabic-Indic and Eastern Arabic-Indic digits to ASCII. */
public static function normalizeDigits(string $value): string
{
return str_replace(
['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩', '۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'],
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
trim($value)
);
}
/** Escape LIKE wildcards so a literal % or _ in the query does not widen it. */
private static function escapeLike(string $value): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $value);
}
}
<?php $__template->layout('Layout.main'); ?>
<?php
use App\Modules\Members\Services\MemberSearchService;
$__template->layout('Layout.main');
$filters = $filters ?? ['q' => '', 'scopes' => [], 'field' => 'all', 'branch_id' => '', 'status' => '', 'membership_type' => ''];
$results = $results ?? [];
$query = $query ?? '';
/** No scopes checked means "search everything", so render every chip as on. */
$activeScopes = $filters['scopes'] === [] ? MemberSearchService::SCOPES : $filters['scopes'];
$typeBadge = [
'member' => ['label' => 'عضو', 'class' => 'badge-primary', 'icon' => 'user'],
'spouse' => ['label' => 'زوجة', 'class' => 'badge-info', 'icon' => 'heart'],
'child' => ['label' => 'ابن/ابنة', 'class' => 'badge-success', 'icon' => 'baby'],
'temporary' => ['label' => 'عضو مؤقت', 'class' => 'badge-warning', 'icon' => 'clock'],
];
/** Dependent detail pages carry their own permission; fall back to the membership file. */
$personUrl = static function (array $row): string {
$memberUrl = '/members/' . (int) $row['member_id'];
return match ($row['person_type']) {
'spouse' => can('spouse.view') ? $memberUrl . '/spouses/' . (int) $row['person_id'] : $memberUrl,
'child' => can('child.view') ? $memberUrl . '/children/' . (int) $row['person_id'] : $memberUrl,
'temporary' => can('temp.view') ? $memberUrl . '/temporary/' . (int) $row['person_id'] : $memberUrl,
default => $memberUrl,
};
};
$statusLabels = $statuses ?? [];
?>
<?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" value="<?= e($query ?? '') ?>" class="form-input" placeholder="الاسم بالعربي، الرقم القومي، رقم العضوية، رقم المحمول، رقم الاستمارة..." style="font-size:16px;padding:12px;" autofocus>
<style>
/* This codebase has no .sr-only utility; scope one here rather than
leaking a global class from a feature view. */
.search-hero .sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.search-hero { padding: 24px; }
.search-bar { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; }
.search-bar__input { flex: 1 1 340px; font-size: 16px; padding-block: 13px; padding-inline: 14px; }
.search-bar__submit { padding-inline: 28px; white-space: nowrap; }
.adv { margin-block-start: 14px; border-block-start: 1px solid var(--border-light); padding-block-start: 14px; }
.adv > summary {
display: inline-flex; align-items: center; gap: 8px; cursor: pointer;
font-size: 13px; font-weight: 600; color: var(--brand-primary);
background: rgba(var(--brand-primary-rgb), 0.07);
border: 1px solid rgba(var(--brand-primary-rgb), 0.18);
border-radius: var(--radius-full); padding-block: 7px; padding-inline: 16px;
list-style: none; user-select: none;
}
.adv > summary::-webkit-details-marker { display: none; }
.adv > summary:hover { background: rgba(var(--brand-primary-rgb), 0.12); }
.adv > summary:focus-visible { outline: 2px solid var(--brand-primary); outline-offset: 2px; }
.adv[open] > summary .adv__caret { transform: rotate(180deg); }
.adv__caret { transition: transform .18s ease; width: 14px; height: 14px; }
.adv__dot { width: 7px; height: 7px; border-radius: 50%; background: var(--brand-primary); }
.adv__body { margin-block-start: 16px; display: grid; gap: 18px; }
.adv__legend { font-size: 12px; font-weight: 700; color: var(--text-secondary); margin-block-end: 8px; display: block; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
.chip { position: relative; }
.chip input { position: absolute; inset-inline-start: 0; inset-block-start: 0; width: 100%; height: 100%; opacity: 0; margin: 0; cursor: pointer; }
.chip span {
display: inline-flex; align-items: center; gap: 6px; pointer-events: none;
border: 1px solid var(--border-medium); border-radius: var(--radius-full);
padding-block: 7px; padding-inline: 15px; font-size: 13px; font-weight: 600;
color: var(--text-secondary); background: var(--surface-card); transition: all .15s ease;
}
.chip input:checked + span {
background: rgba(var(--brand-primary-rgb), 0.1); border-color: var(--brand-primary); color: var(--brand-primary-dark);
}
.chip input:focus-visible + span { outline: 2px solid var(--brand-primary); outline-offset: 2px; }
.chip input:checked + span::before { content: "✓"; font-weight: 700; }
.adv__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.result-name { color: var(--brand-primary); font-weight: 700; text-decoration: none; }
.result-name:hover { text-decoration: underline; }
.result-relation { display: block; font-size: 12px; color: var(--text-muted); margin-block-start: 2px; }
.mono { direction: ltr; text-align: end; font-size: 13px; font-variant-numeric: tabular-nums; }
.hint { display: grid; gap: 10px; padding: 32px 24px; text-align: center; color: var(--text-secondary); }
.hint__list { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.hint__item { font-size: 12px; background: var(--surface-bg); border: 1px solid var(--border-light); border-radius: var(--radius-full); padding-block: 5px; padding-inline: 12px; }
</style>
<div class="card search-hero">
<form method="GET" action="/members/search">
<div class="search-bar">
<label for="q" class="sr-only">بحث</label>
<input type="search" id="q" name="q" value="<?= e($query) ?>" class="form-input search-bar__input"
placeholder="ابحث بالاسم أو جزء منه، باسم الابن أو الزوجة، برقم العضوية، الرقم القومي، المحمول..."
autofocus autocomplete="off">
<button type="submit" class="btn btn-primary search-bar__submit">
<i data-lucide="search" style="width:16px;height:16px;"></i> بحث
</button>
<?php if ($query !== ''): ?>
<a href="/members/search" class="btn btn-outline">مسح</a>
<?php endif; ?>
</div>
<div style="grid-column:1/-1;">
<button type="submit" class="btn btn-primary" style="padding:12px 30px;"><i data-lucide="search" style="width:16px;height:16px;display:inline-block;vertical-align:middle;margin-left:4px;"></i> بحث</button>
<a href="/members" class="btn btn-outline">عرض كل الأعضاء</a>
<details class="adv" <?= !empty($advancedActive) ? 'open' : '' ?>>
<summary>
<i data-lucide="sliders-horizontal" style="width:14px;height:14px;"></i>
بحث متقدم
<?php if (!empty($advancedActive)): ?><span class="adv__dot" title="عوامل تصفية مفعّلة"></span><?php endif; ?>
<i data-lucide="chevron-down" class="adv__caret"></i>
</summary>
<div class="adv__body">
<fieldset style="border:0;padding:0;margin:0;">
<legend class="adv__legend">ابحث ضمن</legend>
<div class="chips">
<?php foreach (MemberSearchService::SCOPE_LABELS as $scope => $label): ?>
<label class="chip">
<input type="checkbox" name="scopes[]" value="<?= e($scope) ?>"
<?= in_array($scope, $activeScopes, true) ? 'checked' : '' ?>>
<span><?= e($label) ?></span>
</label>
<?php endforeach; ?>
</div>
</fieldset>
<div class="adv__grid">
<div class="form-group">
<label class="form-label" for="field">البحث في حقل</label>
<select id="field" name="field" class="form-select">
<?php foreach (MemberSearchService::FIELD_LABELS as $val => $label): ?>
<option value="<?= e($val) ?>" <?= $filters['field'] === $val ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label" for="branch_id">الفرع</label>
<select id="branch_id" name="branch_id" class="form-select">
<option value="">كل الفروع</option>
<?php foreach (($branches ?? []) as $b): ?>
<option value="<?= (int) $b['id'] ?>" <?= (string) $filters['branch_id'] === (string) $b['id'] ? 'selected' : '' ?>><?= e($b['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label" for="status">حالة العضوية</label>
<select id="status" name="status" class="form-select">
<option value="">كل الحالات</option>
<?php foreach ($statusLabels as $val => $label): ?>
<option value="<?= e($val) ?>" <?= $filters['status'] === $val ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label" for="membership_type">نوع العضوية</label>
<select id="membership_type" name="membership_type" class="form-select">
<option value="">كل الأنواع</option>
<?php foreach (($membershipTypes ?? []) as $val => $label): ?>
<option value="<?= e($val) ?>" <?= $filters['membership_type'] === $val ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div>
<button type="submit" class="btn btn-primary btn-sm">تطبيق البحث المتقدم</button>
<a href="/members/search<?= $query !== '' ? '?q=' . urlencode($query) : '' ?>" class="btn btn-sm btn-outline">إعادة ضبط عوامل التصفية</a>
</div>
</div>
</details>
</form>
</div>
<?php if (!empty($results)): ?>
<div class="card" style="margin-top:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#0D7377;">نتائج البحث (<?= count($results) ?>)</h3>
<div class="card" style="margin-block-start:20px;">
<div style="padding:15px 20px;border-block-end:1px solid var(--border-light);display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;">
<h3 style="margin:0;color:var(--brand-primary);font-size:16px;">نتائج البحث (<?= count($results) ?>)</h3>
<span style="font-size:12px;color:var(--text-muted);">مرتّبة حسب دقة المطابقة</span>
</div>
<div class="table-responsive">
<table class="data-table">
<thead><tr><th>رقم العضوية</th><th>الاسم</th><th>الرقم القومي</th><th>الهاتف</th><th>الفرع</th><th>الحالة</th><th>الإجراءات</th></tr></thead>
<thead>
<tr>
<th>النوع</th>
<th>الاسم</th>
<th>رقم العضوية</th>
<th>العضو الأساسي</th>
<th>الرقم القومي</th>
<th>الفرع</th>
<th>الحالة</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($results as $m): ?>
<?php foreach ($results as $row): ?>
<?php
$type = (string) $row['person_type'];
$badge = $typeBadge[$type] ?? ['label' => $type, 'class' => 'badge-neutral', 'icon' => 'user'];
$url = $personUrl($row);
?>
<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></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 style="font-size:13px;font-weight:600;"><?= e($m['status'] ?? '—') ?></td>
<td><a href="/members/<?= (int) $m['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
<td><span class="badge <?= e($badge['class']) ?>"><?= e($badge['label']) ?></span></td>
<td>
<a href="<?= e($url) ?>" class="result-name"><?= e($row['person_name_ar']) ?></a>
<span class="result-relation"><?= e(MemberSearchService::relationLabel($type, $row['relation_label'])) ?></span>
</td>
<td class="mono" style="font-weight:600;"><?= e($row['membership_number'] ?? '') ?: '—' ?></td>
<td>
<?php if ($type === 'member'): ?>
<span style="color:var(--text-muted);"></span>
<?php else: ?>
<a href="/members/<?= (int) $row['member_id'] ?>" style="color:var(--text-secondary);"><?= e($row['member_name_ar']) ?></a>
<?php endif; ?>
</td>
<td class="mono"><?= e($row['national_id'] ?? '') ?: '—' ?></td>
<td style="font-size:13px;"><?= e($row['branch_name'] ?? '') ?: '—' ?></td>
<td style="font-size:13px;">
<?= e($statusLabels[$row['member_status']] ?? $row['member_status'] ?? '—') ?>
<?php if ($type !== 'member' && ($row['person_status'] ?? '') !== ''): ?>
<span class="result-relation">الشخص: <?= e($row['person_status']) ?></span>
<?php endif; ?>
</td>
<td><a href="<?= e($url) ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php elseif (($query ?? '') !== ''): ?>
<div class="card" style="margin-top:20px;padding:40px;text-align:center;color:#6B7280;">
لا توجد نتائج لـ "<?= e($query) ?>"
<?php elseif ($query !== '' && mb_strlen($query) < 2): ?>
<div class="card" style="margin-block-start:20px;padding:40px;text-align:center;color:var(--text-secondary);">
اكتب حرفين على الأقل للبحث.
</div>
<?php elseif ($query !== ''): ?>
<div class="card" style="margin-block-start:20px;padding:40px;text-align:center;color:var(--text-secondary);">
<div style="font-weight:600;margin-block-end:6px;">لا توجد نتائج لـ «<?= e($query) ?>»</div>
<div style="font-size:13px;">جرّب جزءًا من الاسم فقط، أو وسّع نطاق البحث من «بحث متقدم».</div>
</div>
<?php else: ?>
<div class="card hint" style="margin-block-start:20px;">
<div style="font-weight:600;color:var(--text-primary);">ابحث عن أي شخص في النادي</div>
<div style="font-size:13px;">يمكنك الكتابة بأي ترتيب للكلمات — «محمود أحمد» تجد «أحمد سيد محمود».</div>
<div class="hint__list">
<span class="hint__item">اسم العضو</span>
<span class="hint__item">اسم الابن</span>
<span class="hint__item">اسم الزوجة</span>
<span class="hint__item">اسم عضو مؤقت</span>
<span class="hint__item">رقم العضوية</span>
<span class="hint__item">الرقم القومي</span>
<span class="hint__item">رقم المحمول</span>
<span class="hint__item">رقم الاستمارة</span>
<span class="hint__item">جواز السفر</span>
</div>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
......@@ -740,3 +740,47 @@ that advertises it may live in another module's `bootstrap.php`.
`php cli.php permissions:audit` reconciles all four declaration sets and exits
non-zero on drift. Run it after touching any `Routes.php`, any `bootstrap.php`
menu/permission block, or any role seed.
---
## People Search (Members → Spouses / Children / Temporary)
Added when `/members/search` became a cross-entity people search.
### Provider
`App\Modules\Members\Services\MemberSearchService` — single source of truth for
"find a person" inside Members.
### Database dependencies (read-only)
| Table | Columns relied on |
|-------|-------------------|
| `members` | full_name_ar/en, national_id, passport_number, membership_number, form_number, phone_mobile, status, membership_type, branch_id, is_archived |
| `spouses` | full_name_ar/en, national_id, passport_number, mobile, spouse_order, status, member_id, is_archived |
| `children` | full_name_ar/en, national_id, passport_number, birth_certificate_number, relationship, status, member_id, is_archived |
| `temporary_members` | full_name_ar/en, national_id, passport_number, category, status, member_id, is_archived |
| `branches` | id, name_ar, is_active |
**Cascading risk:** renaming or dropping ANY column above breaks the UNION at runtime,
not at boot. The four SELECTs must keep an identical column count and order — adding a
projected column to one scope requires adding it to all four.
### Consumers
| Consumer | Coupling |
|----------|----------|
| `/members/search` screen + `Members.Views.search` | Full row shape incl. `person_type`, `relation_label`, `match_rank` |
| `POST /api/members/search` | Legacy `search()` shape only |
| Spouses / Children / Temporary modules | Search deep-links into their `show` routes and honours `spouse.view`, `child.view`, `temp.view`; when denied it falls back to the membership file |
### Permission dependencies
Route and sidebar entry both gate on `member.search` (see the Authorization section —
these two must be changed together). Dependent deep links additionally require
`spouse.view` / `child.view` / `temp.view`; the view degrades to the membership file
rather than rendering a link that 403s.
### Configuration dependencies
None. No rule-engine keys, no settings — behaviour is entirely code-defined.
### Known divergence
`MemberController@index`, `MemberApiController@searchGet` and `MemberApiV1Controller@search`
still hold their own member-search SQL. A field added to `MemberSearchService` does NOT
appear in those three paths.
......@@ -51,7 +51,7 @@ app/Modules/Members/
│ ├── FormFeeService.php — Form fee calculations
│ ├── FormNumberGenerator.php — Form number sequencing
│ ├── MemberNumberGenerator.php — Membership number + form number assignment
│ ├── MemberSearchService.php — Unified search across members/dependents
│ ├── MemberSearchService.php — Unified PEOPLE search (members + spouses + children + temporary)
│ ├── MembershipPaymentGuard.php — SOLE AUTHORITY for activation/deactivation
│ ├── MembershipRulesService.php — All business rules (fees, eligibility, penalties)
│ ├── MembershipValidationService.php — Validate membership for access (active + subscription paid)
......@@ -129,6 +129,74 @@ app/Modules/Members/
---
## People Search Subsystem
`MemberSearchService` is the single source of truth for "find a person" inside Members.
### API
| Method | Purpose |
|--------|---------|
| `searchPeople(string $q, array $opts): array` | Primary engine. One row per matched PERSON across 4 tables. |
| `search(string $q, int $limit): array` | Legacy member-only wrapper. Delegates to `searchPeople` with `scopes=['member']` and remaps to the old column names. Kept for API consumers — do not change its output shape. |
| `relationLabel(string $type, ?string $raw): string` | Arabic label for a stored relationship value. |
| `normalize()` / `normalizeDigits()` | Arabic orthographic + digit folding. |
### How it works
- UNIONs `members`, `spouses`, `children`, `temporary_members` into one normalised
projection (`person_type, person_id, person_name_ar, …, member_id,
membership_number, member_name_ar, member_status, branch_name, match_rank`).
- **Token AND matching** on names: the query is split on whitespace and every token
must appear, so word order does not matter ("محمود احمد" finds "أحمد سيد محمود").
- **Arabic folding**: أ/إ/آ/ٱ→ا, ى→ي, ة→ه, ؤ→و, ئ→ي applied to BOTH the query (PHP)
and the column (nested SQL `REPLACE`), so "احمد" matches "أحمد".
- **Digit folding**: Arabic-Indic and Persian digits → ASCII before identifier matching.
- **`match_rank`**: 0 = exact membership number / national id, 1 = name prefix, 2 = substring.
- LIKE wildcards in user input are escaped.
### Scopes and fields
- Scopes: `member`, `spouse`, `child`, `temporary`. Empty = all.
- Fields: `all`, `name`, `membership_number`, `national_id`, `phone`, `form_number`, `passport`.
- A scope whose table lacks the requested field is skipped (children/temporary have no
phone column; only members have `form_number` / `membership_number`).
- Membership-level filters (`branch_id`, `status`, `membership_type`) always apply to the
PARENT member, so a child result respects its membership's branch.
### Placeholder ordering (fragile)
Per scope the params are appended in this exact order: **rank params (SELECT list) →
filter params (WHERE) → match params**. Changing the SELECT/WHERE order without
reordering `array_merge` in `buildScopeQuery()` silently mis-binds every row.
### Consumers
| Consumer | Uses |
|----------|------|
| `MemberController@search` (`/members/search`, gated on `member.search`) | `searchPeople()` + all filters |
| `MemberApiController@search` (POST `/api/members/search`) | legacy `search()` |
| `MemberApiController@searchGet` (GET `/api/members/search`) | own inline SQL — NOT yet consolidated |
| `MemberApiV1Controller@search` (`/api/v1/members/search`) | own inline SQL — NOT yet consolidated |
| `MemberController@index` (`/members`) | own inline SQL — NOT yet consolidated |
**Technical debt:** three inline member-search SQL blocks still exist outside the service
and have drifted (5 / 3 / 4 searchable fields vs the service's 6). Fold them into
`searchPeople()` when next touched.
### Status vocabulary (live DB)
- `members.status`: active, deceased, payment_pending, pending_cheques, potential,
transferred, under_review, waived.
- Dependents use a DIFFERENT vocabulary: spouses (active, archived, divorced, inactive,
pending_payment, transferred), children (active, archived, deceased, frozen,
pending_payment, separated), temporary (active, inactive, pending_payment).
Note `pending_payment` (dependents) vs `payment_pending` (members) — not interchangeable.
- `Member::getStatusOptions()` = statuses an employee may ASSIGN by hand. It deliberately
excludes deceased/transferred/waived, which belong to the Death, Transfer and Waiver
workflows. `Member::getAllStatusLabels()` = every status, for DISPLAY and FILTERING only.
Never use `getAllStatusLabels()` as an assignment whitelist.
### Stored relationship values
`children.relationship` = son | daughter. `temporary_members.category` = nanny | parent |
unmarried_daughter. Both are stored in English and mapped to Arabic by `relationLabel()`.
---
## Status Flow (Member Lifecycle)
```
......@@ -366,6 +434,7 @@ dropped → active (within 1 year + board approval + payment)
## Technical Debt
- No test coverage (no test framework configured)
- Three duplicate member-search SQL blocks outside `MemberSearchService` (see People Search Subsystem)
- BillingService has 800 lines with significant duplication across membership types
- MemberController::show() loads 20+ queries per page view
- reconcile() on every page view is a hidden migration — should be event-driven
......
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