Commit a1bc3213 authored by Fares's avatar Fares

feat(seasonal): collect full data for inline spouses, children, and temporary members

Inline family member forms now collect all fields matching standalone creation
forms (NID with auto-parsing, passport, DOB, gender, nationality, qualification,
contact info, etc.) instead of just names. Also adds temporary member inline creation.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 53d68f25
......@@ -54,6 +54,7 @@ class SeasonalController extends Controller
if ($existing) return $this->redirect("/members/{$memberId}")->withError('بيانات العضوية الموسمية مسجلة بالفعل');
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
$qualifications = $db->select("SELECT id, name_ar FROM qualifications WHERE is_active = 1 ORDER BY sort_order");
$spouses = $db->select("SELECT id, full_name_ar FROM spouses WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [(int) $memberId]);
$children = $db->select("SELECT id, full_name_ar, date_of_birth, gender, TIMESTAMPDIFF(YEAR, date_of_birth, CURDATE()) as age FROM children WHERE member_id = ? AND is_archived = 0", [(int) $memberId]);
......@@ -64,6 +65,7 @@ class SeasonalController extends Controller
return $this->view('Seasonal.Views.create', [
'member' => $member,
'branches' => $branches,
'qualifications' => $qualifications,
'spouses' => $spouses,
'children' => $children,
'egyptianPrices' => $egyptianPrices,
......@@ -134,14 +136,31 @@ class SeasonalController extends Controller
$newSpouses = (array) ($data['new_spouses'] ?? []);
$createdSpouseIds = [];
foreach ($newSpouses as $ns) {
$spName = trim($ns['name'] ?? '');
$spName = trim($ns['full_name_ar'] ?? '');
if ($spName === '') continue;
$spDob = (!empty($ns['date_of_birth']) && strtotime($ns['date_of_birth'])) ? $ns['date_of_birth'] : null;
$spAgeYears = $spDob ? (int) ((time() - strtotime($spDob)) / 31557600) : null;
$spAgeMonths = $spDob ? (int) (((time() - strtotime($spDob)) % 31557600) / 2629800) : null;
$sp = \App\Modules\Spouses\Models\Spouse::create([
'member_id' => (int) $memberId,
'full_name_ar' => $spName,
'spouse_order' => \App\Modules\Spouses\Models\Spouse::countActiveForMember((int) $memberId) + 1,
'status' => 'active',
'join_date' => date('Y-m-d'),
'member_id' => (int) $memberId,
'full_name_ar' => $spName,
'full_name_en' => trim($ns['full_name_en'] ?? '') ?: null,
'national_id' => trim($ns['national_id'] ?? '') ?: null,
'passport_number' => trim($ns['passport_number'] ?? '') ?: null,
'date_of_birth' => $spDob,
'age_years' => $spAgeYears,
'age_months' => $spAgeMonths,
'gender' => $ns['gender'] ?? 'female',
'nationality' => $ns['nationality'] ?? 'egyptian',
'marriage_date' => (!empty($ns['marriage_date']) && strtotime($ns['marriage_date'])) ? $ns['marriage_date'] : null,
'qualification_id' => !empty($ns['qualification_id']) ? (int) $ns['qualification_id'] : null,
'occupation' => trim($ns['occupation'] ?? '') ?: null,
'mobile' => trim($ns['mobile'] ?? '') ?: null,
'work_phone' => trim($ns['work_phone'] ?? '') ?: null,
'work_address' => trim($ns['work_address'] ?? '') ?: null,
'spouse_order' => \App\Modules\Spouses\Models\Spouse::countActiveForMember((int) $memberId) + 1,
'status' => 'active',
'join_date' => date('Y-m-d'),
]);
$createdSpouseIds[] = (int) $sp->id;
$items[] = ['person_type' => 'spouse', 'name' => $spName, 'age' => null];
......@@ -151,28 +170,70 @@ class SeasonalController extends Controller
$newChildren = (array) ($data['new_children'] ?? []);
$createdChildIds = [];
foreach ($newChildren as $nc) {
$chName = trim($nc['name'] ?? '');
$chDob = $nc['dob'] ?? '';
if ($chName === '' || !$chDob || !strtotime($chDob)) continue;
$chName = trim($nc['full_name_ar'] ?? '');
$chDob = (!empty($nc['date_of_birth']) && strtotime($nc['date_of_birth'])) ? $nc['date_of_birth'] : '';
if ($chName === '' || $chDob === '') continue;
$childAge = (int) ((time() - strtotime($chDob)) / 31557600);
$childMonths = (int) (((time() - strtotime($chDob)) % 31557600) / 2629800);
$chGender = in_array($nc['gender'] ?? '', ['male', 'female'], true) ? $nc['gender'] : 'male';
$chRelation = in_array($nc['relationship'] ?? '', ['son', 'daughter'], true) ? $nc['relationship'] : ($chGender === 'female' ? 'daughter' : 'son');
$ch = \App\Modules\Children\Models\Child::create([
'member_id' => (int) $memberId,
'child_order' => \App\Modules\Children\Models\Child::countActiveForMember((int) $memberId) + 1,
'full_name_ar' => $chName,
'date_of_birth' => $chDob,
'age_years' => $childAge,
'age_months' => $childMonths,
'gender' => 'male',
'relationship' => 'son',
'classification' => 'included',
'status' => 'active',
'join_date' => date('Y-m-d'),
'member_id' => (int) $memberId,
'child_order' => \App\Modules\Children\Models\Child::countActiveForMember((int) $memberId) + 1,
'full_name_ar' => $chName,
'full_name_en' => trim($nc['full_name_en'] ?? '') ?: null,
'national_id' => trim($nc['national_id'] ?? '') ?: null,
'passport_number' => trim($nc['passport_number'] ?? '') ?: null,
'birth_certificate_number' => trim($nc['birth_certificate_number'] ?? '') ?: null,
'date_of_birth' => $chDob,
'age_years' => $childAge,
'age_months' => $childMonths,
'gender' => $chGender,
'relationship' => $chRelation,
'nationality' => $nc['nationality'] ?? 'egyptian',
'school_faculty' => trim($nc['school_faculty'] ?? '') ?: null,
'classification' => 'included',
'status' => 'active',
'join_date' => date('Y-m-d'),
]);
$createdChildIds[] = (int) $ch->id;
$items[] = ['person_type' => 'child', 'name' => $chName, 'age' => $childAge];
}
// Add new inline temporary members
$newTemps = (array) ($data['new_temps'] ?? []);
$createdTempIds = [];
foreach ($newTemps as $nt) {
$tName = trim($nt['full_name_ar'] ?? '');
if ($tName === '') continue;
$tDob = (!empty($nt['date_of_birth']) && strtotime($nt['date_of_birth'])) ? $nt['date_of_birth'] : null;
$tAgeYears = $tDob ? (int) ((time() - strtotime($tDob)) / 31557600) : null;
$tAgeMonths = $tDob ? (int) (((time() - strtotime($tDob)) % 31557600) / 2629800) : null;
$tGender = in_array($nt['gender'] ?? '', ['male', 'female'], true) ? $nt['gender'] : 'male';
$tm = \App\Modules\Temporary\Models\TemporaryMember::create([
'member_id' => (int) $memberId,
'full_name_ar' => $tName,
'full_name_en' => trim($nt['full_name_en'] ?? '') ?: null,
'category' => $nt['category'] ?? 'relative',
'national_id' => trim($nt['national_id'] ?? '') ?: null,
'passport_number' => trim($nt['passport_number'] ?? '') ?: null,
'date_of_birth' => $tDob,
'age_years' => $tAgeYears,
'age_months' => $tAgeMonths,
'gender' => $tGender,
'nationality' => $nt['nationality'] ?? 'egyptian',
'relationship_to_member' => trim($nt['relationship_to_member'] ?? '') ?: null,
'has_championship' => !empty($nt['has_championship']) ? 1 : 0,
'disability_documentation' => !empty($nt['disability_documentation']) ? 1 : 0,
'notes' => trim($nt['notes'] ?? '') ?: null,
'status' => 'active',
'join_date' => date('Y-m-d'),
]);
$createdTempIds[] = (int) $tm->id;
$tAge = $tDob ? $tAgeYears : null;
$items[] = ['person_type' => 'child', 'name' => $tName, 'age' => $tAge ?? 20];
}
// Calculate pricing
$pricing = SeasonalPricingService::calculate($items, $nationalityType, $durationMonths, $hasQualification);
......
This diff is collapsed.
# Seasonal Module — Architecture Map
> **Last updated:** 2026-06-11
> **Last updated:** 2026-07-22
> **Status:** Living document — incrementally updated as new information is discovered
---
......@@ -9,7 +9,8 @@
The Seasonal module manages **time-limited seasonal memberships** (pool access, facility use) for members typed as 'seasonal'. As of 2026-06-11, seasonal membership is a **primary membership type** selected at creation (not a working member add-on). It:
- Handles dual-nationality pricing (Egyptian in EGP, Foreign in USD)
- Supports family group subscriptions (member + spouses + children in one batch)
- Supports family group subscriptions (member + spouses + children + temporary members in one batch)
- Allows inline creation of NEW spouses, children, and temporary members with full data collection (same fields as standalone forms)
- Calculates age-category-based pricing for children
- Applies family discounts (couple, parent+child, family of 5+)
- Applies no-qualification and qualified-member discounts
......@@ -97,8 +98,10 @@ A family seasonal subscription creates **multiple rows**:
|-------|--------------|--------------|
| members | Members | member_id FK |
| branches | Branches | branch_id FK |
| spouses | Spouses | Referenced during creation to list available spouses |
| children | Children | Referenced during creation to list available children |
| qualifications | Members | Loaded for spouse qualification dropdown in inline form |
| spouses | Spouses | Referenced during creation to list available spouses; new spouses created inline |
| children | Children | Referenced during creation to list available children; new children created inline |
| temporary_members | Temporary | New temporary members created inline during seasonal registration |
| payment_requests | Cashier | Created on store, linked via entity_type/entity_id |
---
......@@ -247,7 +250,10 @@ No dedicated permissions are registered in bootstrap.php.
1. **Menu registered externally**: The Seasonal sidebar menu item is registered by Members/bootstrap.php
2. **EventBus injection**: The bootstrap.php listener injects seasonal data into `member.profile_data` for display on member profile pages
3. **Family batch creation**: A single form submission creates N+1 records (1 parent + N family members)
4. **Payment request description includes emoji**: The breakdown stored in payment notes uses emoji characters for formatting
5. **Index limited to 100 rows**: The index query has `LIMIT 100` — large datasets will be truncated
6. **No over_60 for foreign**: Foreign pricing table does not include an over_60 category (member price applies)
7. **Status filter on index**: Supports filtering by status via query parameter `?status=active`
4. **Inline member creation**: The form creates actual Spouse/Child/TemporaryMember records in their respective tables (full data: NID parsing, passport, DOB, gender, nationality, qualification, contact info, etc.) — not just seasonal_memberships entries
5. **Payment request description includes emoji**: The breakdown stored in payment notes uses emoji characters for formatting
6. **Index limited to 100 rows**: The index query has `LIMIT 100` — large datasets will be truncated
7. **No over_60 for foreign**: Foreign pricing table does not include an over_60 category (member price applies)
8. **Status filter on index**: Supports filtering by status via query parameter `?status=active`
9. **Temporary members priced as children**: Inline temp members use `person_type='child'` for pricing with their age determining the tier
10. **NID auto-parsing in JS**: The inline forms parse Egyptian 14-digit national IDs to extract date_of_birth and gender automatically
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