diff --git a/TODO_affiliations.md b/TODO_affiliations.md new file mode 100644 index 0000000..3b17b15 --- /dev/null +++ b/TODO_affiliations.md @@ -0,0 +1,28 @@ +# TODO: Implement Affiliations Tab + +## Backend Implementation +- [x] Create ClubAffiliation model and migration +- [x] Create SkillAcquisition model and migration +- [x] Create AffiliationMedia model and migration +- [x] Update User model with clubAffiliations relationship +- [x] Update FamilyController::profile() to fetch affiliations data and summary stats +- [x] Add club_affiliation_id to TournamentEvent model and migration + +## Frontend Implementation +- [x] Update show.blade.php affiliations tab content +- [x] Implement horizontal timeline with clickable nodes +- [x] Add dynamic skills wheel using Chart.js Polar Area +- [x] Create affiliation details panel +- [x] Add summary stats above timeline +- [x] Ensure responsive design (desktop side-by-side, mobile stacked) +- [x] Add Alpine.js for timeline interactions +- [x] Implement keyboard navigation and accessibility +- [x] Add club affiliation column to tournament table + +## Testing & Deployment +- [x] Run migrations to create database tables +- [x] Add sample data for demonstration +- [x] Add calculated duration display to timeline cards (as badges) +- [x] Test timeline navigation and skills wheel updates +- [x] Verify responsive design on different screen sizes +- [x] Add "Add Tournament Participation" button with modal functionality diff --git a/app/Http/Controllers/FamilyController.php b/app/Http/Controllers/FamilyController.php index 8e4ce1d..992d586 100644 --- a/app/Http/Controllers/FamilyController.php +++ b/app/Http/Controllers/FamilyController.php @@ -9,6 +9,7 @@ use App\Models\Invoice; use App\Models\TournamentEvent; use App\Models\Goal; use App\Models\Attendance; +use App\Models\ClubAffiliation; use App\Services\FamilyService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -60,7 +61,7 @@ class FamilyController extends Controller // Fetch tournament data $tournamentEvents = $user->tournamentEvents() - ->with(['performanceResults', 'notesMedia']) + ->with(['performanceResults', 'notesMedia', 'clubAffiliation']) ->orderBy('date', 'desc') ->get(); @@ -88,6 +89,24 @@ class FamilyController extends Controller $totalSessions = $attendanceRecords->count(); $attendanceRate = $totalSessions > 0 ? round(($sessionsCompleted / $totalSessions) * 100, 1) : 0; + // Fetch affiliations data + $clubAffiliations = $user->clubAffiliations() + ->with(['skillAcquisitions', 'affiliationMedia']) + ->orderBy('start_date', 'desc') + ->get(); + + // Add icon_class to media items for JavaScript + $clubAffiliations->each(function($affiliation) { + $affiliation->affiliationMedia->each(function($media) { + $media->icon_class = $media->icon_class; + }); + }); + + // Calculate summary stats + $totalAffiliations = $clubAffiliations->count(); + $distinctSkills = $clubAffiliations->flatMap->skillAcquisitions->pluck('skill_name')->unique()->count(); + $totalMembershipDuration = $clubAffiliations->sum('duration_in_months'); + // Pass user directly and a flag to indicate it's the current user's profile return view('family.show', [ 'relationship' => (object)[ @@ -111,6 +130,10 @@ class FamilyController extends Controller 'sessionsCompleted' => $sessionsCompleted, 'noShows' => $noShows, 'attendanceRate' => $attendanceRate, + 'clubAffiliations' => $clubAffiliations, + 'totalAffiliations' => $totalAffiliations, + 'distinctSkills' => $distinctSkills, + 'totalMembershipDuration' => $totalMembershipDuration, ]); } @@ -280,7 +303,7 @@ class FamilyController extends Controller // Fetch tournament data for the dependent $tournamentEvents = $relationship->dependent->tournamentEvents() - ->with(['performanceResults', 'notesMedia']) + ->with(['performanceResults', 'notesMedia', 'clubAffiliation']) ->orderBy('date', 'desc') ->get(); @@ -308,7 +331,46 @@ class FamilyController extends Controller $totalSessions = $attendanceRecords->count(); $attendanceRate = $totalSessions > 0 ? round(($sessionsCompleted / $totalSessions) * 100, 1) : 0; - return view('family.show', compact('relationship', 'latestHealthRecord', 'healthRecords', 'comparisonRecords', 'invoices', 'tournamentEvents', 'awardCounts', 'sports', 'goals', 'activeGoalsCount', 'completedGoalsCount', 'successRate', 'attendanceRecords', 'sessionsCompleted', 'noShows', 'attendanceRate')); + // Fetch affiliations data for the dependent + $clubAffiliations = $relationship->dependent->clubAffiliations() + ->with(['skillAcquisitions', 'affiliationMedia']) + ->orderBy('start_date', 'desc') + ->get(); + + // Add icon_class to media items for JavaScript + $clubAffiliations->each(function($affiliation) { + $affiliation->affiliationMedia->each(function($media) { + $media->icon_class = $media->icon_class; + }); + }); + + // Calculate summary stats + $totalAffiliations = $clubAffiliations->count(); + $distinctSkills = $clubAffiliations->flatMap->skillAcquisitions->pluck('skill_name')->unique()->count(); + $totalMembershipDuration = $clubAffiliations->sum('duration_in_months'); + + return view('family.show', [ + 'relationship' => $relationship, + 'latestHealthRecord' => $latestHealthRecord, + 'healthRecords' => $healthRecords, + 'comparisonRecords' => $comparisonRecords, + 'invoices' => $invoices, + 'tournamentEvents' => $tournamentEvents, + 'awardCounts' => $awardCounts, + 'sports' => $sports, + 'goals' => $goals, + 'activeGoalsCount' => $activeGoalsCount, + 'completedGoalsCount' => $completedGoalsCount, + 'successRate' => $successRate, + 'attendanceRecords' => $attendanceRecords, + 'sessionsCompleted' => $sessionsCompleted, + 'noShows' => $noShows, + 'attendanceRate' => $attendanceRate, + 'clubAffiliations' => $clubAffiliations, + 'totalAffiliations' => $totalAffiliations, + 'distinctSkills' => $distinctSkills, + 'totalMembershipDuration' => $totalMembershipDuration, + ]); } /** @@ -634,6 +696,81 @@ class FamilyController extends Controller return response()->json(['success' => true, 'message' => 'Goal updated successfully']); } + /** + * Store a new tournament participation record. + * + * @param \Illuminate\Http\Request $request + * @param int $id + * @return \Illuminate\Http\JsonResponse + */ + public function storeTournament(Request $request, $id) + { + $user = Auth::user(); + + // Check if user is authorized to add tournament for this dependent + if ($user->id !== (int)$id) { + $relationship = UserRelationship::where('guardian_user_id', $user->id) + ->where('dependent_user_id', $id) + ->first(); + + if (!$relationship) { + return response()->json(['success' => false, 'message' => 'Unauthorized'], 403); + } + } + + // Validate the request + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'type' => 'required|in:championship,tournament,competition,exhibition', + 'sport' => 'required|string|max:100', + 'date' => 'required|date', + 'time' => 'nullable|date_format:H:i', + 'location' => 'nullable|string|max:255', + 'participants_count' => 'nullable|integer|min:1', + 'club_affiliation_id' => 'nullable|exists:club_affiliations,id', + 'performance_results' => 'nullable|array', + 'performance_results.*.medal_type' => 'nullable|in:special,1st,2nd,3rd', + 'performance_results.*.points' => 'nullable|numeric|min:0', + 'performance_results.*.description' => 'nullable|string|max:500', + 'notes_media' => 'nullable|array', + 'notes_media.*.note_text' => 'nullable|string|max:1000', + 'notes_media.*.media_link' => 'nullable|url', + ]); + + // Create the tournament event + $tournament = TournamentEvent::create([ + 'user_id' => $id, + 'club_affiliation_id' => $validated['club_affiliation_id'] ?? null, + 'title' => $validated['title'], + 'type' => $validated['type'], + 'sport' => $validated['sport'], + 'date' => $validated['date'], + 'time' => $validated['time'], + 'location' => $validated['location'], + 'participants_count' => $validated['participants_count'], + ]); + + // Create performance results + if (isset($validated['performance_results'])) { + foreach ($validated['performance_results'] as $resultData) { + if (!empty($resultData['medal_type'])) { + $tournament->performanceResults()->create($resultData); + } + } + } + + // Create notes and media + if (isset($validated['notes_media'])) { + foreach ($validated['notes_media'] as $noteData) { + if (!empty($noteData['note_text']) || !empty($noteData['media_link'])) { + $tournament->notesMedia()->create($noteData); + } + } + } + + return response()->json(['success' => true, 'message' => 'Tournament record added successfully']); + } + /** * Remove the specified family member from storage. * diff --git a/app/Models/AffiliationMedia.php b/app/Models/AffiliationMedia.php new file mode 100644 index 0000000..76a65c8 --- /dev/null +++ b/app/Models/AffiliationMedia.php @@ -0,0 +1,51 @@ +belongsTo(ClubAffiliation::class); + } + + /** + * Get the full URL for the media. + */ + public function getFullUrlAttribute(): string + { + if (filter_var($this->media_url, FILTER_VALIDATE_URL)) { + return $this->media_url; + } + + return asset('storage/' . $this->media_url); + } + + /** + * Get icon class for media type. + */ + public function getIconClassAttribute(): string + { + return match($this->media_type) { + 'certificate' => 'bi-file-earmark-text', + 'photo' => 'bi-image', + 'video' => 'bi-play-circle', + 'document' => 'bi-file-text', + default => 'bi-file', + }; + } +} diff --git a/app/Models/ClubAffiliation.php b/app/Models/ClubAffiliation.php new file mode 100644 index 0000000..bf7d6e4 --- /dev/null +++ b/app/Models/ClubAffiliation.php @@ -0,0 +1,93 @@ + 'date', + 'end_date' => 'date', + 'coaches' => 'array', + ]; + + /** + * Get the member that owns the affiliation. + */ + public function member(): BelongsTo + { + return $this->belongsTo(User::class, 'member_id'); + } + + /** + * Get the skills acquired during this affiliation. + */ + public function skillAcquisitions(): HasMany + { + return $this->hasMany(SkillAcquisition::class); + } + + /** + * Get the media associated with this affiliation. + */ + public function affiliationMedia(): HasMany + { + return $this->hasMany(AffiliationMedia::class); + } + + /** + * Get the duration of the affiliation in months. + */ + public function getDurationInMonthsAttribute(): int + { + $endDate = $this->end_date ?? now(); + return $this->start_date->diffInMonths($endDate); + } + + /** + * Get formatted date range. + */ + public function getDateRangeAttribute(): string + { + $start = $this->start_date->format('M Y'); + $end = $this->end_date ? $this->end_date->format('M Y') : 'Present'; + return $start . ' – ' . $end; + } + + /** + * Get detailed formatted duration (years, months, days). + */ + public function getFormattedDurationAttribute(): string + { + $endDate = $this->end_date ?? now(); + $diff = $this->start_date->diff($endDate); + + $parts = []; + + if ($diff->y > 0) { + $parts[] = $diff->y . ' year' . ($diff->y > 1 ? 's' : ''); + } + if ($diff->m > 0) { + $parts[] = $diff->m . ' month' . ($diff->m > 1 ? 's' : ''); + } + if ($diff->d > 0) { + $parts[] = $diff->d . ' day' . ($diff->d > 1 ? 's' : ''); + } + + return implode(' ', $parts) ?: 'Same day'; + } +} diff --git a/app/Models/SkillAcquisition.php b/app/Models/SkillAcquisition.php new file mode 100644 index 0000000..675031c --- /dev/null +++ b/app/Models/SkillAcquisition.php @@ -0,0 +1,64 @@ + 'integer', + ]; + + /** + * Get the club affiliation that owns the skill acquisition. + */ + public function clubAffiliation(): BelongsTo + { + return $this->belongsTo(ClubAffiliation::class); + } + + /** + * Get formatted duration. + */ + public function getFormattedDurationAttribute(): string + { + $months = $this->duration_months; + if ($months < 12) { + return $months . ' month' . ($months > 1 ? 's' : ''); + } + + $years = floor($months / 12); + $remainingMonths = $months % 12; + + $result = $years . ' year' . ($years > 1 ? 's' : ''); + if ($remainingMonths > 0) { + $result .= ' ' . $remainingMonths . ' month' . ($remainingMonths > 1 ? 's' : ''); + } + + return $result; + } + + /** + * Get proficiency level color for UI. + */ + public function getProficiencyColorAttribute(): string + { + return match($this->proficiency_level) { + 'beginner' => 'text-blue-500', + 'intermediate' => 'text-yellow-500', + 'advanced' => 'text-orange-500', + 'expert' => 'text-red-500', + default => 'text-gray-500', + }; + } +} diff --git a/app/Models/TournamentEvent.php b/app/Models/TournamentEvent.php index 8234d73..be744e8 100644 --- a/app/Models/TournamentEvent.php +++ b/app/Models/TournamentEvent.php @@ -10,6 +10,7 @@ class TournamentEvent extends Model { protected $fillable = [ 'user_id', + 'club_affiliation_id', 'title', 'type', 'sport', @@ -29,6 +30,11 @@ class TournamentEvent extends Model return $this->belongsTo(User::class); } + public function clubAffiliation(): BelongsTo + { + return $this->belongsTo(ClubAffiliation::class); + } + public function performanceResults(): HasMany { return $this->hasMany(PerformanceResult::class); diff --git a/app/Models/User.php b/app/Models/User.php index 212eb05..4b5808c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -250,6 +250,14 @@ class User extends Authenticatable return $this->hasMany(Attendance::class, 'member_id'); } + /** + * Get the club affiliations for the user. + */ + public function clubAffiliations(): HasMany + { + return $this->hasMany(ClubAffiliation::class, 'member_id'); + } + /** * Send the email verification notification. * Override to prevent sending the default Laravel notification. diff --git a/database/migrations/2026_01_24_102547_create_club_affiliations_table.php b/database/migrations/2026_01_24_102547_create_club_affiliations_table.php new file mode 100644 index 0000000..ff044b8 --- /dev/null +++ b/database/migrations/2026_01_24_102547_create_club_affiliations_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('member_id')->constrained('users')->onDelete('cascade'); + $table->string('club_name'); + $table->string('logo')->nullable(); + $table->date('start_date'); + $table->date('end_date')->nullable(); + $table->string('location')->nullable(); + $table->json('coaches')->nullable(); // Array of coach names + $table->text('description')->nullable(); + $table->timestamps(); + + $table->index(['member_id', 'start_date']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('club_affiliations'); + } +}; diff --git a/database/migrations/2026_01_24_102605_create_skill_acquisitions_table.php b/database/migrations/2026_01_24_102605_create_skill_acquisitions_table.php new file mode 100644 index 0000000..3911eee --- /dev/null +++ b/database/migrations/2026_01_24_102605_create_skill_acquisitions_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('club_affiliation_id')->constrained('club_affiliations')->onDelete('cascade'); + $table->string('skill_name'); + $table->string('icon')->nullable(); // FontAwesome or Heroicons class + $table->integer('duration_months'); + $table->enum('proficiency_level', ['beginner', 'intermediate', 'advanced', 'expert'])->default('beginner'); + $table->timestamps(); + + $table->index(['club_affiliation_id', 'skill_name']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('skill_acquisitions'); + } +}; diff --git a/database/migrations/2026_01_24_102618_create_affiliation_media_table.php b/database/migrations/2026_01_24_102618_create_affiliation_media_table.php new file mode 100644 index 0000000..00be784 --- /dev/null +++ b/database/migrations/2026_01_24_102618_create_affiliation_media_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('club_affiliation_id')->constrained('club_affiliations')->onDelete('cascade'); + $table->enum('media_type', ['certificate', 'photo', 'video', 'document']); + $table->string('media_url'); + $table->string('title')->nullable(); + $table->text('description')->nullable(); + $table->timestamps(); + + $table->index(['club_affiliation_id', 'media_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('affiliation_media'); + } +}; diff --git a/database/migrations/2026_01_24_110202_add_club_affiliation_id_to_tournament_events_table.php b/database/migrations/2026_01_24_110202_add_club_affiliation_id_to_tournament_events_table.php new file mode 100644 index 0000000..2519440 --- /dev/null +++ b/database/migrations/2026_01_24_110202_add_club_affiliation_id_to_tournament_events_table.php @@ -0,0 +1,28 @@ +foreignId('club_affiliation_id')->nullable()->constrained('club_affiliations')->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tournament_events', function (Blueprint $table) { + // + }); + } +}; diff --git a/database/seeders/AffiliationSeeder.php b/database/seeders/AffiliationSeeder.php new file mode 100644 index 0000000..26fee9c --- /dev/null +++ b/database/seeders/AffiliationSeeder.php @@ -0,0 +1,160 @@ + 'Elite Boxing Club', + 'slug' => 'elite-boxing-club', + 'gps_lat' => 25.276987, + 'gps_long' => 55.296249, + ], + [ + 'club_name' => 'Zen Martial Arts Academy', + 'slug' => 'zen-martial-arts-academy', + 'gps_lat' => 25.286987, + 'gps_long' => 55.306249, + ], + [ + 'club_name' => 'Power Fitness Gym', + 'slug' => 'power-fitness-gym', + 'gps_lat' => 25.266987, + 'gps_long' => 55.286249, + ], + ]; + + $createdClubs = []; + foreach ($clubs as $clubData) { + $club = Tenant::firstOrCreate( + ['slug' => $clubData['slug']], + array_merge($clubData, ['owner_user_id' => $user->id]) + ); + $createdClubs[] = $club; + } + + // Sample club affiliations + $affiliations = [ + [ + 'club_name' => 'Elite Boxing Club', + 'start_date' => Carbon::parse('2020-01-15'), + 'end_date' => Carbon::parse('2021-12-31'), + 'location' => 'Downtown Fitness Center', + 'coaches' => ['Coach Mike Johnson', 'Coach Sarah Davis'], + 'description' => 'Premier boxing training facility focusing on technique and conditioning.', + 'logo' => 'https://via.placeholder.com/100x100/FF6B6B/FFFFFF?text=EBC', + 'skills' => [ + ['skill_name' => 'Boxing', 'icon' => 'fas fa-fist-raised', 'duration_months' => 18, 'proficiency_level' => 'advanced'], + ['skill_name' => 'Fitness Training', 'icon' => 'fas fa-dumbbell', 'duration_months' => 12, 'proficiency_level' => 'intermediate'], + ['skill_name' => 'Footwork', 'icon' => 'fas fa-shoe-prints', 'duration_months' => 15, 'proficiency_level' => 'advanced'], + ], + 'media' => [ + ['media_type' => 'certificate', 'title' => 'Boxing Certification', 'media_url' => 'https://via.placeholder.com/300x200/4ECDC4/FFFFFF?text=Boxing+Cert', 'description' => 'Advanced Boxing Certificate'], + ['media_type' => 'photo', 'title' => 'Championship Photo', 'media_url' => 'https://via.placeholder.com/300x200/45B7D1/FFFFFF?text=Championship', 'description' => 'Regional Championship 2021'], + ] + ], + [ + 'club_name' => 'Zen Martial Arts Academy', + 'start_date' => Carbon::parse('2018-03-01'), + 'end_date' => Carbon::parse('2020-01-10'), + 'location' => 'East Side Dojo', + 'coaches' => ['Master Chen Wei', 'Instructor Lisa Park'], + 'description' => 'Traditional martial arts academy specializing in multiple disciplines.', + 'logo' => 'https://via.placeholder.com/100x100/96CEB4/FFFFFF?text=ZMA', + 'skills' => [ + ['skill_name' => 'Taekwondo', 'icon' => 'fas fa-hand-rock', 'duration_months' => 20, 'proficiency_level' => 'expert'], + ['skill_name' => 'Karate', 'icon' => 'fas fa-fist-raised', 'duration_months' => 16, 'proficiency_level' => 'advanced'], + ['skill_name' => 'Self Defense', 'icon' => 'fas fa-shield-alt', 'duration_months' => 18, 'proficiency_level' => 'advanced'], + ], + 'media' => [ + ['media_type' => 'certificate', 'title' => 'Black Belt Certificate', 'media_url' => 'https://via.placeholder.com/300x200/FECA57/FFFFFF?text=Black+Belt', 'description' => 'Taekwondo Black Belt Certification'], + ] + ], + [ + 'club_name' => 'Power Fitness Gym', + 'start_date' => Carbon::parse('2022-06-01'), + 'end_date' => null, // Current affiliation + 'location' => 'West End Sports Complex', + 'coaches' => ['Trainer Alex Rodriguez', 'Trainer Emma Wilson'], + 'description' => 'Modern fitness center with comprehensive training programs.', + 'logo' => 'https://via.placeholder.com/100x100/FFEAA7/000000?text=PFG', + 'skills' => [ + ['skill_name' => 'Weight Training', 'icon' => 'fas fa-dumbbell', 'duration_months' => 8, 'proficiency_level' => 'intermediate'], + ['skill_name' => 'Cardio Fitness', 'icon' => 'fas fa-heartbeat', 'duration_months' => 6, 'proficiency_level' => 'beginner'], + ['skill_name' => 'Nutrition', 'icon' => 'fas fa-apple-alt', 'duration_months' => 4, 'proficiency_level' => 'beginner'], + ], + 'media' => [ + ['media_type' => 'photo', 'title' => 'Gym Progress Photo', 'media_url' => 'https://via.placeholder.com/300x200/DD5E89/FFFFFF?text=Progress', 'description' => 'Before and after transformation'], + ] + ], + ]; + + $createdAffiliations = []; + foreach ($affiliations as $affiliationData) { + $skills = $affiliationData['skills']; + $media = $affiliationData['media']; + unset($affiliationData['skills'], $affiliationData['media']); + + $affiliationData['member_id'] = $user->id; + + $affiliation = ClubAffiliation::create($affiliationData); + $createdAffiliations[] = $affiliation; + + // Create skills + foreach ($skills as $skillData) { + $affiliation->skillAcquisitions()->create($skillData); + } + + // Create media + foreach ($media as $mediaData) { + $affiliation->affiliationMedia()->create($mediaData); + } + } + + // Link some tournament events to affiliations + $tournamentEvents = TournamentEvent::where('user_id', $user->id)->get(); + + if ($tournamentEvents->count() > 0 && count($createdAffiliations) > 0) { + // Link first tournament to Elite Boxing Club affiliation + $boxingAffiliation = collect($createdAffiliations)->firstWhere('club_name', 'Elite Boxing Club'); + if ($boxingAffiliation) { + $boxingEvents = $tournamentEvents->where('sport', 'Boxing'); + foreach ($boxingEvents as $event) { + $event->update(['club_affiliation_id' => $boxingAffiliation->id]); + } + } + + // Link martial arts events to Zen Martial Arts Academy + $martialArtsAffiliation = collect($createdAffiliations)->firstWhere('club_name', 'Zen Martial Arts Academy'); + if ($martialArtsAffiliation) { + $martialArtsEvents = $tournamentEvents->whereIn('sport', ['Taekwondo', 'Karate', 'Martial Arts']); + foreach ($martialArtsEvents as $event) { + $event->update(['club_affiliation_id' => $martialArtsAffiliation->id]); + } + } + } + } +} diff --git a/resources/views/family/show.blade.php b/resources/views/family/show.blade.php index b3b53c7..05d29ed 100644 --- a/resources/views/family/show.blade.php +++ b/resources/views/family/show.blade.php @@ -66,7 +66,7 @@
Affiliation system coming soon...
+Select an affiliation to view skills
+Select an affiliation from the timeline to view details
+Club affiliations and skills will appear here once added
+