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 ...@@ -54,6 +54,7 @@ class SeasonalController extends Controller
if ($existing) return $this->redirect("/members/{$memberId}")->withError('بيانات العضوية الموسمية مسجلة بالفعل'); 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"); $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]); $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]); $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 ...@@ -64,6 +65,7 @@ class SeasonalController extends Controller
return $this->view('Seasonal.Views.create', [ return $this->view('Seasonal.Views.create', [
'member' => $member, 'member' => $member,
'branches' => $branches, 'branches' => $branches,
'qualifications' => $qualifications,
'spouses' => $spouses, 'spouses' => $spouses,
'children' => $children, 'children' => $children,
'egyptianPrices' => $egyptianPrices, 'egyptianPrices' => $egyptianPrices,
...@@ -134,11 +136,28 @@ class SeasonalController extends Controller ...@@ -134,11 +136,28 @@ class SeasonalController extends Controller
$newSpouses = (array) ($data['new_spouses'] ?? []); $newSpouses = (array) ($data['new_spouses'] ?? []);
$createdSpouseIds = []; $createdSpouseIds = [];
foreach ($newSpouses as $ns) { foreach ($newSpouses as $ns) {
$spName = trim($ns['name'] ?? ''); $spName = trim($ns['full_name_ar'] ?? '');
if ($spName === '') continue; 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([ $sp = \App\Modules\Spouses\Models\Spouse::create([
'member_id' => (int) $memberId, 'member_id' => (int) $memberId,
'full_name_ar' => $spName, '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, 'spouse_order' => \App\Modules\Spouses\Models\Spouse::countActiveForMember((int) $memberId) + 1,
'status' => 'active', 'status' => 'active',
'join_date' => date('Y-m-d'), 'join_date' => date('Y-m-d'),
...@@ -151,20 +170,28 @@ class SeasonalController extends Controller ...@@ -151,20 +170,28 @@ class SeasonalController extends Controller
$newChildren = (array) ($data['new_children'] ?? []); $newChildren = (array) ($data['new_children'] ?? []);
$createdChildIds = []; $createdChildIds = [];
foreach ($newChildren as $nc) { foreach ($newChildren as $nc) {
$chName = trim($nc['name'] ?? ''); $chName = trim($nc['full_name_ar'] ?? '');
$chDob = $nc['dob'] ?? ''; $chDob = (!empty($nc['date_of_birth']) && strtotime($nc['date_of_birth'])) ? $nc['date_of_birth'] : '';
if ($chName === '' || !$chDob || !strtotime($chDob)) continue; if ($chName === '' || $chDob === '') continue;
$childAge = (int) ((time() - strtotime($chDob)) / 31557600); $childAge = (int) ((time() - strtotime($chDob)) / 31557600);
$childMonths = (int) (((time() - strtotime($chDob)) % 31557600) / 2629800); $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([ $ch = \App\Modules\Children\Models\Child::create([
'member_id' => (int) $memberId, 'member_id' => (int) $memberId,
'child_order' => \App\Modules\Children\Models\Child::countActiveForMember((int) $memberId) + 1, 'child_order' => \App\Modules\Children\Models\Child::countActiveForMember((int) $memberId) + 1,
'full_name_ar' => $chName, '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, 'date_of_birth' => $chDob,
'age_years' => $childAge, 'age_years' => $childAge,
'age_months' => $childMonths, 'age_months' => $childMonths,
'gender' => 'male', 'gender' => $chGender,
'relationship' => 'son', 'relationship' => $chRelation,
'nationality' => $nc['nationality'] ?? 'egyptian',
'school_faculty' => trim($nc['school_faculty'] ?? '') ?: null,
'classification' => 'included', 'classification' => 'included',
'status' => 'active', 'status' => 'active',
'join_date' => date('Y-m-d'), 'join_date' => date('Y-m-d'),
...@@ -173,6 +200,40 @@ class SeasonalController extends Controller ...@@ -173,6 +200,40 @@ class SeasonalController extends Controller
$items[] = ['person_type' => 'child', 'name' => $chName, 'age' => $childAge]; $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 // Calculate pricing
$pricing = SeasonalPricingService::calculate($items, $nationalityType, $durationMonths, $hasQualification); $pricing = SeasonalPricingService::calculate($items, $nationalityType, $durationMonths, $hasQualification);
......
This diff is collapsed.
# Seasonal Module — Architecture Map # Seasonal Module — Architecture Map
> **Last updated:** 2026-06-11 > **Last updated:** 2026-07-22
> **Status:** Living document — incrementally updated as new information is discovered > **Status:** Living document — incrementally updated as new information is discovered
--- ---
...@@ -9,7 +9,8 @@ ...@@ -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: 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) - 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 - Calculates age-category-based pricing for children
- Applies family discounts (couple, parent+child, family of 5+) - Applies family discounts (couple, parent+child, family of 5+)
- Applies no-qualification and qualified-member discounts - Applies no-qualification and qualified-member discounts
...@@ -97,8 +98,10 @@ A family seasonal subscription creates **multiple rows**: ...@@ -97,8 +98,10 @@ A family seasonal subscription creates **multiple rows**:
|-------|--------------|--------------| |-------|--------------|--------------|
| members | Members | member_id FK | | members | Members | member_id FK |
| branches | Branches | branch_id FK | | branches | Branches | branch_id FK |
| spouses | Spouses | Referenced during creation to list available spouses | | qualifications | Members | Loaded for spouse qualification dropdown in inline form |
| children | Children | Referenced during creation to list available children | | 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 | | payment_requests | Cashier | Created on store, linked via entity_type/entity_id |
--- ---
...@@ -247,7 +250,10 @@ No dedicated permissions are registered in bootstrap.php. ...@@ -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 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 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) 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 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. **Index limited to 100 rows**: The index query has `LIMIT 100` — large datasets will be truncated 5. **Payment request description includes emoji**: The breakdown stored in payment notes uses emoji characters for formatting
6. **No over_60 for foreign**: Foreign pricing table does not include an over_60 category (member price applies) 6. **Index limited to 100 rows**: The index query has `LIMIT 100` — large datasets will be truncated
7. **Status filter on index**: Supports filtering by status via query parameter `?status=active` 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