feat: add competition management UI for facility managers
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
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 ClubTabNavigationmaster
parent
f39e3542ed
commit
e6a10f7290
@ -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<CompetitionStatus | 'all'>('all');
|
||||
const [typeFilter, setTypeFilter] = useState<CompetitionType | 'all'>('all');
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const { data: competitions, isLoading, error } = useCompetitions(clubId, {
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
type: typeFilter === 'all' ? undefined : typeFilter,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-900">Competitions</h2>
|
||||
<p className="text-slate-600 mt-1">
|
||||
Manage leagues, tournaments, and challenges
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => window.location.href = `competitions/templates`}
|
||||
className="inline-flex items-center px-4 py-2.5 bg-white border border-slate-200 text-slate-700 font-semibold rounded-xl hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<FileText className="w-5 h-5 mr-2" />
|
||||
Templates
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="inline-flex items-center px-4 py-2.5 bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 text-white font-semibold rounded-xl hover:shadow-lg transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
New Competition
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4 mb-6">
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as CompetitionStatus | 'all')}
|
||||
className="pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm font-medium text-slate-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent appearance-none cursor-pointer"
|
||||
>
|
||||
{statusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Trophy className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value as CompetitionType | 'all')}
|
||||
className="pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm font-medium text-slate-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent appearance-none cursor-pointer"
|
||||
>
|
||||
{typeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
||||
<p className="text-red-700 font-medium">
|
||||
Failed to load competitions. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Competition List */}
|
||||
<CompetitionList
|
||||
competitions={competitions ?? []}
|
||||
facilityId={clubId}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No competitions yet. Create your first competition to get started."
|
||||
/>
|
||||
|
||||
{/* Create Competition Modal */}
|
||||
<CreateCompetitionModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
facilityId={clubId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<CompetitionTemplate | null>(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<string | null>(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 (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||
<ModalHeader
|
||||
title={step === 'source' ? 'Create Competition' : 'Competition Details'}
|
||||
subtitle={
|
||||
step === 'source'
|
||||
? 'Choose a template or start from scratch'
|
||||
: selectedTemplate
|
||||
? `Based on: ${selectedTemplate.name}`
|
||||
: 'Configure your competition'
|
||||
}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<ModalBody padding="md">
|
||||
{step === 'source' ? (
|
||||
<div className="space-y-6">
|
||||
{/* Templates Section */}
|
||||
{templatesLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
|
||||
</div>
|
||||
) : templates && templates.length > 0 ? (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-700 mb-3">
|
||||
From Template
|
||||
</h3>
|
||||
<div className="grid gap-3">
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.template_id}
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
className="flex items-center gap-4 p-4 bg-slate-50 hover:bg-purple-50 border border-slate-200 hover:border-purple-300 rounded-xl text-left transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
{template.type === 'league' && <Trophy className="w-5 h-5 text-purple-600" />}
|
||||
{template.type === 'tournament' && <Target className="w-5 h-5 text-purple-600" />}
|
||||
{template.type === 'challenge' && <Zap className="w-5 h-5 text-purple-600" />}
|
||||
{template.type === 'hybrid' && <Layers className="w-5 h-5 text-purple-600" />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-slate-800">{template.name}</h4>
|
||||
{template.description && (
|
||||
<p className="text-sm text-slate-600 line-clamp-1">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs px-2 py-1 bg-white rounded-full text-slate-500 capitalize">
|
||||
{template.type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* From Scratch Section */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-700 mb-3">
|
||||
{templates && templates.length > 0 ? 'Or Start From Scratch' : 'Choose Type'}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{competitionTypes.map(({ type, label, description, Icon }) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleSelectFromScratch(type)}
|
||||
className="flex flex-col items-center gap-2 p-4 bg-white hover:bg-slate-50 border border-slate-200 hover:border-purple-300 rounded-xl text-center transition-colors"
|
||||
>
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-indigo-100 to-purple-100 rounded-xl flex items-center justify-center">
|
||||
<Icon className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-800">{label}</h4>
|
||||
<p className="text-xs text-slate-500">{description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{formError && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-xl">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||
<p className="text-sm text-red-700">{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
placeholder="Brief description of the competition..."
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
Visibility
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{visibilityOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFormData((prev) => ({ ...prev, visibility: option.value }))
|
||||
}
|
||||
className={`p-3 border rounded-xl text-center transition-colors ${
|
||||
formData.visibility === option.value
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700'
|
||||
: 'border-slate-200 hover:border-slate-300 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm">{option.label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
Start Date <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.starts_at}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, starts_at: e.target.value }))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.ends_at}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, ends_at: e.target.value }))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration Period */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
Registration Opens
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.registration_open_at}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, registration_open_at: e.target.value }))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
Registration Closes
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.registration_close_at}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, registration_close_at: e.target.value }))
|
||||
}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter align="between">
|
||||
{step === 'details' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('source')}
|
||||
className="px-4 py-2.5 text-slate-600 font-medium hover:text-slate-800 transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<div className="flex gap-3 ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 bg-slate-100 text-slate-700 font-semibold rounded-xl hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{step === 'details' && (
|
||||
<button
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={createMutation.isPending}
|
||||
className="px-6 py-2.5 bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 text-white font-semibold rounded-xl hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Creating...
|
||||
</span>
|
||||
) : (
|
||||
'Create Competition'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
Users,
|
||||
PlayCircle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Eye,
|
||||
Settings,
|
||||
Trophy,
|
||||
ListOrdered,
|
||||
} from 'lucide-react';
|
||||
import useTranslation from '@/src/hooks/useTranslation';
|
||||
import Card from '@/src/components/cards/Card';
|
||||
import { CompetitionStatusBadge, CompetitionTypeBadge } from '@/src/components/competitions';
|
||||
import { useCompetition } from '@/src/hooks/queries/useCompetitionQueries';
|
||||
import {
|
||||
usePublishCompetition,
|
||||
useStartCompetition,
|
||||
useFinishCompetition,
|
||||
useCancelCompetition,
|
||||
} from '@/src/hooks/mutations/useCompetitionMutations';
|
||||
import OverviewTab from './tabs/OverviewTab';
|
||||
import RegistrationsTab from './tabs/RegistrationsTab';
|
||||
import ParticipantsTab from './tabs/ParticipantsTab';
|
||||
import FixturesTab from './tabs/FixturesTab';
|
||||
import StandingsTab from './tabs/StandingsTab';
|
||||
import type { CompetitionStatus } from '@/src/types/competition';
|
||||
|
||||
interface CompetitionDetailComponentProps {
|
||||
clubId: number;
|
||||
competitionId: number;
|
||||
}
|
||||
|
||||
type TabKey = 'overview' | 'registrations' | 'participants' | 'fixtures' | 'standings';
|
||||
|
||||
const tabs: { key: TabKey; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
||||
{ key: 'overview', label: 'Overview', icon: Eye },
|
||||
{ key: 'registrations', label: 'Registrations', icon: Users },
|
||||
{ key: 'participants', label: 'Participants', icon: Trophy },
|
||||
{ key: 'fixtures', label: 'Fixtures', icon: Calendar },
|
||||
{ key: 'standings', label: 'Standings', icon: ListOrdered },
|
||||
];
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function StatusTransitionButton({
|
||||
status,
|
||||
competitionId,
|
||||
}: {
|
||||
status: CompetitionStatus;
|
||||
competitionId: number;
|
||||
}) {
|
||||
const publishMutation = usePublishCompetition(competitionId);
|
||||
const startMutation = useStartCompetition(competitionId);
|
||||
const finishMutation = useFinishCompetition(competitionId);
|
||||
const cancelMutation = useCancelCompetition(competitionId);
|
||||
|
||||
const isPending =
|
||||
publishMutation.isPending ||
|
||||
startMutation.isPending ||
|
||||
finishMutation.isPending ||
|
||||
cancelMutation.isPending;
|
||||
|
||||
if (status === 'draft') {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => publishMutation.mutate()}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center px-4 py-2 bg-indigo-600 text-white font-semibold rounded-xl hover:bg-indigo-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{publishMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'published') {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => startMutation.mutate()}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center px-4 py-2 bg-emerald-600 text-white font-semibold rounded-xl hover:bg-emerald-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{startMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
onClick={() => cancelMutation.mutate()}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center px-4 py-2 bg-red-100 text-red-700 font-semibold rounded-xl hover:bg-red-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancelMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'running') {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => finishMutation.mutate()}
|
||||
disabled={isPending}
|
||||
className="inline-flex items-center px-4 py-2 bg-purple-600 text-white font-semibold rounded-xl hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{finishMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Finish
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CompetitionDetailComponent({
|
||||
clubId,
|
||||
competitionId,
|
||||
}: CompetitionDetailComponentProps) {
|
||||
const { locale } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('overview');
|
||||
|
||||
const { data: competition, isLoading, error } = useCompetition(competitionId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Loader2 className="w-12 h-12 text-purple-600 animate-spin mb-4" />
|
||||
<p className="text-slate-600">Loading competition...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !competition) {
|
||||
return (
|
||||
<div className="py-8">
|
||||
<Link
|
||||
href={`/${locale}/admin/clubs/${clubId}/competitions`}
|
||||
className="inline-flex items-center text-slate-600 hover:text-slate-900 font-medium transition-colors mb-6"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Competitions
|
||||
</Link>
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-6 h-6 text-red-600 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-red-800">Error Loading Competition</h3>
|
||||
<p className="text-red-700 mt-1">
|
||||
{error instanceof Error ? error.message : 'Competition not found'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
href={`/${locale}/admin/clubs/${clubId}/competitions`}
|
||||
className="inline-flex items-center text-slate-600 hover:text-slate-900 font-medium transition-colors mb-6"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Competitions
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<CompetitionTypeBadge type={competition.type} size="sm" />
|
||||
<CompetitionStatusBadge status={competition.status} size="sm" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-slate-900">{competition.title}</h1>
|
||||
{competition.description && (
|
||||
<p className="text-slate-600 mt-2">{competition.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<StatusTransitionButton status={competition.status} competitionId={competitionId} />
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card variant="bordered" padding="sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center">
|
||||
<Calendar className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Starts</p>
|
||||
<p className="text-sm font-medium text-slate-800">
|
||||
{formatDate(competition.starts_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="bordered" padding="sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Participants</p>
|
||||
<p className="text-sm font-medium text-slate-800">
|
||||
{competition.participant_count ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="bordered" padding="sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Registrations</p>
|
||||
<p className="text-sm font-medium text-slate-800">
|
||||
{competition.registration_count ?? 0} pending
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="bordered" padding="sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-pink-100 rounded-lg flex items-center justify-center">
|
||||
<Trophy className="w-5 h-5 text-pink-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Stages</p>
|
||||
<p className="text-sm font-medium text-slate-800">
|
||||
{competition.stages?.length ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-slate-200 mb-6">
|
||||
<div className="flex overflow-x-auto scrollbar-hide -mb-px">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.key;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-2 px-4 py-3 font-medium border-b-2 whitespace-nowrap transition-colors ${
|
||||
isActive
|
||||
? 'border-purple-600 text-purple-600'
|
||||
: 'border-transparent text-slate-600 hover:text-slate-900 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab competition={competition} clubId={clubId} />
|
||||
)}
|
||||
{activeTab === 'registrations' && (
|
||||
<RegistrationsTab competitionId={competitionId} />
|
||||
)}
|
||||
{activeTab === 'participants' && (
|
||||
<ParticipantsTab competitionId={competitionId} />
|
||||
)}
|
||||
{activeTab === 'fixtures' && (
|
||||
<FixturesTab competition={competition} />
|
||||
)}
|
||||
{activeTab === 'standings' && (
|
||||
<StandingsTab competition={competition} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import CompetitionDetailComponent from './CompetitionDetailComponent';
|
||||
|
||||
export default async function CompetitionDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ club_id: string; competition_id: string }>;
|
||||
}) {
|
||||
const { club_id, competition_id } = await params;
|
||||
const clubId = parseInt(club_id, 10);
|
||||
const competitionId = parseInt(competition_id, 10);
|
||||
|
||||
return <CompetitionDetailComponent clubId={clubId} competitionId={competitionId} />;
|
||||
}
|
||||
@ -0,0 +1,274 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Calendar, Play, RefreshCw, AlertCircle, Loader2, CheckCircle, Clock } from 'lucide-react';
|
||||
import Card from '@/src/components/cards/Card';
|
||||
import { useCompetitionFixtures } from '@/src/hooks/queries/useCompetitionQueries';
|
||||
import {
|
||||
useGenerateFixtures,
|
||||
useSetMatchWinner,
|
||||
} from '@/src/hooks/mutations/useCompetitionMutations';
|
||||
import type {
|
||||
Competition,
|
||||
CompetitionMatch,
|
||||
CompetitionStage,
|
||||
MatchStatus,
|
||||
} from '@/src/types/competition';
|
||||
|
||||
interface FixturesTabProps {
|
||||
competition: Competition;
|
||||
}
|
||||
|
||||
const matchStatusConfig: Record<MatchStatus, {
|
||||
label: string;
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
}> = {
|
||||
draft: { label: 'Draft', bgColor: 'bg-slate-100', textColor: 'text-slate-600' },
|
||||
needs_scheduling: { label: 'Needs Scheduling', bgColor: 'bg-amber-100', textColor: 'text-amber-700' },
|
||||
scheduled: { label: 'Scheduled', bgColor: 'bg-indigo-100', textColor: 'text-indigo-700' },
|
||||
in_progress: { label: 'In Progress', bgColor: 'bg-emerald-100', textColor: 'text-emerald-700' },
|
||||
completed: { label: 'Completed', bgColor: 'bg-purple-100', textColor: 'text-purple-700' },
|
||||
voided: { label: 'Voided', bgColor: 'bg-red-100', textColor: 'text-red-700' },
|
||||
};
|
||||
|
||||
function MatchCard({
|
||||
match,
|
||||
competitionId,
|
||||
stageId,
|
||||
}: {
|
||||
match: CompetitionMatch;
|
||||
competitionId: number;
|
||||
stageId: number;
|
||||
}) {
|
||||
const setWinnerMutation = useSetMatchWinner(competitionId, stageId);
|
||||
const config = matchStatusConfig[match.status];
|
||||
|
||||
const homeName = match.home_participant?.display_name ?? 'TBD';
|
||||
const awayName = match.away_participant?.display_name ?? 'TBD';
|
||||
const canSetWinner =
|
||||
match.status !== 'completed' &&
|
||||
match.status !== 'voided' &&
|
||||
match.home_participant_id &&
|
||||
match.away_participant_id;
|
||||
|
||||
function handleSetWinner(winnerId: number) {
|
||||
setWinnerMutation.mutate({
|
||||
matchId: match.competition_match_id,
|
||||
request: { winner_participant_id: winnerId },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-white border border-slate-200 rounded-xl">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-slate-500">
|
||||
Round {match.round_index + 1} • Match {match.match_index + 1}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${config.bgColor} ${config.textColor}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Home */}
|
||||
<div
|
||||
className={`flex items-center justify-between p-3 rounded-lg transition-colors ${
|
||||
match.winner_participant_id === match.home_participant_id
|
||||
? 'bg-emerald-50 border border-emerald-200'
|
||||
: 'bg-slate-50'
|
||||
} ${canSetWinner && match.home_participant_id ? 'cursor-pointer hover:bg-purple-50' : ''}`}
|
||||
onClick={() => canSetWinner && match.home_participant_id && handleSetWinner(match.home_participant_id)}
|
||||
>
|
||||
<span className={`font-medium ${homeName === 'TBD' ? 'text-slate-400' : 'text-slate-800'}`}>
|
||||
{homeName}
|
||||
</span>
|
||||
{match.winner_participant_id === match.home_participant_id && (
|
||||
<CheckCircle className="w-5 h-5 text-emerald-600" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center text-xs text-slate-400 font-medium">VS</div>
|
||||
|
||||
{/* Away */}
|
||||
<div
|
||||
className={`flex items-center justify-between p-3 rounded-lg transition-colors ${
|
||||
match.winner_participant_id === match.away_participant_id
|
||||
? 'bg-emerald-50 border border-emerald-200'
|
||||
: 'bg-slate-50'
|
||||
} ${canSetWinner && match.away_participant_id ? 'cursor-pointer hover:bg-purple-50' : ''}`}
|
||||
onClick={() => canSetWinner && match.away_participant_id && handleSetWinner(match.away_participant_id)}
|
||||
>
|
||||
<span className={`font-medium ${awayName === 'TBD' ? 'text-slate-400' : 'text-slate-800'}`}>
|
||||
{awayName}
|
||||
</span>
|
||||
{match.winner_participant_id === match.away_participant_id && (
|
||||
<CheckCircle className="w-5 h-5 text-emerald-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{match.scheduled_at && (
|
||||
<div className="flex items-center gap-1.5 mt-3 text-xs text-slate-500">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{new Date(match.scheduled_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StageFixtures({
|
||||
stage,
|
||||
competitionId,
|
||||
}: {
|
||||
stage: CompetitionStage;
|
||||
competitionId: number;
|
||||
}) {
|
||||
const { data: fixtures, isLoading, error } = useCompetitionFixtures(competitionId, stage.stage_id);
|
||||
const generateMutation = useGenerateFixtures(competitionId);
|
||||
|
||||
const hasFixtures = fixtures && fixtures.length > 0;
|
||||
|
||||
function handleGenerateFixtures() {
|
||||
generateMutation.mutate({
|
||||
stageId: stage.stage_id,
|
||||
request: {
|
||||
stage_type: stage.stage_type,
|
||||
legs: stage.config?.legs,
|
||||
third_place_match: stage.config?.third_place_match,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
<p className="text-red-700">Failed to load fixtures</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasFixtures) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<Calendar className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
||||
<h4 className="font-semibold text-slate-800 mb-2">No fixtures generated</h4>
|
||||
<p className="text-slate-500 mb-4">
|
||||
Generate fixtures to create the match schedule for this stage.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleGenerateFixtures}
|
||||
disabled={generateMutation.isPending}
|
||||
className="inline-flex items-center px-4 py-2 bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 text-white font-semibold rounded-xl hover:shadow-lg transition-all disabled:opacity-50"
|
||||
>
|
||||
{generateMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Generate Fixtures
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group by round
|
||||
const rounds = fixtures.reduce((acc, match) => {
|
||||
const round = match.round_index;
|
||||
if (!acc[round]) acc[round] = [];
|
||||
acc[round].push(match);
|
||||
return acc;
|
||||
}, {} as Record<number, CompetitionMatch[]>);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(rounds)
|
||||
.sort(([a], [b]) => parseInt(a) - parseInt(b))
|
||||
.map(([roundIndex, matches]) => (
|
||||
<div key={roundIndex}>
|
||||
<h4 className="font-semibold text-slate-700 mb-3">
|
||||
Round {parseInt(roundIndex) + 1}
|
||||
</h4>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{matches
|
||||
.sort((a, b) => a.match_index - b.match_index)
|
||||
.map((match) => (
|
||||
<MatchCard
|
||||
key={match.competition_match_id}
|
||||
match={match}
|
||||
competitionId={competitionId}
|
||||
stageId={stage.stage_id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FixturesTab({ competition }: FixturesTabProps) {
|
||||
const [selectedStageId, setSelectedStageId] = useState<number | null>(
|
||||
competition.stages?.[0]?.stage_id ?? null
|
||||
);
|
||||
|
||||
const stages = competition.stages ?? [];
|
||||
const selectedStage = stages.find((s) => s.stage_id === selectedStageId);
|
||||
|
||||
if (stages.length === 0) {
|
||||
return (
|
||||
<Card variant="bordered" padding="lg">
|
||||
<div className="text-center py-8">
|
||||
<Calendar className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
||||
<h3 className="font-semibold text-slate-800 mb-1">No stages configured</h3>
|
||||
<p className="text-slate-500">
|
||||
Configure stages in the competition settings to manage fixtures.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stage Selector */}
|
||||
{stages.length > 1 && (
|
||||
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
|
||||
{stages
|
||||
.sort((a, b) => a.stage_order - b.stage_order)
|
||||
.map((stage) => (
|
||||
<button
|
||||
key={stage.stage_id}
|
||||
onClick={() => setSelectedStageId(stage.stage_id)}
|
||||
className={`px-4 py-2 rounded-xl font-medium whitespace-nowrap transition-colors ${
|
||||
selectedStageId === stage.stage_id
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{stage.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fixtures */}
|
||||
{selectedStage && (
|
||||
<StageFixtures
|
||||
stage={selectedStage}
|
||||
competitionId={competition.competition_id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Calendar, Users, Eye, Globe, Lock, Link as LinkIcon } from 'lucide-react';
|
||||
import Card from '@/src/components/cards/Card';
|
||||
import type { Competition, CompetitionStage } from '@/src/types/competition';
|
||||
|
||||
interface OverviewTabProps {
|
||||
competition: Competition;
|
||||
clubId: number;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | null): string {
|
||||
if (!dateString) return 'Not set';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function StageCard({ stage, index }: { stage: CompetitionStage; index: number }) {
|
||||
const statusColors = {
|
||||
pending: 'bg-slate-100 text-slate-700',
|
||||
active: 'bg-emerald-100 text-emerald-700',
|
||||
completed: 'bg-purple-100 text-purple-700',
|
||||
};
|
||||
|
||||
const stageTypeLabels: Record<string, string> = {
|
||||
round_robin: 'Round Robin',
|
||||
group_stage: 'Group Stage',
|
||||
knockout_single: 'Single Elimination',
|
||||
knockout_double: 'Double Elimination',
|
||||
rating_delta_challenge: 'Rating Challenge',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 p-4 bg-slate-50 rounded-xl">
|
||||
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center font-bold text-slate-600 border border-slate-200">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold text-slate-800">{stage.name}</h4>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${statusColors[stage.status]}`}>
|
||||
{stage.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">
|
||||
{stageTypeLabels[stage.stage_type] || stage.stage_type}
|
||||
{stage.match_count !== undefined && ` • ${stage.match_count} matches`}
|
||||
{stage.groups && stage.groups.length > 0 && ` • ${stage.groups.length} groups`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OverviewTab({ competition, clubId }: OverviewTabProps) {
|
||||
const visibilityIcons = {
|
||||
public: Globe,
|
||||
unlisted: LinkIcon,
|
||||
private: Lock,
|
||||
};
|
||||
const VisibilityIcon = visibilityIcons[competition.visibility];
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Competition Details */}
|
||||
<Card variant="bordered" padding="md">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">Competition Details</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Eye className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Visibility</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<VisibilityIcon className="w-4 h-4 text-slate-600" />
|
||||
<p className="font-medium text-slate-800 capitalize">{competition.visibility}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Calendar className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Competition Period</p>
|
||||
<p className="font-medium text-slate-800">{formatDate(competition.starts_at)}</p>
|
||||
{competition.ends_at && (
|
||||
<p className="text-sm text-slate-600">to {formatDate(competition.ends_at)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Users className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Registration Period</p>
|
||||
{competition.registration_open_at ? (
|
||||
<>
|
||||
<p className="font-medium text-slate-800">
|
||||
Opens: {formatDate(competition.registration_open_at)}
|
||||
</p>
|
||||
{competition.registration_close_at && (
|
||||
<p className="text-sm text-slate-600">
|
||||
Closes: {formatDate(competition.registration_close_at)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="font-medium text-slate-800">Not configured</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Stages */}
|
||||
<Card variant="bordered" padding="md">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">Stages</h3>
|
||||
{competition.stages && competition.stages.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{competition.stages
|
||||
.sort((a, b) => a.stage_order - b.stage_order)
|
||||
.map((stage, index) => (
|
||||
<StageCard key={stage.stage_id} stage={stage} index={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-slate-500">No stages configured yet.</p>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Stages will be created based on the competition type and configuration.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Configuration Preview */}
|
||||
{competition.config_snapshot && (
|
||||
<Card variant="bordered" padding="md" className="lg:col-span-2">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">Configuration</h3>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{competition.config_snapshot.ops?.registration && (
|
||||
<div className="p-4 bg-slate-50 rounded-xl">
|
||||
<h4 className="font-medium text-slate-800 mb-2">Registration</h4>
|
||||
<div className="space-y-1 text-sm text-slate-600">
|
||||
<p>
|
||||
Approval:{' '}
|
||||
{competition.config_snapshot.ops.registration.requires_approval
|
||||
? 'Required'
|
||||
: 'Auto-approve'}
|
||||
</p>
|
||||
{competition.config_snapshot.ops.registration.max_participants && (
|
||||
<p>
|
||||
Max participants: {competition.config_snapshot.ops.registration.max_participants}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
Waitlist:{' '}
|
||||
{competition.config_snapshot.ops.registration.waitlist_enabled
|
||||
? 'Enabled'
|
||||
: 'Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{competition.config_snapshot.rating && (
|
||||
<div className="p-4 bg-slate-50 rounded-xl">
|
||||
<h4 className="font-medium text-slate-800 mb-2">Rating</h4>
|
||||
<div className="space-y-1 text-sm text-slate-600">
|
||||
<p>
|
||||
Affects rating:{' '}
|
||||
{competition.config_snapshot.rating.affects_rating ? 'Yes' : 'No'}
|
||||
</p>
|
||||
{competition.config_snapshot.rating.rating_weight && (
|
||||
<p>Weight: {competition.config_snapshot.rating.rating_weight}x</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{competition.config_snapshot.format && (
|
||||
<div className="p-4 bg-slate-50 rounded-xl">
|
||||
<h4 className="font-medium text-slate-800 mb-2">Format</h4>
|
||||
<div className="space-y-1 text-sm text-slate-600">
|
||||
{competition.config_snapshot.format.participant_type && (
|
||||
<p className="capitalize">
|
||||
Type: {competition.config_snapshot.format.participant_type}
|
||||
</p>
|
||||
)}
|
||||
{competition.config_snapshot.format.team_size && (
|
||||
<p>
|
||||
Team size: {competition.config_snapshot.format.team_size.min}-
|
||||
{competition.config_snapshot.format.team_size.max}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Users, UserMinus, Hash, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import Card from '@/src/components/cards/Card';
|
||||
import { useCompetitionParticipants } from '@/src/hooks/queries/useCompetitionQueries';
|
||||
import {
|
||||
useSetParticipantSeed,
|
||||
useWithdrawParticipant,
|
||||
} from '@/src/hooks/mutations/useCompetitionMutations';
|
||||
import type { CompetitionParticipant, ParticipantStatus } from '@/src/types/competition';
|
||||
|
||||
interface ParticipantsTabProps {
|
||||
competitionId: number;
|
||||
}
|
||||
|
||||
const statusColors: Record<ParticipantStatus, string> = {
|
||||
active: 'bg-emerald-100 text-emerald-700',
|
||||
withdrawn: 'bg-slate-100 text-slate-500',
|
||||
eliminated: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
function ParticipantRow({
|
||||
participant,
|
||||
competitionId,
|
||||
}: {
|
||||
participant: CompetitionParticipant;
|
||||
competitionId: number;
|
||||
}) {
|
||||
const [isEditingSeed, setIsEditingSeed] = useState(false);
|
||||
const [seedValue, setSeedValue] = useState(participant.seed?.toString() ?? '');
|
||||
|
||||
const setSeedMutation = useSetParticipantSeed(competitionId);
|
||||
const withdrawMutation = useWithdrawParticipant(competitionId);
|
||||
|
||||
function handleSeedSave() {
|
||||
const seed = parseInt(seedValue, 10);
|
||||
if (!isNaN(seed) && seed > 0) {
|
||||
setSeedMutation.mutate(
|
||||
{ participantId: participant.participant_id, seed },
|
||||
{ onSuccess: () => setIsEditingSeed(false) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 p-4 bg-white border border-slate-200 rounded-xl">
|
||||
{/* Seed */}
|
||||
<div className="w-12 flex-shrink-0">
|
||||
{isEditingSeed ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
value={seedValue}
|
||||
onChange={(e) => setSeedValue(e.target.value)}
|
||||
className="w-12 px-2 py-1 text-center text-sm border border-slate-200 rounded focus:outline-none focus:ring-1 focus:ring-purple-500"
|
||||
min="1"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSeedSave();
|
||||
if (e.key === 'Escape') setIsEditingSeed(false);
|
||||
}}
|
||||
onBlur={handleSeedSave}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsEditingSeed(true)}
|
||||
className="w-10 h-10 flex items-center justify-center bg-slate-100 rounded-lg text-slate-600 font-medium hover:bg-slate-200 transition-colors"
|
||||
title="Click to set seed"
|
||||
>
|
||||
{participant.seed ?? <Hash className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold text-slate-800 truncate">
|
||||
{participant.display_name}
|
||||
</h4>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${statusColors[participant.status]}`}
|
||||
>
|
||||
{participant.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-slate-600">
|
||||
{participant.members && participant.members.length > 0 && (
|
||||
<span>
|
||||
{participant.members.map((m) => m.display_name).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{participant.group_name && (
|
||||
<>
|
||||
<span className="text-slate-400">•</span>
|
||||
<span>Group {participant.group_name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{participant.status === 'active' && (
|
||||
<button
|
||||
onClick={() => withdrawMutation.mutate(participant.participant_id)}
|
||||
disabled={withdrawMutation.isPending}
|
||||
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
|
||||
title="Withdraw participant"
|
||||
>
|
||||
{withdrawMutation.isPending ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<UserMinus className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-4 p-4 bg-white border border-slate-200 rounded-xl">
|
||||
<div className="w-10 h-10 bg-slate-200 rounded-lg animate-pulse" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-5 w-32 bg-slate-200 rounded animate-pulse" />
|
||||
<div className="h-5 w-16 bg-slate-200 rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div className="h-4 w-48 bg-slate-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ParticipantsTab({ competitionId }: ParticipantsTabProps) {
|
||||
const [statusFilter, setStatusFilter] = useState<ParticipantStatus | 'all'>('all');
|
||||
|
||||
const { data: participants, isLoading, error } = useCompetitionParticipants(competitionId, {
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
});
|
||||
|
||||
const activeCount = participants?.filter((p) => p.status === 'active').length ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4 mb-6">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as ParticipantStatus | 'all')}
|
||||
className="px-4 py-2 bg-white border border-slate-200 rounded-xl text-sm font-medium text-slate-700 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="all">All ({participants?.length ?? 0})</option>
|
||||
<option value="active">Active ({activeCount})</option>
|
||||
<option value="withdrawn">Withdrawn</option>
|
||||
<option value="eliminated">Eliminated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 rounded-xl mb-6">
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
<p className="text-red-700">Failed to load participants</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<ParticipantSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : participants && participants.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{participants
|
||||
.sort((a, b) => (a.seed ?? 999) - (b.seed ?? 999))
|
||||
.map((participant) => (
|
||||
<ParticipantRow
|
||||
key={participant.participant_id}
|
||||
participant={participant}
|
||||
competitionId={competitionId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card variant="bordered" padding="lg">
|
||||
<div className="text-center py-8">
|
||||
<Users className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
||||
<h3 className="font-semibold text-slate-800 mb-1">No participants yet</h3>
|
||||
<p className="text-slate-500">
|
||||
Approve registrations to add participants to this competition.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Check, X, Clock, UserCheck, UserX, Loader2, AlertCircle } from 'lucide-react';
|
||||
import Card from '@/src/components/cards/Card';
|
||||
import { useCompetitionRegistrations } from '@/src/hooks/queries/useCompetitionQueries';
|
||||
import {
|
||||
useApproveRegistration,
|
||||
useRejectRegistration,
|
||||
} from '@/src/hooks/mutations/useCompetitionMutations';
|
||||
import type { CompetitionRegistration, RegistrationStatus } from '@/src/types/competition';
|
||||
|
||||
interface RegistrationsTabProps {
|
||||
competitionId: number;
|
||||
}
|
||||
|
||||
const statusConfig: Record<RegistrationStatus, {
|
||||
label: string;
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
}> = {
|
||||
pending: {
|
||||
label: 'Pending',
|
||||
bgColor: 'bg-amber-100',
|
||||
textColor: 'text-amber-700',
|
||||
Icon: Clock,
|
||||
},
|
||||
approved: {
|
||||
label: 'Approved',
|
||||
bgColor: 'bg-emerald-100',
|
||||
textColor: 'text-emerald-700',
|
||||
Icon: UserCheck,
|
||||
},
|
||||
rejected: {
|
||||
label: 'Rejected',
|
||||
bgColor: 'bg-red-100',
|
||||
textColor: 'text-red-700',
|
||||
Icon: UserX,
|
||||
},
|
||||
waitlisted: {
|
||||
label: 'Waitlisted',
|
||||
bgColor: 'bg-slate-100',
|
||||
textColor: 'text-slate-700',
|
||||
Icon: Clock,
|
||||
},
|
||||
withdrawn: {
|
||||
label: 'Withdrawn',
|
||||
bgColor: 'bg-gray-100',
|
||||
textColor: 'text-gray-700',
|
||||
Icon: X,
|
||||
},
|
||||
};
|
||||
|
||||
function RegistrationRow({
|
||||
registration,
|
||||
competitionId,
|
||||
}: {
|
||||
registration: CompetitionRegistration;
|
||||
competitionId: number;
|
||||
}) {
|
||||
const approveMutation = useApproveRegistration(competitionId);
|
||||
const rejectMutation = useRejectRegistration(competitionId);
|
||||
const isPending = approveMutation.isPending || rejectMutation.isPending;
|
||||
|
||||
const config = statusConfig[registration.status];
|
||||
const StatusIcon = config.Icon;
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 p-4 bg-white border border-slate-200 rounded-xl">
|
||||
{/* User Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold text-slate-800 truncate">
|
||||
{registration.team_name || registration.display_name}
|
||||
</h4>
|
||||
<span className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full ${config.bgColor} ${config.textColor}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1 text-sm text-slate-600">
|
||||
{registration.members && registration.members.length > 0 && (
|
||||
<span>
|
||||
{registration.members.map((m) => m.display_name).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-slate-400">•</span>
|
||||
<span>{formatDate(registration.created_at)}</span>
|
||||
</div>
|
||||
{registration.notes && (
|
||||
<p className="text-sm text-slate-500 mt-1 truncate">
|
||||
{registration.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{registration.status === 'pending' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => approveMutation.mutate(registration.registration_id)}
|
||||
disabled={isPending}
|
||||
className="p-2 bg-emerald-100 text-emerald-700 rounded-lg hover:bg-emerald-200 transition-colors disabled:opacity-50"
|
||||
title="Approve"
|
||||
>
|
||||
{approveMutation.isPending ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Check className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rejectMutation.mutate(registration.registration_id)}
|
||||
disabled={isPending}
|
||||
className="p-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors disabled:opacity-50"
|
||||
title="Reject"
|
||||
>
|
||||
{rejectMutation.isPending ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<X className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegistrationSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-4 p-4 bg-white border border-slate-200 rounded-xl">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-5 w-32 bg-slate-200 rounded animate-pulse" />
|
||||
<div className="h-5 w-16 bg-slate-200 rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div className="h-4 w-48 bg-slate-200 rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-9 h-9 bg-slate-200 rounded-lg animate-pulse" />
|
||||
<div className="w-9 h-9 bg-slate-200 rounded-lg animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegistrationsTab({ competitionId }: RegistrationsTabProps) {
|
||||
const [statusFilter, setStatusFilter] = useState<RegistrationStatus | 'all'>('all');
|
||||
|
||||
const { data: registrations, isLoading, error } = useCompetitionRegistrations(competitionId, {
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
});
|
||||
|
||||
const pendingCount = registrations?.filter((r) => r.status === 'pending').length ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as RegistrationStatus | 'all')}
|
||||
className="px-4 py-2 bg-white border border-slate-200 rounded-xl text-sm font-medium text-slate-700 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="pending">Pending ({pendingCount})</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="waitlisted">Waitlisted</option>
|
||||
<option value="withdrawn">Withdrawn</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 rounded-xl mb-6">
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
<p className="text-red-700">Failed to load registrations</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<RegistrationSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : registrations && registrations.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{registrations.map((registration) => (
|
||||
<RegistrationRow
|
||||
key={registration.registration_id}
|
||||
registration={registration}
|
||||
competitionId={competitionId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card variant="bordered" padding="lg">
|
||||
<div className="text-center py-8">
|
||||
<Clock className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
||||
<h3 className="font-semibold text-slate-800 mb-1">No registrations yet</h3>
|
||||
<p className="text-slate-500">
|
||||
Registrations will appear here when players sign up.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,317 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { RefreshCw, ListOrdered, AlertCircle, Loader2, Trophy } from 'lucide-react';
|
||||
import Card from '@/src/components/cards/Card';
|
||||
import {
|
||||
useCompetitionStandings,
|
||||
useCompetitionLeaderboard,
|
||||
} from '@/src/hooks/queries/useCompetitionQueries';
|
||||
import {
|
||||
useRecalculateStandings,
|
||||
useRecalculateLeaderboard,
|
||||
} from '@/src/hooks/mutations/useCompetitionMutations';
|
||||
import type { Competition, CompetitionStage, CompetitionStanding, LeaderboardEntry, LeaderboardStats } from '@/src/types/competition';
|
||||
|
||||
interface StandingsTabProps {
|
||||
competition: Competition;
|
||||
}
|
||||
|
||||
function StandingsTable({
|
||||
standings,
|
||||
competitionId,
|
||||
stageId,
|
||||
}: {
|
||||
standings: CompetitionStanding[];
|
||||
competitionId: number;
|
||||
stageId: number;
|
||||
}) {
|
||||
const recalculateMutation = useRecalculateStandings(competitionId, stageId);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-semibold text-slate-700">Standings</h4>
|
||||
<button
|
||||
onClick={() => recalculateMutation.mutate(undefined)}
|
||||
disabled={recalculateMutation.isPending}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{recalculateMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-1.5" />
|
||||
)}
|
||||
Recalculate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left text-xs font-semibold text-slate-500 uppercase py-3 px-4">#</th>
|
||||
<th className="text-left text-xs font-semibold text-slate-500 uppercase py-3 px-4">Team</th>
|
||||
<th className="text-center text-xs font-semibold text-slate-500 uppercase py-3 px-4">P</th>
|
||||
<th className="text-center text-xs font-semibold text-slate-500 uppercase py-3 px-4">W</th>
|
||||
<th className="text-center text-xs font-semibold text-slate-500 uppercase py-3 px-4">D</th>
|
||||
<th className="text-center text-xs font-semibold text-slate-500 uppercase py-3 px-4">L</th>
|
||||
<th className="text-center text-xs font-semibold text-slate-500 uppercase py-3 px-4">GD</th>
|
||||
<th className="text-center text-xs font-semibold text-slate-500 uppercase py-3 px-4 bg-purple-50">Pts</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{standings
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
.map((standing, index) => (
|
||||
<tr
|
||||
key={standing.standing_id}
|
||||
className={`border-b border-slate-100 ${
|
||||
index < 2 ? 'bg-emerald-50/50' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`font-semibold ${index < 2 ? 'text-emerald-600' : 'text-slate-600'}`}>
|
||||
{standing.rank}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-slate-800">
|
||||
{standing.display_name}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center text-slate-600">{standing.played}</td>
|
||||
<td className="py-3 px-4 text-center text-emerald-600">{standing.won}</td>
|
||||
<td className="py-3 px-4 text-center text-amber-600">{standing.drawn}</td>
|
||||
<td className="py-3 px-4 text-center text-red-600">{standing.lost}</td>
|
||||
<td className="py-3 px-4 text-center text-slate-600">
|
||||
{standing.goal_difference > 0 ? '+' : ''}
|
||||
{standing.goal_difference}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center font-bold text-purple-700 bg-purple-50">
|
||||
{standing.points}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LeaderboardTable({
|
||||
leaderboardData,
|
||||
competitionId,
|
||||
stageId,
|
||||
}: {
|
||||
leaderboardData: { leaderboard: LeaderboardEntry[]; stats: LeaderboardStats };
|
||||
competitionId: number;
|
||||
stageId: number;
|
||||
}) {
|
||||
const recalculateMutation = useRecalculateLeaderboard(competitionId, stageId);
|
||||
const entries = leaderboardData.leaderboard;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-semibold text-slate-700">Leaderboard</h4>
|
||||
<button
|
||||
onClick={() => recalculateMutation.mutate(undefined)}
|
||||
disabled={recalculateMutation.isPending}
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{recalculateMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-1.5" />
|
||||
)}
|
||||
Recalculate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{entries
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
.map((entry, index) => (
|
||||
<div
|
||||
key={entry.entry_id}
|
||||
className={`flex items-center gap-4 p-4 rounded-xl ${
|
||||
index === 0
|
||||
? 'bg-gradient-to-r from-amber-100 to-amber-50 border border-amber-200'
|
||||
: index === 1
|
||||
? 'bg-gradient-to-r from-slate-200 to-slate-100 border border-slate-300'
|
||||
: index === 2
|
||||
? 'bg-gradient-to-r from-orange-100 to-orange-50 border border-orange-200'
|
||||
: 'bg-white border border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-bold ${
|
||||
index === 0
|
||||
? 'bg-amber-500 text-white'
|
||||
: index === 1
|
||||
? 'bg-slate-400 text-white'
|
||||
: index === 2
|
||||
? 'bg-orange-400 text-white'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{entry.rank}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h5 className="font-semibold text-slate-800">{entry.display_name}</h5>
|
||||
<p className="text-sm text-slate-500">
|
||||
{entry.matches_counted} matches counted
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{entry.metric_value > 0 ? '+' : ''}
|
||||
{entry.metric_value.toFixed(1)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">Rating Delta</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StageStandings({
|
||||
stage,
|
||||
competitionId,
|
||||
}: {
|
||||
stage: CompetitionStage;
|
||||
competitionId: number;
|
||||
}) {
|
||||
const isChallenge = stage.stage_type === 'rating_delta_challenge';
|
||||
|
||||
const {
|
||||
data: standings,
|
||||
isLoading: standingsLoading,
|
||||
error: standingsError,
|
||||
} = useCompetitionStandings(competitionId, stage.stage_id, undefined);
|
||||
|
||||
const {
|
||||
data: leaderboard,
|
||||
isLoading: leaderboardLoading,
|
||||
error: leaderboardError,
|
||||
} = useCompetitionLeaderboard(competitionId, stage.stage_id);
|
||||
|
||||
const isLoading = isChallenge ? leaderboardLoading : standingsLoading;
|
||||
const error = isChallenge ? leaderboardError : standingsError;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
<p className="text-red-700">Failed to load standings</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isChallenge) {
|
||||
if (!leaderboard || leaderboard.leaderboard.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<Trophy className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
||||
<h4 className="font-semibold text-slate-800 mb-2">No leaderboard data</h4>
|
||||
<p className="text-slate-500">
|
||||
Leaderboard will populate as matches are completed.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LeaderboardTable
|
||||
leaderboardData={leaderboard}
|
||||
competitionId={competitionId}
|
||||
stageId={stage.stage_id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!standings || standings.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<ListOrdered className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
||||
<h4 className="font-semibold text-slate-800 mb-2">No standings yet</h4>
|
||||
<p className="text-slate-500">
|
||||
Standings will populate as matches are completed.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StandingsTable
|
||||
standings={standings}
|
||||
competitionId={competitionId}
|
||||
stageId={stage.stage_id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StandingsTab({ competition }: StandingsTabProps) {
|
||||
const stages = competition.stages ?? [];
|
||||
const [selectedStageId, setSelectedStageId] = useState<number | null>(
|
||||
stages[0]?.stage_id ?? null
|
||||
);
|
||||
|
||||
const selectedStage = stages.find((s) => s.stage_id === selectedStageId);
|
||||
|
||||
if (stages.length === 0) {
|
||||
return (
|
||||
<Card variant="bordered" padding="lg">
|
||||
<div className="text-center py-8">
|
||||
<ListOrdered className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
||||
<h3 className="font-semibold text-slate-800 mb-1">No stages configured</h3>
|
||||
<p className="text-slate-500">
|
||||
Configure stages in the competition settings to view standings.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Stage Selector */}
|
||||
{stages.length > 1 && (
|
||||
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
|
||||
{stages
|
||||
.sort((a, b) => a.stage_order - b.stage_order)
|
||||
.map((stage) => (
|
||||
<button
|
||||
key={stage.stage_id}
|
||||
onClick={() => setSelectedStageId(stage.stage_id)}
|
||||
className={`px-4 py-2 rounded-xl font-medium whitespace-nowrap transition-colors ${
|
||||
selectedStageId === stage.stage_id
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{stage.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Standings */}
|
||||
{selectedStage && (
|
||||
<StageStandings
|
||||
stage={selectedStage}
|
||||
competitionId={competition.competition_id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import CompetitionsPageComponent from './CompetitionsPageComponent';
|
||||
|
||||
export default async function CompetitionsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ club_id: string }>;
|
||||
}) {
|
||||
const { club_id } = await params;
|
||||
const clubId = parseInt(club_id, 10);
|
||||
|
||||
return <CompetitionsPageComponent clubId={clubId} />;
|
||||
}
|
||||
@ -0,0 +1,265 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { 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 { useCreateTemplate } from '@/src/hooks/mutations/useCompetitionMutations';
|
||||
import type { CompetitionType, CreateTemplateRequest, StageType } from '@/src/types/competition';
|
||||
|
||||
interface CreateTemplateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
facilityId: number;
|
||||
}
|
||||
|
||||
const competitionTypes: {
|
||||
type: CompetitionType;
|
||||
label: string;
|
||||
description: string;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
defaultStageType: StageType;
|
||||
}[] = [
|
||||
{
|
||||
type: 'league',
|
||||
label: 'League',
|
||||
description: 'Round-robin format with standings',
|
||||
Icon: Trophy,
|
||||
defaultStageType: 'round_robin',
|
||||
},
|
||||
{
|
||||
type: 'tournament',
|
||||
label: 'Tournament',
|
||||
description: 'Knockout bracket format',
|
||||
Icon: Target,
|
||||
defaultStageType: 'knockout_single',
|
||||
},
|
||||
{
|
||||
type: 'challenge',
|
||||
label: 'Challenge',
|
||||
description: 'Rating-based leaderboard',
|
||||
Icon: Zap,
|
||||
defaultStageType: 'rating_delta_challenge',
|
||||
},
|
||||
{
|
||||
type: 'hybrid',
|
||||
label: 'Hybrid',
|
||||
description: 'Multiple stages combined',
|
||||
Icon: Layers,
|
||||
defaultStageType: 'group_stage',
|
||||
},
|
||||
];
|
||||
|
||||
export default function CreateTemplateModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
facilityId,
|
||||
}: CreateTemplateModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
type: 'tournament' as CompetitionType,
|
||||
name: '',
|
||||
description: '',
|
||||
sport_id: 1, // Default to padel
|
||||
});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useCreateTemplate(facilityId);
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setFormData({
|
||||
type: 'tournament',
|
||||
name: '',
|
||||
description: '',
|
||||
sport_id: 1,
|
||||
});
|
||||
setFormError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
setFormError('Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedType = competitionTypes.find((t) => t.type === formData.type);
|
||||
if (!selectedType) {
|
||||
setFormError('Invalid competition type');
|
||||
return;
|
||||
}
|
||||
|
||||
const request: CreateTemplateRequest = {
|
||||
type: formData.type,
|
||||
scope: 'facility',
|
||||
sport_id: formData.sport_id,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
config: {
|
||||
format: {
|
||||
stages: [
|
||||
{
|
||||
legs: formData.type === 'league' ? 2 : undefined,
|
||||
points_win: formData.type === 'league' ? 3 : undefined,
|
||||
points_draw: formData.type === 'league' ? 1 : undefined,
|
||||
points_loss: formData.type === 'league' ? 0 : undefined,
|
||||
third_place_match: formData.type === 'tournament' ? false : undefined,
|
||||
leaderboard_metric:
|
||||
formData.type === 'challenge' ? 'rating_delta_sum' : undefined,
|
||||
min_matches: formData.type === 'challenge' ? 3 : undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
ops: {
|
||||
registration: {
|
||||
requires_approval: true,
|
||||
waitlist_enabled: true,
|
||||
},
|
||||
},
|
||||
rating: {
|
||||
affects_rating: true,
|
||||
rating_weight: 1.0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await createMutation.mutateAsync(request);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : 'Failed to create template');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||
<ModalHeader
|
||||
title="Create Template"
|
||||
subtitle="Create a reusable competition configuration"
|
||||
onClose={onClose}
|
||||
/>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ModalBody padding="md">
|
||||
<div className="space-y-5">
|
||||
{formError && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-xl">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||
<p className="text-sm text-red-700">{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Competition Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{competitionTypes.map(({ type, label, description, Icon }) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setFormData((prev) => ({ ...prev, type }))}
|
||||
className={`flex items-center gap-3 p-4 border rounded-xl text-left transition-colors ${
|
||||
formData.type === type
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
formData.type === type
|
||||
? 'bg-purple-100'
|
||||
: 'bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-5 h-5 ${
|
||||
formData.type === type
|
||||
? 'text-purple-600'
|
||||
: 'text-slate-500'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={`font-semibold ${
|
||||
formData.type === type
|
||||
? 'text-purple-700'
|
||||
: 'text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">{description}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="e.g., Standard Tournament (8 players)"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, description: e.target.value }))
|
||||
}
|
||||
placeholder="Describe the template configuration..."
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter align="right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 bg-slate-100 text-slate-700 font-semibold rounded-xl hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="px-6 py-2.5 bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 text-white font-semibold rounded-xl hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Creating...
|
||||
</span>
|
||||
) : (
|
||||
'Create Template'
|
||||
)}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Plus, Trophy, Target, Zap, Layers, MoreVertical, Pencil, Trash2, Eye, EyeOff } from 'lucide-react';
|
||||
import useTranslation from '@/src/hooks/useTranslation';
|
||||
import Card from '@/src/components/cards/Card';
|
||||
import { useCompetitionTemplates } from '@/src/hooks/queries/useCompetitionQueries';
|
||||
import { useUpdateTemplate } from '@/src/hooks/mutations/useCompetitionMutations';
|
||||
import CreateTemplateModal from './CreateTemplateModal';
|
||||
import type { CompetitionTemplate, CompetitionType } from '@/src/types/competition';
|
||||
|
||||
interface TemplatesPageComponentProps {
|
||||
clubId: number;
|
||||
}
|
||||
|
||||
const typeIcons: Record<CompetitionType, React.ComponentType<{ className?: string }>> = {
|
||||
league: Trophy,
|
||||
tournament: Target,
|
||||
challenge: Zap,
|
||||
hybrid: Layers,
|
||||
};
|
||||
|
||||
const typeColors: Record<CompetitionType, string> = {
|
||||
league: 'bg-amber-100 text-amber-700',
|
||||
tournament: 'bg-purple-100 text-purple-700',
|
||||
challenge: 'bg-pink-100 text-pink-700',
|
||||
hybrid: 'bg-indigo-100 text-indigo-700',
|
||||
};
|
||||
|
||||
function TemplateCard({
|
||||
template,
|
||||
onToggleActive,
|
||||
}: {
|
||||
template: CompetitionTemplate;
|
||||
onToggleActive: () => void;
|
||||
}) {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const Icon = typeIcons[template.type];
|
||||
|
||||
return (
|
||||
<Card variant="bordered" padding="none" className="overflow-visible">
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${typeColors[template.type]}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4 text-slate-500" />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setShowMenu(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 w-48 bg-white border border-slate-200 rounded-xl shadow-lg z-20 py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMenu(false);
|
||||
// TODO: Open edit modal
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
Edit Template
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMenu(false);
|
||||
onToggleActive();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
{template.is_active ? (
|
||||
<>
|
||||
<EyeOff className="w-4 h-4" />
|
||||
Deactivate
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-4 h-4" />
|
||||
Activate
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-1">
|
||||
{template.name}
|
||||
</h3>
|
||||
{template.description && (
|
||||
<p className="text-sm text-slate-600 mb-3 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs px-2 py-1 rounded-full capitalize ${typeColors[template.type]}`}>
|
||||
{template.type}
|
||||
</span>
|
||||
{!template.is_active && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-slate-100 text-slate-500">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
{template.scope === 'platform' && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-indigo-100 text-indigo-700">
|
||||
Platform
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateCardSkeleton() {
|
||||
return (
|
||||
<Card variant="bordered" padding="none">
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-slate-200 rounded-lg animate-pulse" />
|
||||
<div className="w-8 h-8 bg-slate-200 rounded-lg animate-pulse" />
|
||||
</div>
|
||||
<div className="h-6 w-3/4 bg-slate-200 rounded mb-2 animate-pulse" />
|
||||
<div className="h-4 w-full bg-slate-200 rounded mb-3 animate-pulse" />
|
||||
<div className="flex gap-2">
|
||||
<div className="h-5 w-16 bg-slate-200 rounded-full animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TemplatesPageComponent({ clubId }: TemplatesPageComponentProps) {
|
||||
const { locale } = useTranslation();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const { data: templates, isLoading, error } = useCompetitionTemplates(clubId);
|
||||
const updateMutation = useUpdateTemplate();
|
||||
|
||||
function handleToggleActive(template: CompetitionTemplate) {
|
||||
updateMutation.mutate({
|
||||
templateId: template.template_id,
|
||||
data: { is_active: !template.is_active },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<Link
|
||||
href={`/${locale}/admin/clubs/${clubId}/competitions`}
|
||||
className="inline-flex items-center text-slate-600 hover:text-slate-900 font-medium transition-colors mb-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Competitions
|
||||
</Link>
|
||||
<h2 className="text-2xl font-bold text-slate-900">Competition Templates</h2>
|
||||
<p className="text-slate-600 mt-1">
|
||||
Reusable configurations for creating competitions
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="inline-flex items-center px-4 py-2.5 bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 text-white font-semibold rounded-xl hover:shadow-lg transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
New Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
||||
<p className="text-red-700 font-medium">
|
||||
Failed to load templates. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Templates Grid */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<TemplateCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : templates && templates.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{templates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.template_id}
|
||||
template={template}
|
||||
onToggleActive={() => handleToggleActive(template)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-4">
|
||||
<Trophy className="w-8 h-8 text-slate-400" />
|
||||
</div>
|
||||
<p className="text-slate-600 text-center mb-4">
|
||||
No templates yet. Create your first template to get started.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="inline-flex items-center px-4 py-2.5 bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 text-white font-semibold rounded-xl hover:shadow-lg transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Create Template
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Template Modal */}
|
||||
<CreateTemplateModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
facilityId={clubId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import TemplatesPageComponent from './TemplatesPageComponent';
|
||||
|
||||
export default async function TemplatesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ club_id: string }>;
|
||||
}) {
|
||||
const { club_id } = await params;
|
||||
const clubId = parseInt(club_id, 10);
|
||||
|
||||
return <TemplatesPageComponent clubId={clubId} />;
|
||||
}
|
||||
@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Calendar, Users, MapPin, ChevronRight } from 'lucide-react';
|
||||
import Card from '@/src/components/cards/Card';
|
||||
import CompetitionStatusBadge from './CompetitionStatusBadge';
|
||||
import CompetitionTypeBadge from './CompetitionTypeBadge';
|
||||
import type { Competition } from '@/src/types/competition';
|
||||
|
||||
interface CompetitionCardProps {
|
||||
competition: Competition;
|
||||
facilityId: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateRange(startDate: string, endDate: string | null): string {
|
||||
const start = formatDate(startDate);
|
||||
if (!endDate) return start;
|
||||
const end = formatDate(endDate);
|
||||
return `${start} - ${end}`;
|
||||
}
|
||||
|
||||
export default function CompetitionCard({
|
||||
competition,
|
||||
facilityId,
|
||||
className = '',
|
||||
}: CompetitionCardProps) {
|
||||
const href = `/facility/${facilityId}/competitions/${competition.competition_id}`;
|
||||
|
||||
return (
|
||||
<Link href={href} className={`block group ${className}`}>
|
||||
<Card
|
||||
variant="bordered"
|
||||
padding="none"
|
||||
hover
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="p-5">
|
||||
{/* Header with badges */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<CompetitionTypeBadge type={competition.type} size="xs" />
|
||||
<CompetitionStatusBadge status={competition.status} size="xs" />
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-purple-600 transition-colors flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-2 group-hover:text-purple-700 transition-colors line-clamp-2">
|
||||
{competition.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
{competition.description && (
|
||||
<p className="text-sm text-slate-600 mb-4 line-clamp-2">
|
||||
{competition.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex flex-wrap gap-4 text-sm text-slate-500">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{formatDateRange(competition.starts_at, competition.ends_at)}</span>
|
||||
</div>
|
||||
{competition.participant_count !== undefined && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{competition.participant_count} participants</span>
|
||||
</div>
|
||||
)}
|
||||
{competition.scope !== 'facility' && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin className="w-4 h-4" />
|
||||
<span className="capitalize">{competition.scope.replace('_', ' ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration status bar for published competitions */}
|
||||
{competition.status === 'published' && competition.registration_count !== undefined && (
|
||||
<div className="px-5 py-3 bg-slate-50 border-t border-slate-100">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-600">Registrations</span>
|
||||
<span className="font-medium text-slate-800">
|
||||
{competition.registration_count} pending
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import Card from '@/src/components/cards/Card';
|
||||
|
||||
interface CompetitionCardSkeletonProps {
|
||||
showRegistrationBar?: boolean;
|
||||
}
|
||||
|
||||
export default function CompetitionCardSkeleton({
|
||||
showRegistrationBar = false,
|
||||
}: CompetitionCardSkeletonProps) {
|
||||
return (
|
||||
<Card variant="bordered" padding="none" className="overflow-hidden">
|
||||
<div className="p-5">
|
||||
{/* Header with badges */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-6 w-20 bg-slate-200 rounded-full animate-pulse" />
|
||||
<div className="h-6 w-16 bg-slate-200 rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div className="w-5 h-5 bg-slate-200 rounded animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="h-6 w-3/4 bg-slate-200 rounded-lg mb-2 animate-pulse" />
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1 mb-4">
|
||||
<div className="h-4 w-full bg-slate-200 rounded animate-pulse" />
|
||||
<div className="h-4 w-2/3 bg-slate-200 rounded animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-4 bg-slate-200 rounded animate-pulse" />
|
||||
<div className="h-4 w-24 bg-slate-200 rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-4 bg-slate-200 rounded animate-pulse" />
|
||||
<div className="h-4 w-20 bg-slate-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration status bar */}
|
||||
{showRegistrationBar && (
|
||||
<div className="px-5 py-3 bg-slate-50 border-t border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-4 w-20 bg-slate-200 rounded animate-pulse" />
|
||||
<div className="h-4 w-16 bg-slate-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import CompetitionCard from './CompetitionCard';
|
||||
import CompetitionCardSkeleton from './CompetitionCardSkeleton';
|
||||
import type { Competition } from '@/src/types/competition';
|
||||
|
||||
interface CompetitionListProps {
|
||||
competitions: Competition[];
|
||||
facilityId: number;
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CompetitionList({
|
||||
competitions,
|
||||
facilityId,
|
||||
isLoading = false,
|
||||
emptyMessage = 'No competitions found',
|
||||
className = '',
|
||||
}: CompetitionListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`grid gap-4 md:grid-cols-2 lg:grid-cols-3 ${className}`}>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<CompetitionCardSkeleton key={i} showRegistrationBar={i % 2 === 0} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (competitions.length === 0) {
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center py-16 ${className}`}>
|
||||
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-4">
|
||||
<Trophy className="w-8 h-8 text-slate-400" />
|
||||
</div>
|
||||
<p className="text-slate-600 text-center">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid gap-4 md:grid-cols-2 lg:grid-cols-3 ${className}`}>
|
||||
{competitions.map((competition) => (
|
||||
<CompetitionCard
|
||||
key={competition.competition_id}
|
||||
competition={competition}
|
||||
facilityId={facilityId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import type { CompetitionStatus } from '@/src/types/competition';
|
||||
|
||||
interface CompetitionStatusBadgeProps {
|
||||
status: CompetitionStatus;
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
showDot?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const statusConfig: Record<CompetitionStatus, {
|
||||
label: string;
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
dotColor: string;
|
||||
}> = {
|
||||
draft: {
|
||||
label: 'Draft',
|
||||
bgColor: 'bg-slate-100',
|
||||
textColor: 'text-slate-700',
|
||||
dotColor: 'bg-slate-400',
|
||||
},
|
||||
published: {
|
||||
label: 'Published',
|
||||
bgColor: 'bg-indigo-50',
|
||||
textColor: 'text-indigo-700',
|
||||
dotColor: 'bg-indigo-500',
|
||||
},
|
||||
running: {
|
||||
label: 'Running',
|
||||
bgColor: 'bg-emerald-50',
|
||||
textColor: 'text-emerald-700',
|
||||
dotColor: 'bg-emerald-500',
|
||||
},
|
||||
finished: {
|
||||
label: 'Finished',
|
||||
bgColor: 'bg-purple-50',
|
||||
textColor: 'text-purple-700',
|
||||
dotColor: 'bg-purple-500',
|
||||
},
|
||||
cancelled: {
|
||||
label: 'Cancelled',
|
||||
bgColor: 'bg-red-50',
|
||||
textColor: 'text-red-700',
|
||||
dotColor: 'bg-red-500',
|
||||
},
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
xs: {
|
||||
padding: 'px-2 py-0.5',
|
||||
text: 'text-xs',
|
||||
dot: 'w-1.5 h-1.5',
|
||||
},
|
||||
sm: {
|
||||
padding: 'px-2.5 py-1',
|
||||
text: 'text-sm',
|
||||
dot: 'w-2 h-2',
|
||||
},
|
||||
md: {
|
||||
padding: 'px-3 py-1.5',
|
||||
text: 'text-sm',
|
||||
dot: 'w-2 h-2',
|
||||
},
|
||||
};
|
||||
|
||||
export default function CompetitionStatusBadge({
|
||||
status,
|
||||
size = 'sm',
|
||||
showDot = true,
|
||||
className = '',
|
||||
}: CompetitionStatusBadgeProps) {
|
||||
const config = statusConfig[status];
|
||||
const sizes = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center gap-1.5
|
||||
${sizes.padding}
|
||||
${config.bgColor}
|
||||
${config.textColor}
|
||||
rounded-full
|
||||
${sizes.text}
|
||||
font-medium
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{showDot && (
|
||||
<span
|
||||
className={`${sizes.dot} ${config.dotColor} rounded-full ${status === 'running' ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
)}
|
||||
<span>{config.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { Trophy, Target, Zap, Layers } from 'lucide-react';
|
||||
import type { CompetitionType } from '@/src/types/competition';
|
||||
|
||||
interface CompetitionTypeBadgeProps {
|
||||
type: CompetitionType;
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
showIcon?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const typeConfig: Record<CompetitionType, {
|
||||
label: string;
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
Icon: React.ComponentType<{ className?: string }>;
|
||||
}> = {
|
||||
league: {
|
||||
label: 'League',
|
||||
bgColor: 'bg-amber-50',
|
||||
textColor: 'text-amber-700',
|
||||
Icon: Trophy,
|
||||
},
|
||||
tournament: {
|
||||
label: 'Tournament',
|
||||
bgColor: 'bg-purple-50',
|
||||
textColor: 'text-purple-700',
|
||||
Icon: Target,
|
||||
},
|
||||
challenge: {
|
||||
label: 'Challenge',
|
||||
bgColor: 'bg-pink-50',
|
||||
textColor: 'text-pink-700',
|
||||
Icon: Zap,
|
||||
},
|
||||
hybrid: {
|
||||
label: 'Hybrid',
|
||||
bgColor: 'bg-indigo-50',
|
||||
textColor: 'text-indigo-700',
|
||||
Icon: Layers,
|
||||
},
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
xs: {
|
||||
padding: 'px-2 py-0.5',
|
||||
text: 'text-xs',
|
||||
icon: 'w-3 h-3',
|
||||
},
|
||||
sm: {
|
||||
padding: 'px-2.5 py-1',
|
||||
text: 'text-sm',
|
||||
icon: 'w-3.5 h-3.5',
|
||||
},
|
||||
md: {
|
||||
padding: 'px-3 py-1.5',
|
||||
text: 'text-sm',
|
||||
icon: 'w-4 h-4',
|
||||
},
|
||||
};
|
||||
|
||||
export default function CompetitionTypeBadge({
|
||||
type,
|
||||
size = 'sm',
|
||||
showIcon = true,
|
||||
className = '',
|
||||
}: CompetitionTypeBadgeProps) {
|
||||
const config = typeConfig[type];
|
||||
const sizes = sizeClasses[size];
|
||||
const { Icon } = config;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center gap-1.5
|
||||
${sizes.padding}
|
||||
${config.bgColor}
|
||||
${config.textColor}
|
||||
rounded-full
|
||||
${sizes.text}
|
||||
font-medium
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{showIcon && <Icon className={sizes.icon} />}
|
||||
<span>{config.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
export { default as CompetitionStatusBadge } from './CompetitionStatusBadge';
|
||||
export { default as CompetitionTypeBadge } from './CompetitionTypeBadge';
|
||||
export { default as CompetitionCard } from './CompetitionCard';
|
||||
export { default as CompetitionCardSkeleton } from './CompetitionCardSkeleton';
|
||||
export { default as CompetitionList } from './CompetitionList';
|
||||
@ -0,0 +1,592 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
createCompetition,
|
||||
updateCompetition,
|
||||
publishCompetition,
|
||||
startCompetition,
|
||||
finishCompetition,
|
||||
cancelCompetition,
|
||||
approveRegistration,
|
||||
rejectRegistration,
|
||||
createParticipant,
|
||||
assignParticipantToGroup,
|
||||
setParticipantSeed,
|
||||
withdrawParticipant,
|
||||
generateFixtures,
|
||||
linkMatchToSlot,
|
||||
unlinkMatchFromSlot,
|
||||
setMatchWinner,
|
||||
voidMatch,
|
||||
recalculateStandings,
|
||||
recalculateLeaderboard,
|
||||
} from '@/src/lib/api/competition-admin';
|
||||
import { competitionQueryKeys } from '@/src/hooks/queries/useCompetitionQueries';
|
||||
import type {
|
||||
CreateTemplateRequest,
|
||||
UpdateTemplateRequest,
|
||||
CreateCompetitionRequest,
|
||||
UpdateCompetitionRequest,
|
||||
CreateParticipantRequest,
|
||||
GenerateFixturesRequest,
|
||||
LinkSlotRequest,
|
||||
SetWinnerRequest,
|
||||
} from '@/src/types/competition';
|
||||
|
||||
// ============================================================================
|
||||
// Template Mutations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a new competition template
|
||||
*/
|
||||
export function useCreateTemplate(facilityId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: CreateTemplateRequest) => {
|
||||
const result = await createTemplate(facilityId, data);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.templates() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing template
|
||||
*/
|
||||
export function useUpdateTemplate() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
templateId,
|
||||
data,
|
||||
}: {
|
||||
templateId: number;
|
||||
data: UpdateTemplateRequest;
|
||||
}) => {
|
||||
const result = await updateTemplate(templateId, data);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.templates() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.templateDetail(variables.templateId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Competition Mutations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a new competition
|
||||
*/
|
||||
export function useCreateCompetition(facilityId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: CreateCompetitionRequest) => {
|
||||
const result = await createCompetition(facilityId, data);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing competition
|
||||
*/
|
||||
export function useUpdateCompetition(competitionId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: UpdateCompetitionRequest) => {
|
||||
const result = await updateCompetition(competitionId, data);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.lists() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.detail(competitionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a competition (draft -> published)
|
||||
*/
|
||||
export function usePublishCompetition(competitionId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await publishCompetition(competitionId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.lists() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.detail(competitionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a competition (published -> running)
|
||||
*/
|
||||
export function useStartCompetition(competitionId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await startCompetition(competitionId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.lists() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.detail(competitionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish a competition (running -> finished)
|
||||
*/
|
||||
export function useFinishCompetition(competitionId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await finishCompetition(competitionId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.lists() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.detail(competitionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a competition
|
||||
*/
|
||||
export function useCancelCompetition(competitionId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await cancelCompetition(competitionId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.lists() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.detail(competitionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Registration Mutations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Approve a registration
|
||||
*/
|
||||
export function useApproveRegistration(competitionId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (registrationId: number) => {
|
||||
const result = await approveRegistration(competitionId, registrationId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.registrations(competitionId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.participants(competitionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a registration
|
||||
*/
|
||||
export function useRejectRegistration(competitionId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (registrationId: number) => {
|
||||
const result = await rejectRegistration(competitionId, registrationId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.registrations(competitionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Participant Mutations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a participant manually
|
||||
*/
|
||||
export function useCreateParticipant(competitionId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: CreateParticipantRequest) => {
|
||||
const result = await createParticipant(competitionId, data);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.participants(competitionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a participant to a group
|
||||
*/
|
||||
export function useAssignParticipantToGroup(competitionId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
participantId,
|
||||
groupId,
|
||||
}: {
|
||||
participantId: number;
|
||||
groupId: number;
|
||||
}) => {
|
||||
const result = await assignParticipantToGroup(participantId, groupId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.participants(competitionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a participant's seed
|
||||
*/
|
||||
export function useSetParticipantSeed(competitionId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
participantId,
|
||||
seed,
|
||||
}: {
|
||||
participantId: number;
|
||||
seed: number;
|
||||
}) => {
|
||||
const result = await setParticipantSeed(participantId, seed);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.participants(competitionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw a participant
|
||||
*/
|
||||
export function useWithdrawParticipant(competitionId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (participantId: number) => {
|
||||
const result = await withdrawParticipant(participantId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.participants(competitionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fixture Mutations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate fixtures for a stage
|
||||
*/
|
||||
export function useGenerateFixtures(competitionId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
stageId,
|
||||
request,
|
||||
}: {
|
||||
stageId: number;
|
||||
request: GenerateFixturesRequest;
|
||||
}) => {
|
||||
const result = await generateFixtures(competitionId, stageId, request);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.fixtures(competitionId, variables.stageId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.detail(competitionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a match to a slot
|
||||
*/
|
||||
export function useLinkMatchToSlot(competitionId: number, stageId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
matchId,
|
||||
request,
|
||||
}: {
|
||||
matchId: number;
|
||||
request: LinkSlotRequest;
|
||||
}) => {
|
||||
const result = await linkMatchToSlot(matchId, request);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.fixtures(competitionId, stageId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.match(variables.matchId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a match from its slot
|
||||
*/
|
||||
export function useUnlinkMatchFromSlot(competitionId: number, stageId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (matchId: number) => {
|
||||
const result = await unlinkMatchFromSlot(matchId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: (_, matchId) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.fixtures(competitionId, stageId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.match(matchId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a match winner manually
|
||||
*/
|
||||
export function useSetMatchWinner(competitionId: number, stageId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
matchId,
|
||||
request,
|
||||
}: {
|
||||
matchId: number;
|
||||
request: SetWinnerRequest;
|
||||
}) => {
|
||||
const result = await setMatchWinner(matchId, request);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.fixtures(competitionId, stageId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.match(variables.matchId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.standings(competitionId, stageId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Void a match
|
||||
*/
|
||||
export function useVoidMatch(competitionId: number, stageId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (matchId: number) => {
|
||||
const result = await voidMatch(matchId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: (_, matchId) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.fixtures(competitionId, stageId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.match(matchId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Standings/Leaderboard Mutations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Recalculate standings for a stage
|
||||
*/
|
||||
export function useRecalculateStandings(competitionId: number, stageId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (groupId?: number) => {
|
||||
const result = await recalculateStandings(competitionId, stageId, groupId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.standings(competitionId, stageId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate leaderboard for a challenge stage
|
||||
*/
|
||||
export function useRecalculateLeaderboard(competitionId: number, stageId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const result = await recalculateLeaderboard(competitionId, stageId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: competitionQueryKeys.leaderboard(competitionId, stageId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Type exports for mutation results
|
||||
// ============================================================================
|
||||
|
||||
export type CreateTemplateMutation = ReturnType<typeof useCreateTemplate>;
|
||||
export type UpdateTemplateMutation = ReturnType<typeof useUpdateTemplate>;
|
||||
export type CreateCompetitionMutation = ReturnType<typeof useCreateCompetition>;
|
||||
export type UpdateCompetitionMutation = ReturnType<typeof useUpdateCompetition>;
|
||||
export type PublishCompetitionMutation = ReturnType<typeof usePublishCompetition>;
|
||||
export type ApproveRegistrationMutation = ReturnType<typeof useApproveRegistration>;
|
||||
export type GenerateFixturesMutation = ReturnType<typeof useGenerateFixtures>;
|
||||
@ -0,0 +1,293 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
listTemplates,
|
||||
getTemplate,
|
||||
listCompetitions,
|
||||
getCompetition,
|
||||
listRegistrations,
|
||||
listParticipants,
|
||||
listFixtures,
|
||||
getMatch,
|
||||
getStandings,
|
||||
getLeaderboard,
|
||||
} from '@/src/lib/api/competition-admin';
|
||||
import type {
|
||||
CompetitionListFilters,
|
||||
RegistrationListFilters,
|
||||
ParticipantListFilters,
|
||||
FixtureListFilters,
|
||||
StandingsFilters,
|
||||
} from '@/src/types/competition';
|
||||
|
||||
// ============================================================================
|
||||
// Query Keys
|
||||
// ============================================================================
|
||||
|
||||
export const competitionQueryKeys = {
|
||||
all: ['competitions'] as const,
|
||||
|
||||
// Templates
|
||||
templates: () => [...competitionQueryKeys.all, 'templates'] as const,
|
||||
templateList: (facilityId: number, filters?: { sport_id?: number; include_inactive?: boolean }) =>
|
||||
[...competitionQueryKeys.templates(), 'list', facilityId, filters] as const,
|
||||
templateDetail: (templateId: number) =>
|
||||
[...competitionQueryKeys.templates(), 'detail', templateId] as const,
|
||||
|
||||
// Competitions
|
||||
lists: () => [...competitionQueryKeys.all, 'list'] as const,
|
||||
list: (facilityId: number, filters?: CompetitionListFilters) =>
|
||||
[...competitionQueryKeys.lists(), facilityId, filters] as const,
|
||||
details: () => [...competitionQueryKeys.all, 'detail'] as const,
|
||||
detail: (competitionId: number) =>
|
||||
[...competitionQueryKeys.details(), competitionId] as const,
|
||||
|
||||
// Registrations
|
||||
registrations: (competitionId: number) =>
|
||||
[...competitionQueryKeys.detail(competitionId), 'registrations'] as const,
|
||||
registrationList: (competitionId: number, filters?: RegistrationListFilters) =>
|
||||
[...competitionQueryKeys.registrations(competitionId), filters] as const,
|
||||
|
||||
// Participants
|
||||
participants: (competitionId: number) =>
|
||||
[...competitionQueryKeys.detail(competitionId), 'participants'] as const,
|
||||
participantList: (competitionId: number, filters?: ParticipantListFilters) =>
|
||||
[...competitionQueryKeys.participants(competitionId), filters] as const,
|
||||
|
||||
// Fixtures
|
||||
fixtures: (competitionId: number, stageId: number) =>
|
||||
[...competitionQueryKeys.detail(competitionId), 'fixtures', stageId] as const,
|
||||
fixtureList: (competitionId: number, stageId: number, filters?: FixtureListFilters) =>
|
||||
[...competitionQueryKeys.fixtures(competitionId, stageId), filters] as const,
|
||||
match: (matchId: number) =>
|
||||
[...competitionQueryKeys.all, 'match', matchId] as const,
|
||||
|
||||
// Standings
|
||||
standings: (competitionId: number, stageId: number) =>
|
||||
[...competitionQueryKeys.detail(competitionId), 'standings', stageId] as const,
|
||||
standingsList: (competitionId: number, stageId: number, filters?: StandingsFilters) =>
|
||||
[...competitionQueryKeys.standings(competitionId, stageId), filters] as const,
|
||||
|
||||
// Leaderboard
|
||||
leaderboard: (competitionId: number, stageId: number) =>
|
||||
[...competitionQueryKeys.detail(competitionId), 'leaderboard', stageId] as const,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Template Query Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch competition templates for a facility
|
||||
*/
|
||||
export function useCompetitionTemplates(
|
||||
facilityId: number,
|
||||
filters?: { sport_id?: number; include_inactive?: boolean }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: competitionQueryKeys.templateList(facilityId, filters),
|
||||
queryFn: async () => {
|
||||
const result = await listTemplates(facilityId, filters);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
enabled: facilityId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single template
|
||||
*/
|
||||
export function useCompetitionTemplate(templateId: number) {
|
||||
return useQuery({
|
||||
queryKey: competitionQueryKeys.templateDetail(templateId),
|
||||
queryFn: async () => {
|
||||
const result = await getTemplate(templateId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
enabled: templateId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Competition Query Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch competitions for a facility
|
||||
*/
|
||||
export function useCompetitions(facilityId: number, filters?: CompetitionListFilters) {
|
||||
return useQuery({
|
||||
queryKey: competitionQueryKeys.list(facilityId, filters),
|
||||
queryFn: async () => {
|
||||
const result = await listCompetitions(facilityId, filters);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
enabled: facilityId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single competition with stages
|
||||
*/
|
||||
export function useCompetition(competitionId: number) {
|
||||
return useQuery({
|
||||
queryKey: competitionQueryKeys.detail(competitionId),
|
||||
queryFn: async () => {
|
||||
const result = await getCompetition(competitionId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
enabled: competitionId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Registration Query Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch registrations for a competition
|
||||
*/
|
||||
export function useCompetitionRegistrations(
|
||||
competitionId: number,
|
||||
filters?: RegistrationListFilters
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: competitionQueryKeys.registrationList(competitionId, filters),
|
||||
queryFn: async () => {
|
||||
const result = await listRegistrations(competitionId, filters);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
enabled: competitionId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Participant Query Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch participants for a competition
|
||||
*/
|
||||
export function useCompetitionParticipants(
|
||||
competitionId: number,
|
||||
filters?: ParticipantListFilters
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: competitionQueryKeys.participantList(competitionId, filters),
|
||||
queryFn: async () => {
|
||||
const result = await listParticipants(competitionId, filters);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
enabled: competitionId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fixture Query Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch fixtures for a stage
|
||||
*/
|
||||
export function useCompetitionFixtures(
|
||||
competitionId: number,
|
||||
stageId: number,
|
||||
filters?: FixtureListFilters
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: competitionQueryKeys.fixtureList(competitionId, stageId, filters),
|
||||
queryFn: async () => {
|
||||
const result = await listFixtures(competitionId, stageId, filters);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
enabled: competitionId > 0 && stageId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single match
|
||||
*/
|
||||
export function useCompetitionMatch(matchId: number) {
|
||||
return useQuery({
|
||||
queryKey: competitionQueryKeys.match(matchId),
|
||||
queryFn: async () => {
|
||||
const result = await getMatch(matchId);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
enabled: matchId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Standings Query Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch standings for a stage
|
||||
*/
|
||||
export function useCompetitionStandings(
|
||||
competitionId: number,
|
||||
stageId: number,
|
||||
filters?: StandingsFilters
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: competitionQueryKeys.standingsList(competitionId, stageId, filters),
|
||||
queryFn: async () => {
|
||||
const result = await getStandings(competitionId, stageId, filters);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
enabled: competitionId > 0 && stageId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Leaderboard Query Hooks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch leaderboard for a challenge stage
|
||||
*/
|
||||
export function useCompetitionLeaderboard(
|
||||
competitionId: number,
|
||||
stageId: number,
|
||||
limit: number = 100,
|
||||
offset: number = 0
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...competitionQueryKeys.leaderboard(competitionId, stageId), limit, offset],
|
||||
queryFn: async () => {
|
||||
const result = await getLeaderboard(competitionId, stageId, limit, offset);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.detail);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
enabled: competitionId > 0 && stageId > 0,
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,870 @@
|
||||
/**
|
||||
* Competition Admin API Client
|
||||
*
|
||||
* Handles all competition management operations: templates, competitions,
|
||||
* registrations, participants, fixtures, standings, and leaderboards.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CompetitionTemplate,
|
||||
CreateTemplateRequest,
|
||||
UpdateTemplateRequest,
|
||||
Competition,
|
||||
CreateCompetitionRequest,
|
||||
UpdateCompetitionRequest,
|
||||
CompetitionListFilters,
|
||||
CompetitionStage,
|
||||
CompetitionRegistration,
|
||||
CreateRegistrationRequest,
|
||||
RegistrationListFilters,
|
||||
CompetitionParticipant,
|
||||
CreateParticipantRequest,
|
||||
ParticipantListFilters,
|
||||
CompetitionMatch,
|
||||
GenerateFixturesRequest,
|
||||
FixtureListFilters,
|
||||
LinkSlotRequest,
|
||||
SetWinnerRequest,
|
||||
CompetitionStanding,
|
||||
StandingsFilters,
|
||||
LeaderboardEntry,
|
||||
LeaderboardStats,
|
||||
CompetitionAdminError,
|
||||
CompetitionApiResult,
|
||||
} from '@/src/types/competition';
|
||||
import apiFetch from '@/src/utils/apiFetch';
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Handle API response
|
||||
* Flask backend wraps responses in: { status: 'success'|'fail', data: {...} }
|
||||
*/
|
||||
async function handleApiResponse<T>(response: Response): Promise<CompetitionApiResult<T>> {
|
||||
if (response.ok) {
|
||||
if (response.status === 204) {
|
||||
return { success: true, data: undefined as T };
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const data = json.data !== undefined ? json.data : json;
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
try {
|
||||
const error: CompetitionAdminError = await response.json();
|
||||
return { success: false, error };
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'about:blank',
|
||||
title: 'API Error',
|
||||
status: response.status,
|
||||
detail: response.statusText || 'An unexpected error occurred',
|
||||
code: 'internal_error',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildNetworkError(message: string): CompetitionApiResult<never> {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
type: 'about:blank',
|
||||
title: 'Network Error',
|
||||
status: 0,
|
||||
detail: message,
|
||||
code: 'internal_error',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Competition Templates
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /competition/templates
|
||||
* List competition templates
|
||||
*/
|
||||
export async function listTemplates(
|
||||
facilityId: number,
|
||||
filters?: { sport_id?: number; include_inactive?: boolean }
|
||||
): Promise<CompetitionApiResult<CompetitionTemplate[]>> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('facility_id', String(facilityId));
|
||||
if (filters?.sport_id !== undefined) params.set('sport_id', String(filters.sport_id));
|
||||
if (filters?.include_inactive) params.set('include_inactive', 'true');
|
||||
|
||||
const response = await apiFetch(`/competition/templates?${params}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ templates: CompetitionTemplate[] }>(response);
|
||||
if (result.success && result.data.templates) {
|
||||
return { success: true, data: result.data.templates };
|
||||
}
|
||||
return result as CompetitionApiResult<CompetitionTemplate[]>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to list templates');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /competition/templates/{template_id}
|
||||
* Get a single template
|
||||
*/
|
||||
export async function getTemplate(
|
||||
templateId: number
|
||||
): Promise<CompetitionApiResult<CompetitionTemplate>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/templates/${templateId}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ template: CompetitionTemplate }>(response);
|
||||
if (result.success && result.data.template) {
|
||||
return { success: true, data: result.data.template };
|
||||
}
|
||||
return result as CompetitionApiResult<CompetitionTemplate>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to get template');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/templates
|
||||
* Create a new template
|
||||
*/
|
||||
export async function createTemplate(
|
||||
facilityId: number,
|
||||
request: CreateTemplateRequest
|
||||
): Promise<CompetitionApiResult<CompetitionTemplate>> {
|
||||
try {
|
||||
const response = await apiFetch('/competition/templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...request, facility_id: facilityId }),
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ template: CompetitionTemplate }>(response);
|
||||
if (result.success && result.data.template) {
|
||||
return { success: true, data: result.data.template };
|
||||
}
|
||||
return result as CompetitionApiResult<CompetitionTemplate>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to create template');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /competition/templates/{template_id}
|
||||
* Update a template
|
||||
*/
|
||||
export async function updateTemplate(
|
||||
templateId: number,
|
||||
request: UpdateTemplateRequest
|
||||
): Promise<CompetitionApiResult<CompetitionTemplate>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/templates/${templateId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ template: CompetitionTemplate }>(response);
|
||||
if (result.success && result.data.template) {
|
||||
return { success: true, data: result.data.template };
|
||||
}
|
||||
return result as CompetitionApiResult<CompetitionTemplate>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to update template');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Competitions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /competitions
|
||||
* List competitions for a facility
|
||||
*/
|
||||
export async function listCompetitions(
|
||||
facilityId: number,
|
||||
filters?: CompetitionListFilters
|
||||
): Promise<CompetitionApiResult<Competition[]>> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('facility_id', String(facilityId));
|
||||
if (filters?.status) params.set('status', filters.status);
|
||||
if (filters?.sport_id !== undefined) params.set('sport_id', String(filters.sport_id));
|
||||
if (filters?.type) params.set('type', filters.type);
|
||||
|
||||
const response = await apiFetch(`/competitions?${params}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ competitions: Competition[] }>(response);
|
||||
if (result.success && result.data.competitions) {
|
||||
return { success: true, data: result.data.competitions };
|
||||
}
|
||||
return result as CompetitionApiResult<Competition[]>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to list competitions');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /competition/{competition_id}
|
||||
* Get competition with stages
|
||||
*/
|
||||
export async function getCompetition(
|
||||
competitionId: number
|
||||
): Promise<CompetitionApiResult<Competition>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/${competitionId}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ competition: Competition }>(response);
|
||||
if (result.success && result.data.competition) {
|
||||
return { success: true, data: result.data.competition };
|
||||
}
|
||||
return result as CompetitionApiResult<Competition>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to get competition');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition
|
||||
* Create a new competition
|
||||
*/
|
||||
export async function createCompetition(
|
||||
facilityId: number,
|
||||
request: CreateCompetitionRequest
|
||||
): Promise<CompetitionApiResult<Competition>> {
|
||||
try {
|
||||
const response = await apiFetch('/competition', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...request, facility_id: facilityId }),
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ competition: Competition }>(response);
|
||||
if (result.success && result.data.competition) {
|
||||
return { success: true, data: result.data.competition };
|
||||
}
|
||||
return result as CompetitionApiResult<Competition>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to create competition');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /competition/{competition_id}
|
||||
* Update a competition
|
||||
*/
|
||||
export async function updateCompetition(
|
||||
competitionId: number,
|
||||
request: UpdateCompetitionRequest
|
||||
): Promise<CompetitionApiResult<Competition>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/${competitionId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ competition: Competition }>(response);
|
||||
if (result.success && result.data.competition) {
|
||||
return { success: true, data: result.data.competition };
|
||||
}
|
||||
return result as CompetitionApiResult<Competition>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to update competition');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/{competition_id}/publish
|
||||
* Publish a competition (draft -> published)
|
||||
*/
|
||||
export async function publishCompetition(
|
||||
competitionId: number
|
||||
): Promise<CompetitionApiResult<Competition>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/${competitionId}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ competition: Competition }>(response);
|
||||
if (result.success && result.data.competition) {
|
||||
return { success: true, data: result.data.competition };
|
||||
}
|
||||
return result as CompetitionApiResult<Competition>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to publish competition');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/{competition_id}/start
|
||||
* Start a competition (published -> running)
|
||||
*/
|
||||
export async function startCompetition(
|
||||
competitionId: number
|
||||
): Promise<CompetitionApiResult<Competition>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/${competitionId}/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ competition: Competition }>(response);
|
||||
if (result.success && result.data.competition) {
|
||||
return { success: true, data: result.data.competition };
|
||||
}
|
||||
return result as CompetitionApiResult<Competition>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to start competition');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/{competition_id}/finish
|
||||
* Finish a competition (running -> finished)
|
||||
*/
|
||||
export async function finishCompetition(
|
||||
competitionId: number
|
||||
): Promise<CompetitionApiResult<Competition>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/${competitionId}/finish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ competition: Competition }>(response);
|
||||
if (result.success && result.data.competition) {
|
||||
return { success: true, data: result.data.competition };
|
||||
}
|
||||
return result as CompetitionApiResult<Competition>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to finish competition');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/{competition_id}/cancel
|
||||
* Cancel a competition
|
||||
*/
|
||||
export async function cancelCompetition(
|
||||
competitionId: number
|
||||
): Promise<CompetitionApiResult<Competition>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/${competitionId}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ competition: Competition }>(response);
|
||||
if (result.success && result.data.competition) {
|
||||
return { success: true, data: result.data.competition };
|
||||
}
|
||||
return result as CompetitionApiResult<Competition>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to cancel competition');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Registrations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /competition/{competition_id}/registrations
|
||||
* List registrations for a competition
|
||||
*/
|
||||
export async function listRegistrations(
|
||||
competitionId: number,
|
||||
filters?: RegistrationListFilters
|
||||
): Promise<CompetitionApiResult<CompetitionRegistration[]>> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.status) params.set('status', filters.status);
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = `/competition/${competitionId}/registrations${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ registrations: CompetitionRegistration[] }>(response);
|
||||
if (result.success && result.data.registrations) {
|
||||
return { success: true, data: result.data.registrations };
|
||||
}
|
||||
return result as CompetitionApiResult<CompetitionRegistration[]>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to list registrations');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/{competition_id}/registrations/{registration_id}/approve
|
||||
* Approve a registration
|
||||
*/
|
||||
export async function approveRegistration(
|
||||
competitionId: number,
|
||||
registrationId: number
|
||||
): Promise<CompetitionApiResult<{ participant_id: number }>> {
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`/competition/${competitionId}/registrations/${registrationId}/approve`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
|
||||
return handleApiResponse<{ participant_id: number }>(response);
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to approve registration');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/{competition_id}/registrations/{registration_id}/reject
|
||||
* Reject a registration
|
||||
*/
|
||||
export async function rejectRegistration(
|
||||
competitionId: number,
|
||||
registrationId: number
|
||||
): Promise<CompetitionApiResult<void>> {
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`/competition/${competitionId}/registrations/${registrationId}/reject`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
|
||||
return handleApiResponse<void>(response);
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to reject registration');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Participants
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /competition/{competition_id}/participants
|
||||
* List participants for a competition
|
||||
*/
|
||||
export async function listParticipants(
|
||||
competitionId: number,
|
||||
filters?: ParticipantListFilters
|
||||
): Promise<CompetitionApiResult<CompetitionParticipant[]>> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.status) params.set('status', filters.status);
|
||||
if (filters?.stage_group_id !== undefined) params.set('stage_group_id', String(filters.stage_group_id));
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = `/competition/${competitionId}/participants${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ participants: CompetitionParticipant[] }>(response);
|
||||
if (result.success && result.data.participants) {
|
||||
return { success: true, data: result.data.participants };
|
||||
}
|
||||
return result as CompetitionApiResult<CompetitionParticipant[]>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to list participants');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/{competition_id}/participants
|
||||
* Create a participant manually (without registration)
|
||||
*/
|
||||
export async function createParticipant(
|
||||
competitionId: number,
|
||||
request: CreateParticipantRequest
|
||||
): Promise<CompetitionApiResult<CompetitionParticipant>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/${competitionId}/participants`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ participant: CompetitionParticipant }>(response);
|
||||
if (result.success && result.data.participant) {
|
||||
return { success: true, data: result.data.participant };
|
||||
}
|
||||
return result as CompetitionApiResult<CompetitionParticipant>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to create participant');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/participants/{participant_id}/assign-group
|
||||
* Assign a participant to a group
|
||||
*/
|
||||
export async function assignParticipantToGroup(
|
||||
participantId: number,
|
||||
groupId: number
|
||||
): Promise<CompetitionApiResult<void>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/participants/${participantId}/assign-group`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ group_id: groupId }),
|
||||
});
|
||||
|
||||
return handleApiResponse<void>(response);
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to assign to group');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/participants/{participant_id}/seed
|
||||
* Set participant seed
|
||||
*/
|
||||
export async function setParticipantSeed(
|
||||
participantId: number,
|
||||
seed: number
|
||||
): Promise<CompetitionApiResult<void>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/participants/${participantId}/seed`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seed }),
|
||||
});
|
||||
|
||||
return handleApiResponse<void>(response);
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to set seed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/participants/{participant_id}/withdraw
|
||||
* Withdraw a participant
|
||||
*/
|
||||
export async function withdrawParticipant(
|
||||
participantId: number
|
||||
): Promise<CompetitionApiResult<void>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/participants/${participantId}/withdraw`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
return handleApiResponse<void>(response);
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to withdraw participant');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fixtures
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* POST /competition/{competition_id}/stages/{stage_id}/generate-fixtures
|
||||
* Generate fixtures for a stage
|
||||
*/
|
||||
export async function generateFixtures(
|
||||
competitionId: number,
|
||||
stageId: number,
|
||||
request: GenerateFixturesRequest
|
||||
): Promise<CompetitionApiResult<{ match_count: number; match_ids: number[] }>> {
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`/competition/${competitionId}/stages/${stageId}/generate-fixtures`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
}
|
||||
);
|
||||
|
||||
return handleApiResponse<{ match_count: number; match_ids: number[] }>(response);
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to generate fixtures');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /competition/{competition_id}/stages/{stage_id}/fixtures
|
||||
* List fixtures for a stage
|
||||
*/
|
||||
export async function listFixtures(
|
||||
competitionId: number,
|
||||
stageId: number,
|
||||
filters?: FixtureListFilters
|
||||
): Promise<CompetitionApiResult<CompetitionMatch[]>> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.status) params.set('status', filters.status);
|
||||
if (filters?.round_index !== undefined) params.set('round_index', String(filters.round_index));
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = `/competition/${competitionId}/stages/${stageId}/fixtures${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ fixtures: CompetitionMatch[] }>(response);
|
||||
if (result.success && result.data.fixtures) {
|
||||
return { success: true, data: result.data.fixtures };
|
||||
}
|
||||
return result as CompetitionApiResult<CompetitionMatch[]>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to list fixtures');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /competition/matches/{match_id}
|
||||
* Get a single match
|
||||
*/
|
||||
export async function getMatch(
|
||||
matchId: number
|
||||
): Promise<CompetitionApiResult<CompetitionMatch>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/matches/${matchId}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ match: CompetitionMatch }>(response);
|
||||
if (result.success && result.data.match) {
|
||||
return { success: true, data: result.data.match };
|
||||
}
|
||||
return result as CompetitionApiResult<CompetitionMatch>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to get match');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/matches/{match_id}/link-slot
|
||||
* Link a match to a slot instance
|
||||
*/
|
||||
export async function linkMatchToSlot(
|
||||
matchId: number,
|
||||
request: LinkSlotRequest
|
||||
): Promise<CompetitionApiResult<void>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/matches/${matchId}/link-slot`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
return handleApiResponse<void>(response);
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to link slot');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/matches/{match_id}/unlink-slot
|
||||
* Unlink a match from its slot instance
|
||||
*/
|
||||
export async function unlinkMatchFromSlot(
|
||||
matchId: number
|
||||
): Promise<CompetitionApiResult<void>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/matches/${matchId}/unlink-slot`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
return handleApiResponse<void>(response);
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to unlink slot');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/matches/{match_id}/set-winner
|
||||
* Manually set match winner
|
||||
*/
|
||||
export async function setMatchWinner(
|
||||
matchId: number,
|
||||
request: SetWinnerRequest
|
||||
): Promise<CompetitionApiResult<void>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/matches/${matchId}/set-winner`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
return handleApiResponse<void>(response);
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to set winner');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/matches/{match_id}/void
|
||||
* Void a match
|
||||
*/
|
||||
export async function voidMatch(
|
||||
matchId: number
|
||||
): Promise<CompetitionApiResult<void>> {
|
||||
try {
|
||||
const response = await apiFetch(`/competition/matches/${matchId}/void`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
return handleApiResponse<void>(response);
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to void match');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Standings
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /competition/{competition_id}/stages/{stage_id}/standings
|
||||
* Get standings for a stage
|
||||
*/
|
||||
export async function getStandings(
|
||||
competitionId: number,
|
||||
stageId: number,
|
||||
filters?: StandingsFilters
|
||||
): Promise<CompetitionApiResult<CompetitionStanding[]>> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.group_id !== undefined) params.set('group_id', String(filters.group_id));
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = `/competition/${competitionId}/stages/${stageId}/standings${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<{ standings: CompetitionStanding[] }>(response);
|
||||
if (result.success && result.data.standings) {
|
||||
return { success: true, data: result.data.standings };
|
||||
}
|
||||
return result as CompetitionApiResult<CompetitionStanding[]>;
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to get standings');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/{competition_id}/stages/{stage_id}/standings/recalculate
|
||||
* Recalculate standings
|
||||
*/
|
||||
export async function recalculateStandings(
|
||||
competitionId: number,
|
||||
stageId: number,
|
||||
groupId?: number
|
||||
): Promise<CompetitionApiResult<{ participant_count: number }>> {
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`/competition/${competitionId}/stages/${stageId}/standings/recalculate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(groupId !== undefined ? { group_id: groupId } : {}),
|
||||
}
|
||||
);
|
||||
|
||||
return handleApiResponse<{ participant_count: number }>(response);
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to recalculate standings');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Leaderboard
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* GET /competition/{competition_id}/stages/{stage_id}/leaderboard
|
||||
* Get leaderboard for a challenge stage
|
||||
*/
|
||||
export async function getLeaderboard(
|
||||
competitionId: number,
|
||||
stageId: number,
|
||||
limit: number = 100,
|
||||
offset: number = 0
|
||||
): Promise<CompetitionApiResult<{ leaderboard: LeaderboardEntry[]; stats: LeaderboardStats }>> {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (limit !== 100) params.set('limit', String(limit));
|
||||
if (offset !== 0) params.set('offset', String(offset));
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = `/competition/${competitionId}/stages/${stageId}/leaderboard${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await apiFetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
return handleApiResponse<{ leaderboard: LeaderboardEntry[]; stats: LeaderboardStats }>(response);
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to get leaderboard');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /competition/{competition_id}/stages/{stage_id}/leaderboard/recalculate
|
||||
* Recalculate leaderboard
|
||||
*/
|
||||
export async function recalculateLeaderboard(
|
||||
competitionId: number,
|
||||
stageId: number
|
||||
): Promise<CompetitionApiResult<{ entry_count: number }>> {
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`/competition/${competitionId}/stages/${stageId}/leaderboard/recalculate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
|
||||
return handleApiResponse<{ entry_count: number }>(response);
|
||||
} catch (error) {
|
||||
return buildNetworkError(error instanceof Error ? error.message : 'Failed to recalculate leaderboard');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,508 @@
|
||||
/**
|
||||
* Competition Admin TypeScript Types
|
||||
*
|
||||
* Types for competition management: templates, competitions, registrations,
|
||||
* participants, fixtures, standings, and leaderboards.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Competition Types (Enums)
|
||||
// ============================================================================
|
||||
|
||||
export type CompetitionType = 'league' | 'tournament' | 'challenge' | 'hybrid';
|
||||
export type CompetitionScope = 'facility' | 'cross_facility' | 'platform';
|
||||
export type CompetitionStatus = 'draft' | 'published' | 'running' | 'finished' | 'cancelled';
|
||||
export type CompetitionVisibility = 'public' | 'unlisted' | 'private';
|
||||
export type StageType = 'round_robin' | 'group_stage' | 'knockout_single' | 'knockout_double' | 'rating_delta_challenge';
|
||||
export type StageStatus = 'pending' | 'active' | 'completed';
|
||||
export type RegistrationStatus = 'pending' | 'approved' | 'rejected' | 'waitlisted' | 'withdrawn';
|
||||
export type ParticipantType = 'single' | 'pair' | 'team';
|
||||
export type ParticipantStatus = 'active' | 'withdrawn' | 'eliminated';
|
||||
export type MatchStatus = 'draft' | 'needs_scheduling' | 'scheduled' | 'in_progress' | 'completed' | 'voided';
|
||||
|
||||
// ============================================================================
|
||||
// Competition Templates
|
||||
// ============================================================================
|
||||
|
||||
export interface CompetitionTemplate {
|
||||
template_id: number;
|
||||
type: CompetitionType;
|
||||
scope: 'platform' | 'facility';
|
||||
facility_id: number | null;
|
||||
sport_id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
config: CompetitionConfig;
|
||||
config_version: number;
|
||||
source_template_id: number | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
created_by_app_user_id: number;
|
||||
}
|
||||
|
||||
export interface CreateTemplateRequest {
|
||||
type: CompetitionType;
|
||||
scope: 'platform' | 'facility';
|
||||
sport_id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
config: CompetitionConfig;
|
||||
}
|
||||
|
||||
export interface UpdateTemplateRequest {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
config?: CompetitionConfig;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Competitions
|
||||
// ============================================================================
|
||||
|
||||
export interface Competition {
|
||||
competition_id: number;
|
||||
type: CompetitionType;
|
||||
scope: CompetitionScope;
|
||||
facility_id: number | null;
|
||||
owner_app_user_id: number;
|
||||
template_id: number | null;
|
||||
series_id: number | null;
|
||||
sport_id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
visibility: CompetitionVisibility;
|
||||
status: CompetitionStatus;
|
||||
registration_open_at: string | null;
|
||||
registration_close_at: string | null;
|
||||
starts_at: string;
|
||||
ends_at: string | null;
|
||||
config_snapshot: CompetitionConfig;
|
||||
config_version: number;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
created_by_app_user_id: number;
|
||||
// Joined data
|
||||
stages?: CompetitionStage[];
|
||||
participant_count?: number;
|
||||
registration_count?: number;
|
||||
}
|
||||
|
||||
export interface CreateCompetitionRequest {
|
||||
type: CompetitionType;
|
||||
scope?: CompetitionScope;
|
||||
template_id?: number | null;
|
||||
sport_id: number;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
visibility?: CompetitionVisibility;
|
||||
registration_open_at?: string | null;
|
||||
registration_close_at?: string | null;
|
||||
starts_at: string;
|
||||
ends_at?: string | null;
|
||||
config?: CompetitionConfig;
|
||||
}
|
||||
|
||||
export interface UpdateCompetitionRequest {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
visibility?: CompetitionVisibility;
|
||||
registration_open_at?: string | null;
|
||||
registration_close_at?: string | null;
|
||||
starts_at?: string;
|
||||
ends_at?: string | null;
|
||||
}
|
||||
|
||||
export interface CompetitionListFilters {
|
||||
status?: CompetitionStatus;
|
||||
sport_id?: number;
|
||||
type?: CompetitionType;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Competition Stages
|
||||
// ============================================================================
|
||||
|
||||
export interface CompetitionStage {
|
||||
stage_id: number;
|
||||
competition_id: number;
|
||||
stage_type: StageType;
|
||||
name: string;
|
||||
stage_order: number;
|
||||
status: StageStatus;
|
||||
config: StageConfig;
|
||||
created_at: string;
|
||||
// Joined data
|
||||
groups?: CompetitionGroup[];
|
||||
match_count?: number;
|
||||
}
|
||||
|
||||
export interface StageConfig {
|
||||
// Round robin / group stage
|
||||
legs?: number;
|
||||
points_win?: number;
|
||||
points_draw?: number;
|
||||
points_loss?: number;
|
||||
// Knockout
|
||||
third_place_match?: boolean;
|
||||
// Challenge
|
||||
leaderboard_metric?: 'rating_delta_sum' | 'rating_delta_avg' | 'rating_delta_max';
|
||||
min_matches?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Competition Groups
|
||||
// ============================================================================
|
||||
|
||||
export interface CompetitionGroup {
|
||||
group_id: number;
|
||||
stage_id: number;
|
||||
name: string;
|
||||
display_order: number;
|
||||
// Joined data
|
||||
participant_count?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Registrations
|
||||
// ============================================================================
|
||||
|
||||
export interface CompetitionRegistration {
|
||||
registration_id: number;
|
||||
competition_id: number;
|
||||
app_user_id: number;
|
||||
display_name: string;
|
||||
email?: string;
|
||||
status: RegistrationStatus;
|
||||
team_name: string | null;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
reviewed_at: string | null;
|
||||
reviewed_by_app_user_id: number | null;
|
||||
// Joined data
|
||||
members: RegistrationMember[];
|
||||
}
|
||||
|
||||
export interface RegistrationMember {
|
||||
origin_member_id: number;
|
||||
display_name: string;
|
||||
role: 'captain' | 'player';
|
||||
}
|
||||
|
||||
export interface CreateRegistrationRequest {
|
||||
origin_member_ids: number[];
|
||||
team_name?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface RegistrationListFilters {
|
||||
status?: RegistrationStatus;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Participants
|
||||
// ============================================================================
|
||||
|
||||
export interface CompetitionParticipant {
|
||||
participant_id: number;
|
||||
competition_id: number;
|
||||
registration_id: number | null;
|
||||
participant_type: ParticipantType;
|
||||
display_name: string;
|
||||
seed: number | null;
|
||||
status: ParticipantStatus;
|
||||
stage_group_id: number | null;
|
||||
created_at: string;
|
||||
// Joined data
|
||||
members: ParticipantMember[];
|
||||
group_name?: string | null;
|
||||
}
|
||||
|
||||
export interface ParticipantMember {
|
||||
origin_member_id: number;
|
||||
display_name: string;
|
||||
role: 'captain' | 'player';
|
||||
}
|
||||
|
||||
export interface CreateParticipantRequest {
|
||||
origin_member_ids: number[];
|
||||
participant_type?: ParticipantType;
|
||||
display_name?: string | null;
|
||||
seed?: number | null;
|
||||
stage_group_id?: number | null;
|
||||
}
|
||||
|
||||
export interface ParticipantListFilters {
|
||||
status?: ParticipantStatus;
|
||||
stage_group_id?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fixtures / Matches
|
||||
// ============================================================================
|
||||
|
||||
export interface CompetitionMatch {
|
||||
competition_match_id: number;
|
||||
competition_id: number;
|
||||
stage_id: number;
|
||||
group_id: number | null;
|
||||
round_index: number;
|
||||
match_index: number;
|
||||
home_participant_id: number | null;
|
||||
away_participant_id: number | null;
|
||||
home_from_match_id: number | null;
|
||||
home_from_outcome: 'winner' | 'loser' | null;
|
||||
away_from_match_id: number | null;
|
||||
away_from_outcome: 'winner' | 'loser' | null;
|
||||
slot_instance_id: number | null;
|
||||
status: MatchStatus;
|
||||
winner_participant_id: number | null;
|
||||
scheduled_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
// Joined data
|
||||
home_participant?: CompetitionParticipant | null;
|
||||
away_participant?: CompetitionParticipant | null;
|
||||
stage_name?: string;
|
||||
group_name?: string | null;
|
||||
}
|
||||
|
||||
export interface GenerateFixturesRequest {
|
||||
stage_type: StageType;
|
||||
legs?: number;
|
||||
group_id?: number | null;
|
||||
third_place_match?: boolean;
|
||||
}
|
||||
|
||||
export interface FixtureListFilters {
|
||||
status?: MatchStatus;
|
||||
round_index?: number;
|
||||
}
|
||||
|
||||
export interface LinkSlotRequest {
|
||||
slot_instance_id: number;
|
||||
}
|
||||
|
||||
export interface SetWinnerRequest {
|
||||
winner_participant_id: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Standings
|
||||
// ============================================================================
|
||||
|
||||
export interface CompetitionStanding {
|
||||
standing_id: number;
|
||||
stage_id: number;
|
||||
group_id: number | null;
|
||||
participant_id: number;
|
||||
rank: number;
|
||||
played: number;
|
||||
won: number;
|
||||
drawn: number;
|
||||
lost: number;
|
||||
goals_for: number;
|
||||
goals_against: number;
|
||||
goal_difference: number;
|
||||
points: number;
|
||||
updated_at: string;
|
||||
// Joined data
|
||||
display_name: string;
|
||||
participant?: CompetitionParticipant;
|
||||
}
|
||||
|
||||
export interface StandingsFilters {
|
||||
group_id?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Leaderboard
|
||||
// ============================================================================
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
entry_id: number;
|
||||
stage_id: number;
|
||||
participant_id: number;
|
||||
origin_member_id: number;
|
||||
rank: number;
|
||||
metric_value: number;
|
||||
matches_counted: number;
|
||||
updated_at: string;
|
||||
// Joined data
|
||||
display_name: string;
|
||||
participant?: CompetitionParticipant;
|
||||
}
|
||||
|
||||
export interface LeaderboardStats {
|
||||
total_entries: number;
|
||||
total_matches: number;
|
||||
top_metric_value: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Competition Config
|
||||
// ============================================================================
|
||||
|
||||
export interface CompetitionConfig {
|
||||
format?: FormatConfig;
|
||||
ops?: OpsConfig;
|
||||
rating?: RatingConfig;
|
||||
experience?: ExperienceConfig;
|
||||
}
|
||||
|
||||
export interface FormatConfig {
|
||||
stages: StageConfig[];
|
||||
participant_type?: ParticipantType;
|
||||
team_size?: { min: number; max: number };
|
||||
}
|
||||
|
||||
export interface OpsConfig {
|
||||
registration?: {
|
||||
requires_approval?: boolean;
|
||||
max_participants?: number;
|
||||
waitlist_enabled?: boolean;
|
||||
};
|
||||
scheduling?: {
|
||||
auto_schedule?: boolean;
|
||||
preferred_days?: number[];
|
||||
preferred_hours?: { start: number; end: number };
|
||||
};
|
||||
verification?: {
|
||||
require_score_consensus?: boolean;
|
||||
allow_admin_override?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RatingConfig {
|
||||
affects_rating?: boolean;
|
||||
rating_weight?: number;
|
||||
}
|
||||
|
||||
export interface ExperienceConfig {
|
||||
theme?: string;
|
||||
share_cards?: boolean;
|
||||
badges_enabled?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Types (RFC-7807)
|
||||
// ============================================================================
|
||||
|
||||
export interface CompetitionAdminError {
|
||||
type: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail: string;
|
||||
code: CompetitionErrorCode;
|
||||
errors?: ValidationError[];
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type CompetitionErrorCode =
|
||||
| 'validation_error'
|
||||
| 'not_found'
|
||||
| 'unauthorized'
|
||||
| 'forbidden'
|
||||
| 'internal_error'
|
||||
| 'competition_not_found'
|
||||
| 'template_not_found'
|
||||
| 'registration_not_found'
|
||||
| 'participant_not_found'
|
||||
| 'match_not_found'
|
||||
| 'stage_not_found'
|
||||
| 'invalid_status_transition'
|
||||
| 'registration_closed'
|
||||
| 'already_registered'
|
||||
| 'fixtures_already_generated'
|
||||
| 'match_already_scheduled'
|
||||
| 'match_already_completed';
|
||||
|
||||
// ============================================================================
|
||||
// API Result Wrapper
|
||||
// ============================================================================
|
||||
|
||||
export type CompetitionApiResult<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: CompetitionAdminError };
|
||||
|
||||
// ============================================================================
|
||||
// User-Friendly Error Messages
|
||||
// ============================================================================
|
||||
|
||||
export const COMPETITION_ERROR_MESSAGES: Record<CompetitionErrorCode, { title: string; hint: string }> = {
|
||||
validation_error: {
|
||||
title: 'Please fix the highlighted fields.',
|
||||
hint: 'Check your input and try again.',
|
||||
},
|
||||
not_found: {
|
||||
title: 'Not found.',
|
||||
hint: 'The resource you requested does not exist.',
|
||||
},
|
||||
unauthorized: {
|
||||
title: 'Not authenticated.',
|
||||
hint: 'Please log in and try again.',
|
||||
},
|
||||
forbidden: {
|
||||
title: 'Access denied.',
|
||||
hint: "You don't have permission to perform this action.",
|
||||
},
|
||||
internal_error: {
|
||||
title: 'Something went wrong.',
|
||||
hint: 'Please try again later.',
|
||||
},
|
||||
competition_not_found: {
|
||||
title: 'Competition not found.',
|
||||
hint: 'This competition may have been deleted.',
|
||||
},
|
||||
template_not_found: {
|
||||
title: 'Template not found.',
|
||||
hint: 'This template may have been deleted.',
|
||||
},
|
||||
registration_not_found: {
|
||||
title: 'Registration not found.',
|
||||
hint: 'This registration may have been deleted.',
|
||||
},
|
||||
participant_not_found: {
|
||||
title: 'Participant not found.',
|
||||
hint: 'This participant may have been withdrawn.',
|
||||
},
|
||||
match_not_found: {
|
||||
title: 'Match not found.',
|
||||
hint: 'This match may have been deleted.',
|
||||
},
|
||||
stage_not_found: {
|
||||
title: 'Stage not found.',
|
||||
hint: 'This stage may have been deleted.',
|
||||
},
|
||||
invalid_status_transition: {
|
||||
title: 'Invalid status change.',
|
||||
hint: 'This status transition is not allowed.',
|
||||
},
|
||||
registration_closed: {
|
||||
title: 'Registration is closed.',
|
||||
hint: 'The registration period for this competition has ended.',
|
||||
},
|
||||
already_registered: {
|
||||
title: 'Already registered.',
|
||||
hint: 'You are already registered for this competition.',
|
||||
},
|
||||
fixtures_already_generated: {
|
||||
title: 'Fixtures already exist.',
|
||||
hint: 'Fixtures have already been generated for this stage.',
|
||||
},
|
||||
match_already_scheduled: {
|
||||
title: 'Match already scheduled.',
|
||||
hint: 'This match has already been linked to a slot.',
|
||||
},
|
||||
match_already_completed: {
|
||||
title: 'Match already completed.',
|
||||
hint: 'This match has already been completed.',
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue