From c2f0047ed98614a5221380fb59c9d205920bd67b Mon Sep 17 00:00:00 2001 From: gbanyan Date: Sun, 25 Jan 2026 05:52:40 +0800 Subject: [PATCH] Add personal application fields to members --- app/Console/Commands/ImportMembers.php | 28 ++- app/Console/Commands/ImportMembersCommand.php | 129 +++++++++-- app/Http/Requests/StoreMemberRequest.php | 11 + app/Http/Requests/UpdateMemberRequest.php | 17 ++ app/Models/Member.php | 17 ++ ...10_add_profile_fields_to_members_table.php | 48 +++++ .../views/admin/members/create.blade.php | 204 +++++++++++++++++- resources/views/admin/members/edit.blade.php | 204 +++++++++++++++++- .../views/admin/members/import.blade.php | 12 ++ resources/views/admin/members/show.blade.php | 86 ++++++++ 10 files changed, 729 insertions(+), 27 deletions(-) create mode 100644 database/migrations/2026_01_25_054310_add_profile_fields_to_members_table.php diff --git a/app/Console/Commands/ImportMembers.php b/app/Console/Commands/ImportMembers.php index 9c2ab31..818e48d 100644 --- a/app/Console/Commands/ImportMembers.php +++ b/app/Console/Commands/ImportMembers.php @@ -46,7 +46,7 @@ class ImportMembers extends Command $header = array_map('trim', $header); - $expected = [ + $required = [ 'full_name', 'email', 'phone', @@ -60,7 +60,7 @@ class ImportMembers extends Command 'membership_expires_at', ]; - foreach ($expected as $column) { + foreach ($required as $column) { if (! in_array($column, $header, true)) { $this->error("Missing required column: {$column}"); fclose($handle); @@ -82,8 +82,19 @@ class ImportMembers extends Command } $fullName = trim($row[$indexes['full_name']] ?? ''); - $nationalId = trim($row[$indexes['national_id']] ?? ''); + $memberNumber = isset($indexes['member_number']) ? trim($row[$indexes['member_number']] ?? '') : ''; + $nationalId = isset($indexes['national_id']) ? trim($row[$indexes['national_id']] ?? '') : ''; $phone = trim($row[$indexes['phone']] ?? ''); + $phoneHome = isset($indexes['phone_home']) ? trim($row[$indexes['phone_home']] ?? '') : ''; + $phoneFax = isset($indexes['phone_fax']) ? trim($row[$indexes['phone_fax']] ?? '') : ''; + $birthDate = isset($indexes['birth_date']) ? trim($row[$indexes['birth_date']] ?? '') : ''; + $gender = isset($indexes['gender']) ? trim($row[$indexes['gender']] ?? '') : ''; + $identityType = isset($indexes['identity_type']) ? trim($row[$indexes['identity_type']] ?? '') : ''; + $identityOtherText = isset($indexes['identity_other_text']) ? trim($row[$indexes['identity_other_text']] ?? '') : ''; + $occupation = isset($indexes['occupation']) ? trim($row[$indexes['occupation']] ?? '') : ''; + $employer = isset($indexes['employer']) ? trim($row[$indexes['employer']] ?? '') : ''; + $jobTitle = isset($indexes['job_title']) ? trim($row[$indexes['job_title']] ?? '') : ''; + $appliedAt = isset($indexes['applied_at']) ? trim($row[$indexes['applied_at']] ?? '') : ''; $started = trim($row[$indexes['membership_started_at']] ?? ''); $expires = trim($row[$indexes['membership_expires_at']] ?? ''); $address1 = trim($row[$indexes['address_line_1']] ?? ''); @@ -109,10 +120,21 @@ class ImportMembers extends Command $member = Member::updateOrCreate( ['user_id' => $user->id], [ + 'member_number' => $memberNumber !== '' ? $memberNumber : null, 'full_name' => $fullName !== '' ? $fullName : $user->name, 'email' => $email, 'national_id' => $nationalId !== '' ? $nationalId : null, 'phone' => $phone !== '' ? $phone : null, + 'phone_home' => $phoneHome !== '' ? $phoneHome : null, + 'phone_fax' => $phoneFax !== '' ? $phoneFax : null, + 'birth_date' => $birthDate !== '' ? $birthDate : null, + 'gender' => $gender !== '' ? $gender : null, + 'identity_type' => $identityType !== '' ? $identityType : null, + 'identity_other_text' => $identityOtherText !== '' ? $identityOtherText : null, + 'occupation' => $occupation !== '' ? $occupation : null, + 'employer' => $employer !== '' ? $employer : null, + 'job_title' => $jobTitle !== '' ? $jobTitle : null, + 'applied_at' => $appliedAt !== '' ? $appliedAt : null, 'address_line_1' => $address1 ?: null, 'address_line_2' => $address2 ?: null, 'city' => $city ?: null, diff --git a/app/Console/Commands/ImportMembersCommand.php b/app/Console/Commands/ImportMembersCommand.php index a480763..15fc2b3 100644 --- a/app/Console/Commands/ImportMembersCommand.php +++ b/app/Console/Commands/ImportMembersCommand.php @@ -9,6 +9,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use PhpOffice\PhpSpreadsheet\IOFactory; +use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate; class ImportMembersCommand extends Command { @@ -78,11 +79,17 @@ class ImportMembersCommand extends Command continue; } - $phone = $this->normalizePhone($row[6] ?? ''); - $email = $this->resolveEmail($name, $row[10] ?? ''); - $nationalId = trim($row[7] ?? ''); + $memberNumber = $this->normalizeMemberNumber($row[0] ?? ''); + $birthDate = $this->normalizeRocDate($row[2] ?? ''); + $gender = $this->normalizeGender($row[3] ?? ''); + $occupation = trim($row[4] ?? ''); $address = trim($row[5] ?? ''); - $memberType = $this->mapMemberType($row[8] ?? ''); + $phone = $this->normalizePhone($row[6] ?? ''); + $nationalId = trim($row[7] ?? ''); + [$identityType, $identityOtherText] = $this->parseIdentityType($row[8] ?? ''); + $applyDate = $this->normalizeRocDate($row[9] ?? ''); + $email = $this->resolveEmail($name, $row[10] ?? ''); + $memberType = Member::TYPE_INDIVIDUAL; // Validate phone for password generation if (strlen($phone) < 4) { @@ -102,6 +109,15 @@ class ImportMembersCommand extends Command continue; } + if ($memberNumber) { + $existingMemberNumber = Member::where('member_number', $memberNumber)->first(); + if ($existingMemberNumber) { + $this->warn("Row {$rowNum}: {$name} - Member number already exists: {$memberNumber}"); + $skipped++; + continue; + } + } + // Check for existing member by national ID if ($nationalId) { $existingMember = Member::where('national_id_hash', hash('sha256', $nationalId))->first(); @@ -128,13 +144,24 @@ class ImportMembersCommand extends Command // Create Member $member = Member::create([ 'user_id' => $user->id, + 'member_number' => $memberNumber ?: null, 'full_name' => $name, 'email' => $email, 'phone' => $phone, + 'phone_home' => null, + 'phone_fax' => null, 'address_line_1' => $address, 'national_id' => $nationalId ?: null, 'membership_status' => Member::STATUS_ACTIVE, 'membership_type' => $memberType, + 'identity_type' => $identityType, + 'identity_other_text' => $identityOtherText ?: null, + 'birth_date' => $birthDate, + 'gender' => $gender, + 'occupation' => $occupation ?: null, + 'employer' => null, + 'job_title' => null, + 'applied_at' => $applyDate, 'membership_started_at' => now(), 'membership_expires_at' => now()->endOfYear(), // 2026-12-31 ]); @@ -277,18 +304,96 @@ class ImportMembersCommand extends Command return $phone; } - protected function mapMemberType(string $type): string + protected function normalizeMemberNumber(mixed $value): ?string { - // Map Chinese member types to constants - $type = trim($type); - return match ($type) { - '榮譽會員' => Member::TYPE_HONORARY, - '終身會員' => Member::TYPE_LIFETIME, - '學生會員' => Member::TYPE_STUDENT, - default => Member::TYPE_REGULAR, // 患者, 家屬, etc. + if ($value === null || $value === '') { + return null; + } + + $raw = is_numeric($value) ? (string) (int) $value : trim((string) $value); + return $raw !== '' ? $raw : null; + } + + protected function normalizeGender(mixed $value): ?string + { + $value = trim((string) $value); + return match ($value) { + '男' => 'male', + '女' => 'female', + '其他' => 'other', + default => null, }; } + protected function normalizeRocDate(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d'); + } + + if (is_numeric($value)) { + $numeric = (float) $value; + if ($numeric > 10000) { + return ExcelDate::excelToDateTimeObject($numeric)->format('Y-m-d'); + } + } + + $raw = trim((string) $value); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) { + return $raw; + } + + if (preg_match('/^\d{4}\/\d{2}\/\d{2}$/', $raw)) { + return str_replace('/', '-', $raw); + } + + $digits = preg_replace('/\D/', '', $raw); + if (strlen($digits) === 6 || strlen($digits) === 7) { + $yearLength = strlen($digits) - 4; + $rocYear = (int) substr($digits, 0, $yearLength); + $month = (int) substr($digits, $yearLength, 2); + $day = (int) substr($digits, $yearLength + 2, 2); + $year = $rocYear + 1911; + + if (checkdate($month, $day, $year)) { + return sprintf('%04d-%02d-%02d', $year, $month, $day); + } + } + + return null; + } + + protected function parseIdentityType(mixed $value): array + { + $raw = trim((string) $value); + + if ($raw === '') { + return [null, null]; + } + + if (str_contains($raw, '病友')) { + return [Member::IDENTITY_PATIENT, null]; + } + + if (str_contains($raw, '父母') || str_contains($raw, '家長')) { + return [Member::IDENTITY_PARENT, null]; + } + + if (str_contains($raw, '社會') || str_contains($raw, '學者') || str_contains($raw, '醫師')) { + return [Member::IDENTITY_SOCIAL, null]; + } + + if (str_contains($raw, '其他')) { + return [Member::IDENTITY_OTHER, $raw]; + } + + return [Member::IDENTITY_OTHER, $raw]; + } + protected function toPinyin(string $name): string { // Simple romanization for email generation diff --git a/app/Http/Requests/StoreMemberRequest.php b/app/Http/Requests/StoreMemberRequest.php index 5e70426..6dfc775 100644 --- a/app/Http/Requests/StoreMemberRequest.php +++ b/app/Http/Requests/StoreMemberRequest.php @@ -22,10 +22,21 @@ class StoreMemberRequest extends FormRequest public function rules(): array { return [ + 'member_number' => ['nullable', 'string', 'max:50', 'unique:members,member_number'], 'full_name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'national_id' => ['nullable', 'string', 'max:50'], 'phone' => ['nullable', 'string', 'max:50'], + 'phone_home' => ['nullable', 'string', 'max:50'], + 'phone_fax' => ['nullable', 'string', 'max:50'], + 'birth_date' => ['nullable', 'date'], + 'gender' => ['nullable', 'in:male,female,other'], + 'identity_type' => ['nullable', 'in:patient,parent,social,other'], + 'identity_other_text' => ['nullable', 'string', 'max:255', 'required_if:identity_type,other'], + 'occupation' => ['nullable', 'string', 'max:120'], + 'employer' => ['nullable', 'string', 'max:255'], + 'job_title' => ['nullable', 'string', 'max:120'], + 'applied_at' => ['nullable', 'date'], 'address_line_1' => ['nullable', 'string', 'max:255'], 'address_line_2' => ['nullable', 'string', 'max:255'], 'city' => ['nullable', 'string', 'max:120'], diff --git a/app/Http/Requests/UpdateMemberRequest.php b/app/Http/Requests/UpdateMemberRequest.php index 320bc0a..bd9c3e5 100644 --- a/app/Http/Requests/UpdateMemberRequest.php +++ b/app/Http/Requests/UpdateMemberRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class UpdateMemberRequest extends FormRequest { @@ -22,10 +23,26 @@ class UpdateMemberRequest extends FormRequest public function rules(): array { return [ + 'member_number' => [ + 'nullable', + 'string', + 'max:50', + Rule::unique('members', 'member_number')->ignore($this->member), + ], 'full_name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255'], 'national_id' => ['nullable', 'string', 'max:50'], 'phone' => ['nullable', 'string', 'max:50'], + 'phone_home' => ['nullable', 'string', 'max:50'], + 'phone_fax' => ['nullable', 'string', 'max:50'], + 'birth_date' => ['nullable', 'date'], + 'gender' => ['nullable', 'in:male,female,other'], + 'identity_type' => ['nullable', 'in:patient,parent,social,other'], + 'identity_other_text' => ['nullable', 'string', 'max:255', 'required_if:identity_type,other'], + 'occupation' => ['nullable', 'string', 'max:120'], + 'employer' => ['nullable', 'string', 'max:255'], + 'job_title' => ['nullable', 'string', 'max:120'], + 'applied_at' => ['nullable', 'date'], 'address_line_1' => ['nullable', 'string', 'max:255'], 'address_line_2' => ['nullable', 'string', 'max:255'], 'city' => ['nullable', 'string', 'max:120'], diff --git a/app/Models/Member.php b/app/Models/Member.php index a81e678..9e0cb4e 100644 --- a/app/Models/Member.php +++ b/app/Models/Member.php @@ -33,18 +33,30 @@ class Member extends Model // Identity type constants (病友/家長) const IDENTITY_PATIENT = 'patient'; // 病友 const IDENTITY_PARENT = 'parent'; // 家長/父母 + const IDENTITY_SOCIAL = 'social'; // 社會人士 + const IDENTITY_OTHER = 'other'; // 其他 protected $fillable = [ 'user_id', + 'member_number', 'full_name', 'email', 'phone', + 'phone_home', + 'phone_fax', 'address_line_1', 'address_line_2', 'city', 'postal_code', + 'birth_date', + 'gender', + 'occupation', + 'employer', + 'job_title', + 'applied_at', 'emergency_contact_name', 'emergency_contact_phone', + 'national_id', 'national_id_encrypted', 'national_id_hash', 'membership_started_at', @@ -52,6 +64,7 @@ class Member extends Model 'membership_status', 'membership_type', 'identity_type', + 'identity_other_text', 'guardian_member_id', 'disability_certificate_path', 'disability_certificate_status', @@ -63,6 +76,8 @@ class Member extends Model protected $casts = [ 'membership_started_at' => 'date', 'membership_expires_at' => 'date', + 'birth_date' => 'date', + 'applied_at' => 'date', 'disability_verified_at' => 'datetime', ]; @@ -118,6 +133,8 @@ class Member extends Model return match ($this->identity_type) { self::IDENTITY_PATIENT => '病友', self::IDENTITY_PARENT => '家長/父母', + self::IDENTITY_SOCIAL => '社會人士', + self::IDENTITY_OTHER => $this->identity_other_text ? '其他(' . $this->identity_other_text . ')' : '其他', default => '未設定', }; } diff --git a/database/migrations/2026_01_25_054310_add_profile_fields_to_members_table.php b/database/migrations/2026_01_25_054310_add_profile_fields_to_members_table.php new file mode 100644 index 0000000..a46aca7 --- /dev/null +++ b/database/migrations/2026_01_25_054310_add_profile_fields_to_members_table.php @@ -0,0 +1,48 @@ +string('member_number', 50)->unique()->nullable(); + $table->date('birth_date')->nullable(); + $table->string('gender', 20)->nullable(); + $table->string('occupation', 120)->nullable(); + $table->string('employer', 255)->nullable(); + $table->string('job_title', 120)->nullable(); + $table->date('applied_at')->nullable(); + $table->string('phone_home', 50)->nullable(); + $table->string('phone_fax', 50)->nullable(); + $table->string('identity_other_text', 255)->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('members', function (Blueprint $table) { + $table->dropColumn([ + 'member_number', + 'birth_date', + 'gender', + 'occupation', + 'employer', + 'job_title', + 'applied_at', + 'phone_home', + 'phone_fax', + 'identity_other_text', + ]); + }); + } +}; diff --git a/resources/views/admin/members/create.blade.php b/resources/views/admin/members/create.blade.php index bdb64a1..994d274 100644 --- a/resources/views/admin/members/create.blade.php +++ b/resources/views/admin/members/create.blade.php @@ -37,6 +37,22 @@ @enderror +
+ + + @error('member_number') +

{{ $message }}

+ @enderror +
+
+
+
+ + + @error('birth_date') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('gender') +

{{ $message }}

+ @enderror +
+
+ +
+
+ + + @error('identity_type') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('identity_other_text') +

{{ $message }}

+ @enderror +
+
+
+
+
+ + + @error('phone') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('phone_home') +

{{ $message }}

+ @enderror +
+
+
-
+ +
+
+ + + @error('occupation') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('job_title') +

{{ $message }}

+ @enderror +
+
+ +
+ + + @error('employer') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('applied_at')

{{ $message }}

@enderror
diff --git a/resources/views/admin/members/edit.blade.php b/resources/views/admin/members/edit.blade.php index 12eabb6..8567a46 100644 --- a/resources/views/admin/members/edit.blade.php +++ b/resources/views/admin/members/edit.blade.php @@ -38,6 +38,22 @@ @enderror +
+ + + @error('member_number') +

{{ $message }}

+ @enderror +
+
+
+
+ + + @error('birth_date') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('gender') +

{{ $message }}

+ @enderror +
+
+ +
+
+ + + @error('identity_type') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('identity_other_text') +

{{ $message }}

+ @enderror +
+
+
+
+
+ + + @error('phone') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('phone_home') +

{{ $message }}

+ @enderror +
+
+
-
+ +
+
+ + + @error('occupation') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('job_title') +

{{ $message }}

+ @enderror +
+
+ +
+ + + @error('employer') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('applied_at')

{{ $message }}

@enderror
diff --git a/resources/views/admin/members/import.blade.php b/resources/views/admin/members/import.blade.php index 36c6273..67c19b8 100644 --- a/resources/views/admin/members/import.blade.php +++ b/resources/views/admin/members/import.blade.php @@ -14,8 +14,20 @@