From e6a10f72900277e7c39604bdc25fd13604cae1fb Mon Sep 17 00:00:00 2001 From: Guillermo Pages Date: Sat, 6 Dec 2025 17:35:25 +0100 Subject: [PATCH] feat: add competition management UI for facility managers Add comprehensive competition management features: Types & API: - competition.ts: Type definitions for competitions, templates, stages, etc. - competition-admin.ts: API client for all competition endpoints - useCompetitionQueries.ts: React Query hooks for data fetching - useCompetitionMutations.ts: Mutation hooks for write operations Components: - CompetitionStatusBadge, CompetitionTypeBadge for visual indicators - CompetitionCard, CompetitionList for displaying competitions - Skeleton components for loading states Pages: - /competitions: List competitions with status/type filtering - /competitions/templates: Manage reusable competition templates - /competitions/[id]: Detail view with tabs for: - Overview: Competition details and configuration - Registrations: Approve/reject registration requests - Participants: Manage seeds and withdrawals - Fixtures: Generate and manage match fixtures - Standings: View standings tables and leaderboards Navigation: - Added Competitions tab to ClubTabNavigation --- .../CompetitionsPageComponent.tsx | 126 +++ .../competitions/CreateCompetitionModal.tsx | 418 +++++++++ .../CompetitionDetailComponent.tsx | 323 +++++++ .../competitions/[competition_id]/page.tsx | 13 + .../[competition_id]/tabs/FixturesTab.tsx | 274 ++++++ .../[competition_id]/tabs/OverviewTab.tsx | 207 +++++ .../[competition_id]/tabs/ParticipantsTab.tsx | 203 ++++ .../tabs/RegistrationsTab.tsx | 224 +++++ .../[competition_id]/tabs/StandingsTab.tsx | 317 +++++++ .../clubs/[club_id]/competitions/page.tsx | 12 + .../templates/CreateTemplateModal.tsx | 265 ++++++ .../templates/TemplatesPageComponent.tsx | 236 +++++ .../[club_id]/competitions/templates/page.tsx | 12 + src/components/ClubTabNavigation.tsx | 1 + .../competitions/CompetitionCard.tsx | 105 +++ .../competitions/CompetitionCardSkeleton.tsx | 56 ++ .../competitions/CompetitionList.tsx | 56 ++ .../competitions/CompetitionStatusBadge.tsx | 97 ++ .../competitions/CompetitionTypeBadge.tsx | 89 ++ src/components/competitions/index.ts | 5 + .../mutations/useCompetitionMutations.ts | 592 ++++++++++++ src/hooks/queries/useCompetitionQueries.ts | 293 ++++++ src/lib/api/competition-admin.ts | 870 ++++++++++++++++++ src/types/competition.ts | 508 ++++++++++ 24 files changed, 5302 insertions(+) create mode 100644 src/app/[locale]/admin/clubs/[club_id]/competitions/CompetitionsPageComponent.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/competitions/CreateCompetitionModal.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/competitions/[competition_id]/CompetitionDetailComponent.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/competitions/[competition_id]/page.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/competitions/[competition_id]/tabs/FixturesTab.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/competitions/[competition_id]/tabs/OverviewTab.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/competitions/[competition_id]/tabs/ParticipantsTab.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/competitions/[competition_id]/tabs/RegistrationsTab.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/competitions/[competition_id]/tabs/StandingsTab.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/competitions/page.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/competitions/templates/CreateTemplateModal.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/competitions/templates/TemplatesPageComponent.tsx create mode 100644 src/app/[locale]/admin/clubs/[club_id]/competitions/templates/page.tsx create mode 100644 src/components/competitions/CompetitionCard.tsx create mode 100644 src/components/competitions/CompetitionCardSkeleton.tsx create mode 100644 src/components/competitions/CompetitionList.tsx create mode 100644 src/components/competitions/CompetitionStatusBadge.tsx create mode 100644 src/components/competitions/CompetitionTypeBadge.tsx create mode 100644 src/components/competitions/index.ts create mode 100644 src/hooks/mutations/useCompetitionMutations.ts create mode 100644 src/hooks/queries/useCompetitionQueries.ts create mode 100644 src/lib/api/competition-admin.ts create mode 100644 src/types/competition.ts diff --git a/src/app/[locale]/admin/clubs/[club_id]/competitions/CompetitionsPageComponent.tsx b/src/app/[locale]/admin/clubs/[club_id]/competitions/CompetitionsPageComponent.tsx new file mode 100644 index 0000000..cf22a64 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/competitions/CompetitionsPageComponent.tsx @@ -0,0 +1,126 @@ +'use client'; + +import React, { useState } from 'react'; +import { Plus, Filter, Search, Trophy, FileText } from 'lucide-react'; +import { useCompetitions } from '@/src/hooks/queries/useCompetitionQueries'; +import { CompetitionList } from '@/src/components/competitions'; +import CreateCompetitionModal from './CreateCompetitionModal'; +import type { CompetitionStatus, CompetitionType } from '@/src/types/competition'; + +interface CompetitionsPageComponentProps { + clubId: number; +} + +const statusOptions: { value: CompetitionStatus | 'all'; label: string }[] = [ + { value: 'all', label: 'All Statuses' }, + { value: 'draft', label: 'Draft' }, + { value: 'published', label: 'Published' }, + { value: 'running', label: 'Running' }, + { value: 'finished', label: 'Finished' }, + { value: 'cancelled', label: 'Cancelled' }, +]; + +const typeOptions: { value: CompetitionType | 'all'; label: string }[] = [ + { value: 'all', label: 'All Types' }, + { value: 'league', label: 'League' }, + { value: 'tournament', label: 'Tournament' }, + { value: 'challenge', label: 'Challenge' }, + { value: 'hybrid', label: 'Hybrid' }, +]; + +export default function CompetitionsPageComponent({ clubId }: CompetitionsPageComponentProps) { + const [statusFilter, setStatusFilter] = useState('all'); + const [typeFilter, setTypeFilter] = useState('all'); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + + const { data: competitions, isLoading, error } = useCompetitions(clubId, { + status: statusFilter === 'all' ? undefined : statusFilter, + type: typeFilter === 'all' ? undefined : typeFilter, + }); + + return ( +
+ {/* Page Header */} +
+
+

Competitions

+

+ Manage leagues, tournaments, and challenges +

+
+
+ + +
+
+ + {/* Filters */} +
+
+ + +
+
+ + +
+
+ + {/* Error State */} + {error && ( +
+

+ Failed to load competitions. Please try again. +

+
+ )} + + {/* Competition List */} + + + {/* Create Competition Modal */} + setIsCreateModalOpen(false)} + facilityId={clubId} + /> +
+ ); +} diff --git a/src/app/[locale]/admin/clubs/[club_id]/competitions/CreateCompetitionModal.tsx b/src/app/[locale]/admin/clubs/[club_id]/competitions/CreateCompetitionModal.tsx new file mode 100644 index 0000000..fb79af5 --- /dev/null +++ b/src/app/[locale]/admin/clubs/[club_id]/competitions/CreateCompetitionModal.tsx @@ -0,0 +1,418 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { X, Trophy, Target, Zap, Layers, Loader2, AlertCircle } from 'lucide-react'; +import Modal from '@/src/components/modals/Modal'; +import ModalHeader from '@/src/components/modals/ModalHeader'; +import ModalBody from '@/src/components/modals/ModalBody'; +import ModalFooter from '@/src/components/modals/ModalFooter'; +import { useCompetitionTemplates } from '@/src/hooks/queries/useCompetitionQueries'; +import { useCreateCompetition } from '@/src/hooks/mutations/useCompetitionMutations'; +import type { + CompetitionType, + CompetitionVisibility, + CreateCompetitionRequest, + CompetitionTemplate, +} from '@/src/types/competition'; + +interface CreateCompetitionModalProps { + isOpen: boolean; + onClose: () => void; + facilityId: number; +} + +const competitionTypes: { + type: CompetitionType; + label: string; + description: string; + Icon: React.ComponentType<{ className?: string }>; +}[] = [ + { + type: 'league', + label: 'League', + description: 'Round-robin format with standings', + Icon: Trophy, + }, + { + type: 'tournament', + label: 'Tournament', + description: 'Knockout bracket format', + Icon: Target, + }, + { + type: 'challenge', + label: 'Challenge', + description: 'Rating-based leaderboard', + Icon: Zap, + }, + { + type: 'hybrid', + label: 'Hybrid', + description: 'Multiple stages combined', + Icon: Layers, + }, +]; + +const visibilityOptions: { value: CompetitionVisibility; label: string; description: string }[] = [ + { value: 'public', label: 'Public', description: 'Anyone can see and register' }, + { value: 'unlisted', label: 'Unlisted', description: 'Only accessible via link' }, + { value: 'private', label: 'Private', description: 'Invite only' }, +]; + +export default function CreateCompetitionModal({ + isOpen, + onClose, + facilityId, +}: CreateCompetitionModalProps) { + const [step, setStep] = useState<'source' | 'details'>('source'); + const [selectedTemplate, setSelectedTemplate] = useState(null); + const [formData, setFormData] = useState<{ + type: CompetitionType; + title: string; + description: string; + visibility: CompetitionVisibility; + starts_at: string; + ends_at: string; + registration_open_at: string; + registration_close_at: string; + sport_id: number; + }>({ + type: 'tournament', + title: '', + description: '', + visibility: 'public', + starts_at: '', + ends_at: '', + registration_open_at: '', + registration_close_at: '', + sport_id: 1, // Default to padel + }); + const [formError, setFormError] = useState(null); + + const { data: templates, isLoading: templatesLoading } = useCompetitionTemplates(facilityId); + const createMutation = useCreateCompetition(facilityId); + + // Reset form when modal closes + useEffect(() => { + if (!isOpen) { + setStep('source'); + setSelectedTemplate(null); + setFormData({ + type: 'tournament', + title: '', + description: '', + visibility: 'public', + starts_at: '', + ends_at: '', + registration_open_at: '', + registration_close_at: '', + sport_id: 1, + }); + setFormError(null); + } + }, [isOpen]); + + function handleSelectTemplate(template: CompetitionTemplate) { + setSelectedTemplate(template); + setFormData((prev) => ({ + ...prev, + type: template.type, + sport_id: template.sport_id, + })); + setStep('details'); + } + + function handleSelectFromScratch(type: CompetitionType) { + setSelectedTemplate(null); + setFormData((prev) => ({ + ...prev, + type, + })); + setStep('details'); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setFormError(null); + + if (!formData.title.trim()) { + setFormError('Title is required'); + return; + } + if (!formData.starts_at) { + setFormError('Start date is required'); + return; + } + + const request: CreateCompetitionRequest = { + type: formData.type, + sport_id: formData.sport_id, + title: formData.title.trim(), + description: formData.description.trim() || undefined, + visibility: formData.visibility, + starts_at: new Date(formData.starts_at).toISOString(), + ends_at: formData.ends_at ? new Date(formData.ends_at).toISOString() : undefined, + registration_open_at: formData.registration_open_at + ? new Date(formData.registration_open_at).toISOString() + : undefined, + registration_close_at: formData.registration_close_at + ? new Date(formData.registration_close_at).toISOString() + : undefined, + template_id: selectedTemplate?.template_id, + config: selectedTemplate?.config, + }; + + try { + await createMutation.mutateAsync(request); + onClose(); + } catch (err) { + setFormError(err instanceof Error ? err.message : 'Failed to create competition'); + } + } + + return ( + + + + {step === 'source' ? ( +
+ {/* Templates Section */} + {templatesLoading ? ( +
+ +
+ ) : templates && templates.length > 0 ? ( +
+

+ From Template +

+
+ {templates.map((template) => ( + + ))} +
+
+ ) : null} + + {/* From Scratch Section */} +
+

+ {templates && templates.length > 0 ? 'Or Start From Scratch' : 'Choose Type'} +

+
+ {competitionTypes.map(({ type, label, description, Icon }) => ( + + ))} +
+
+
+ ) : ( +
+ {formError && ( +
+ +

{formError}

+
+ )} + + {/* Title */} +
+ + setFormData((prev) => ({ ...prev, title: e.target.value }))} + placeholder="e.g., Summer Tournament 2025" + className="w-full px-4 py-2.5 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> +
+ + {/* Description */} +
+ +