feat: complete competition manager UI with scheduling, results, and series
continuous-integration/drone/push Build is passing Details

- Add SchedulingTab with SlotPickerModal for linking matches to court slots
- Add ResultsTab with dispute management (list, resolve, reject disputes)
- Add Series management pages (list, create with RRULE configuration)
- Add SaveAsTemplateModal to save competition config as reusable template
- Add GroupAssignmentModal for assigning participants to groups
- Enable approve button for waitlisted registrations
- Add dispute types, API functions, and query/mutation hooks
- Add series types, API functions, and query/mutation hooks
master
Guillermo Pages 2 months ago
parent 5e020aa4eb
commit 2a71371683

@ -16,6 +16,8 @@ import {
Settings,
Trophy,
ListOrdered,
FileText,
CalendarClock,
} from 'lucide-react';
import useTranslation from '@/src/hooks/useTranslation';
import Card from '@/src/components/cards/Card';
@ -31,7 +33,10 @@ import OverviewTab from './tabs/OverviewTab';
import RegistrationsTab from './tabs/RegistrationsTab';
import ParticipantsTab from './tabs/ParticipantsTab';
import FixturesTab from './tabs/FixturesTab';
import SchedulingTab from './tabs/SchedulingTab';
import ResultsTab from './tabs/ResultsTab';
import StandingsTab from './tabs/StandingsTab';
import SaveAsTemplateModal from './SaveAsTemplateModal';
import type { CompetitionStatus } from '@/src/types/competition';
interface CompetitionDetailComponentProps {
@ -39,13 +44,15 @@ interface CompetitionDetailComponentProps {
competitionId: number;
}
type TabKey = 'overview' | 'registrations' | 'participants' | 'fixtures' | 'standings';
type TabKey = 'overview' | 'registrations' | 'participants' | 'fixtures' | 'scheduling' | 'results' | '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: 'scheduling', label: 'Scheduling', icon: CalendarClock },
{ key: 'results', label: 'Results', icon: CheckCircle },
{ key: 'standings', label: 'Standings', icon: ListOrdered },
];
@ -157,6 +164,7 @@ export default function CompetitionDetailComponent({
}: CompetitionDetailComponentProps) {
const { locale } = useTranslation();
const [activeTab, setActiveTab] = useState<TabKey>('overview');
const [showSaveAsTemplateModal, setShowSaveAsTemplateModal] = useState(false);
const { data: competition, isLoading, error } = useCompetition(competitionId);
@ -218,7 +226,16 @@ export default function CompetitionDetailComponent({
<p className="text-slate-600 mt-2">{competition.description}</p>
)}
</div>
<StatusTransitionButton status={competition.status} competitionId={competitionId} />
<div className="flex items-center gap-3">
<button
onClick={() => setShowSaveAsTemplateModal(true)}
className="inline-flex items-center px-4 py-2 bg-slate-100 text-slate-700 font-semibold rounded-xl hover:bg-slate-200 transition-colors"
>
<FileText className="w-4 h-4 mr-2" />
Save as Template
</button>
<StatusTransitionButton status={competition.status} competitionId={competitionId} />
</div>
</div>
{/* Quick Stats */}
@ -310,14 +327,31 @@ export default function CompetitionDetailComponent({
<RegistrationsTab competitionId={competitionId} />
)}
{activeTab === 'participants' && (
<ParticipantsTab competitionId={competitionId} />
<ParticipantsTab
competitionId={competitionId}
groups={competition.stages?.flatMap((s) => s.groups ?? []) ?? []}
/>
)}
{activeTab === 'fixtures' && (
<FixturesTab competition={competition} />
)}
{activeTab === 'scheduling' && (
<SchedulingTab competition={competition} clubId={clubId} />
)}
{activeTab === 'results' && (
<ResultsTab competition={competition} clubId={clubId} />
)}
{activeTab === 'standings' && (
<StandingsTab competition={competition} />
)}
{/* Save as Template Modal */}
<SaveAsTemplateModal
isOpen={showSaveAsTemplateModal}
onClose={() => setShowSaveAsTemplateModal(false)}
competitionId={competitionId}
defaultName={competition.title}
/>
</div>
);
}

@ -0,0 +1,143 @@
'use client';
import React, { useState } from 'react';
import { X, Loader2, FileText } from 'lucide-react';
import { useSaveAsTemplate } from '@/src/hooks/mutations/useCompetitionMutations';
interface SaveAsTemplateModalProps {
isOpen: boolean;
onClose: () => void;
competitionId: number;
defaultName: string;
}
export default function SaveAsTemplateModal({
isOpen,
onClose,
competitionId,
defaultName,
}: SaveAsTemplateModalProps) {
const [name, setName] = useState(defaultName + ' Template');
const [description, setDescription] = useState('');
const saveAsTemplateMutation = useSaveAsTemplate(competitionId);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
await saveAsTemplateMutation.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
});
onClose();
} catch {
// Error is handled by the mutation
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative z-10 w-full max-w-md bg-white rounded-2xl shadow-xl mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-xl flex items-center justify-center">
<FileText className="w-5 h-5 text-purple-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900">Save as Template</h2>
<p className="text-sm text-slate-500">Create a reusable template from this competition</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-4">
<div>
<label htmlFor="templateName" className="block text-sm font-medium text-slate-700 mb-1">
Template Name *
</label>
<input
type="text"
id="templateName"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Weekly Padel Tournament"
className="w-full px-4 py-2 border border-slate-300 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 outline-none transition-all"
required
/>
</div>
<div>
<label htmlFor="templateDescription" className="block text-sm font-medium text-slate-700 mb-1">
Description (optional)
</label>
<textarea
id="templateDescription"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe when and how to use this template..."
rows={3}
className="w-full px-4 py-2 border border-slate-300 rounded-xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 outline-none transition-all resize-none"
/>
</div>
{saveAsTemplateMutation.isError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
{saveAsTemplateMutation.error instanceof Error
? saveAsTemplateMutation.error.message
: 'Failed to save as template'}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-200 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-700 font-medium hover:bg-slate-100 rounded-xl transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || saveAsTemplateMutation.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 disabled:cursor-not-allowed"
>
{saveAsTemplateMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<FileText className="w-4 h-4 mr-2" />
Save Template
</>
)}
</button>
</div>
</form>
</div>
</div>
);
}

@ -0,0 +1,147 @@
'use client';
import React from 'react';
import { X, Loader2, Users } from 'lucide-react';
import { useAssignParticipantToGroup } from '@/src/hooks/mutations/useCompetitionMutations';
import type { CompetitionGroup } from '@/src/types/competition';
interface GroupAssignmentModalProps {
isOpen: boolean;
onClose: () => void;
competitionId: number;
participantId: number;
participantName: string;
currentGroupId: number | null;
groups: CompetitionGroup[];
}
export default function GroupAssignmentModal({
isOpen,
onClose,
competitionId,
participantId,
participantName,
currentGroupId,
groups,
}: GroupAssignmentModalProps) {
const assignMutation = useAssignParticipantToGroup(competitionId);
if (!isOpen) return null;
const handleAssign = async (groupId: number) => {
try {
await assignMutation.mutateAsync({ participantId, groupId });
onClose();
} catch {
// Error is handled by the mutation
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative z-10 w-full max-w-md bg-white rounded-2xl shadow-xl mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-xl flex items-center justify-center">
<Users className="w-5 h-5 text-purple-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900">Assign to Group</h2>
<p className="text-sm text-slate-500">{participantName}</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Groups */}
<div className="px-6 py-4">
{groups.length === 0 ? (
<div className="text-center py-8">
<Users className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h3 className="font-medium text-slate-800 mb-1">No groups available</h3>
<p className="text-sm text-slate-500">
Create groups in the stage configuration first.
</p>
</div>
) : (
<div className="space-y-2">
{groups.map((group) => {
const isCurrentGroup = group.group_id === currentGroupId;
return (
<button
key={group.group_id}
onClick={() => handleAssign(group.group_id)}
disabled={isCurrentGroup || assignMutation.isPending}
className={`w-full flex items-center justify-between p-4 rounded-xl border transition-colors ${
isCurrentGroup
? 'bg-purple-50 border-purple-200 cursor-default'
: 'bg-white border-slate-200 hover:border-purple-300 hover:bg-purple-50'
} disabled:opacity-50`}
>
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center font-semibold ${
isCurrentGroup
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-600'
}`}
>
{group.name}
</div>
<div className="text-left">
<p className="font-medium text-slate-800">Group {group.name}</p>
<p className="text-sm text-slate-500">
{group.participant_count ?? 0} participants
</p>
</div>
</div>
{isCurrentGroup && (
<span className="text-xs font-medium text-purple-600 bg-purple-100 px-2 py-1 rounded-full">
Current
</span>
)}
{assignMutation.isPending && !isCurrentGroup && (
<Loader2 className="w-5 h-5 text-purple-600 animate-spin" />
)}
</button>
);
})}
</div>
)}
{assignMutation.isError && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
{assignMutation.error instanceof Error
? assignMutation.error.message
: 'Failed to assign to group'}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-200 flex justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-700 font-medium hover:bg-slate-100 rounded-xl transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
);
}

@ -1,17 +1,19 @@
'use client';
import React, { useState } from 'react';
import { Plus, Users, UserMinus, Hash, AlertCircle, Loader2 } from 'lucide-react';
import { Plus, Users, UserMinus, Hash, AlertCircle, Loader2, Layers } 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';
import GroupAssignmentModal from './GroupAssignmentModal';
import type { CompetitionParticipant, ParticipantStatus, CompetitionGroup } from '@/src/types/competition';
interface ParticipantsTabProps {
competitionId: number;
groups?: CompetitionGroup[];
}
const statusColors: Record<ParticipantStatus, string> = {
@ -23,15 +25,20 @@ const statusColors: Record<ParticipantStatus, string> = {
function ParticipantRow({
participant,
competitionId,
groups,
onAssignGroup,
}: {
participant: CompetitionParticipant;
competitionId: number;
groups?: CompetitionGroup[];
onAssignGroup: (participant: CompetitionParticipant) => void;
}) {
const [isEditingSeed, setIsEditingSeed] = useState(false);
const [seedValue, setSeedValue] = useState(participant.seed?.toString() ?? '');
const setSeedMutation = useSetParticipantSeed(competitionId);
const withdrawMutation = useWithdrawParticipant(competitionId);
const hasGroups = groups && groups.length > 0;
function handleSeedSave() {
const seed = parseInt(seedValue, 10);
@ -103,18 +110,29 @@ function ParticipantRow({
{/* 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" />
<div className="flex items-center gap-1">
{hasGroups && (
<button
onClick={() => onAssignGroup(participant)}
className="p-2 text-slate-400 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
title="Assign to group"
>
<Layers className="w-5 h-5" />
</button>
)}
</button>
<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>
)}
</div>
);
@ -135,8 +153,9 @@ function ParticipantSkeleton() {
);
}
export default function ParticipantsTab({ competitionId }: ParticipantsTabProps) {
export default function ParticipantsTab({ competitionId, groups = [] }: ParticipantsTabProps) {
const [statusFilter, setStatusFilter] = useState<ParticipantStatus | 'all'>('all');
const [selectedParticipant, setSelectedParticipant] = useState<CompetitionParticipant | null>(null);
const { data: participants, isLoading, error } = useCompetitionParticipants(competitionId, {
status: statusFilter === 'all' ? undefined : statusFilter,
@ -184,6 +203,8 @@ export default function ParticipantsTab({ competitionId }: ParticipantsTabProps)
key={participant.participant_id}
participant={participant}
competitionId={competitionId}
groups={groups}
onAssignGroup={setSelectedParticipant}
/>
))}
</div>
@ -198,6 +219,19 @@ export default function ParticipantsTab({ competitionId }: ParticipantsTabProps)
</div>
</Card>
)}
{/* Group Assignment Modal */}
{selectedParticipant && (
<GroupAssignmentModal
isOpen={!!selectedParticipant}
onClose={() => setSelectedParticipant(null)}
competitionId={competitionId}
participantId={selectedParticipant.participant_id}
participantName={selectedParticipant.display_name}
currentGroupId={selectedParticipant.stage_group_id ?? null}
groups={groups}
/>
)}
</div>
);
}

@ -105,14 +105,14 @@ function RegistrationRow({
)}
</div>
{/* Actions */}
{registration.status === 'pending' && (
{/* Actions - allow for both pending and waitlisted registrations */}
{(registration.status === 'pending' || registration.status === 'waitlisted') && (
<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"
title={registration.status === 'waitlisted' ? 'Approve from waitlist' : 'Approve'}
>
{approveMutation.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
@ -164,6 +164,7 @@ export default function RegistrationsTab({ competitionId }: RegistrationsTabProp
});
const pendingCount = registrations?.filter((r) => r.status === 'pending').length ?? 0;
const waitlistedCount = registrations?.filter((r) => r.status === 'waitlisted').length ?? 0;
return (
<div>
@ -176,9 +177,9 @@ export default function RegistrationsTab({ competitionId }: RegistrationsTabProp
>
<option value="all">All Statuses</option>
<option value="pending">Pending ({pendingCount})</option>
<option value="waitlisted">Waitlisted ({waitlistedCount})</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="waitlisted">Waitlisted</option>
<option value="withdrawn">Withdrawn</option>
</select>
</div>

@ -0,0 +1,492 @@
'use client';
import React, { useState } from 'react';
import {
Trophy,
AlertTriangle,
CheckCircle,
XCircle,
Loader2,
ChevronDown,
ChevronUp,
MessageSquare,
User,
} from 'lucide-react';
import Card from '@/src/components/cards/Card';
import {
useCompetitionFixtures,
useCompetitionDisputes,
} from '@/src/hooks/queries/useCompetitionQueries';
import {
useSetMatchWinner,
useResolveDispute,
} from '@/src/hooks/mutations/useCompetitionMutations';
import type {
Competition,
CompetitionMatch,
MatchDispute,
DisputeStatus,
} from '@/src/types/competition';
interface ResultsTabProps {
competition: Competition;
clubId: number;
}
const disputeStatusConfig: Record<DisputeStatus, { label: string; bgColor: string; textColor: string }> = {
pending: { label: 'Pending', bgColor: 'bg-amber-100', textColor: 'text-amber-700' },
resolved: { label: 'Resolved', bgColor: 'bg-emerald-100', textColor: 'text-emerald-700' },
rejected: { label: 'Rejected', bgColor: 'bg-red-100', textColor: 'text-red-700' },
};
function MatchResultCard({
match,
onSetWinner,
isPending,
}: {
match: CompetitionMatch;
onSetWinner: (participantId: number) => void;
isPending: boolean;
}) {
const homeName = match.home_participant?.display_name ?? 'TBD';
const awayName = match.away_participant?.display_name ?? 'TBD';
const isCompleted = match.status === 'completed';
const homeWon = match.winner_participant_id === match.home_participant_id;
const awayWon = match.winner_participant_id === match.away_participant_id;
return (
<Card variant="bordered" padding="md">
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="text-xs text-slate-500 mb-2">
Round {match.round_index + 1} Match {match.match_index + 1}
{match.group_name && `${match.group_name}`}
</div>
<div className="space-y-2">
{/* Home */}
<div className={`flex items-center justify-between p-2 rounded-lg ${
isCompleted && homeWon ? 'bg-emerald-50 border border-emerald-200' : 'bg-slate-50'
}`}>
<span className={`font-medium ${isCompleted && homeWon ? 'text-emerald-700' : 'text-slate-700'}`}>
{homeName}
</span>
{isCompleted && homeWon && (
<Trophy className="w-4 h-4 text-emerald-600" />
)}
{!isCompleted && match.home_participant_id && (
<button
onClick={() => onSetWinner(match.home_participant_id!)}
disabled={isPending}
className="text-xs px-2 py-1 bg-emerald-100 text-emerald-700 rounded-lg hover:bg-emerald-200 transition-colors disabled:opacity-50"
>
{isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : 'Winner'}
</button>
)}
</div>
{/* Away */}
<div className={`flex items-center justify-between p-2 rounded-lg ${
isCompleted && awayWon ? 'bg-emerald-50 border border-emerald-200' : 'bg-slate-50'
}`}>
<span className={`font-medium ${isCompleted && awayWon ? 'text-emerald-700' : 'text-slate-700'}`}>
{awayName}
</span>
{isCompleted && awayWon && (
<Trophy className="w-4 h-4 text-emerald-600" />
)}
{!isCompleted && match.away_participant_id && (
<button
onClick={() => onSetWinner(match.away_participant_id!)}
disabled={isPending}
className="text-xs px-2 py-1 bg-emerald-100 text-emerald-700 rounded-lg hover:bg-emerald-200 transition-colors disabled:opacity-50"
>
{isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : 'Winner'}
</button>
)}
</div>
</div>
</div>
</div>
</Card>
);
}
function DisputeCard({
dispute,
participants,
onResolve,
isPending,
}: {
dispute: MatchDispute;
participants: { id: number; name: string }[];
onResolve: (status: 'resolved' | 'rejected', winnerId?: number, notes?: string) => void;
isPending: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const [selectedWinner, setSelectedWinner] = useState<number | null>(null);
const [notes, setNotes] = useState('');
const config = disputeStatusConfig[dispute.status];
function handleResolve(status: 'resolved' | 'rejected') {
onResolve(
status,
status === 'resolved' ? selectedWinner ?? undefined : undefined,
notes || undefined
);
}
return (
<Card variant="bordered" padding="md">
<div className="space-y-3">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<AlertTriangle className="w-4 h-4 text-amber-500" />
<span className={`text-xs px-2 py-0.5 rounded-full ${config.bgColor} ${config.textColor}`}>
{config.label}
</span>
</div>
<p className="text-sm text-slate-700">{dispute.reason}</p>
<div className="flex items-center gap-2 mt-2 text-xs text-slate-500">
<User className="w-3 h-3" />
<span>{dispute.raised_by_display_name}</span>
<span></span>
<span>{new Date(dispute.created_at).toLocaleDateString()}</span>
</div>
</div>
{dispute.status === 'pending' && (
<button
onClick={() => setExpanded(!expanded)}
className="p-1 text-slate-400 hover:text-slate-600 transition-colors"
>
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
)}
</div>
{/* Evidence link */}
{dispute.evidence_url && (
<a
href={dispute.evidence_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-purple-600 hover:underline"
>
View evidence
</a>
)}
{/* Resolution notes if resolved/rejected */}
{dispute.resolution_notes && (
<div className="p-3 bg-slate-50 rounded-lg">
<div className="flex items-center gap-2 text-xs text-slate-500 mb-1">
<MessageSquare className="w-3 h-3" />
Resolution Notes
</div>
<p className="text-sm text-slate-700">{dispute.resolution_notes}</p>
</div>
)}
{/* Resolve form */}
{dispute.status === 'pending' && expanded && (
<div className="pt-3 border-t border-slate-200 space-y-3">
{/* Select correct winner */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Correct Winner (optional)
</label>
<select
value={selectedWinner ?? ''}
onChange={(e) => setSelectedWinner(e.target.value ? parseInt(e.target.value, 10) : null)}
className="w-full px-3 py-2 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
disabled={isPending}
>
<option value="">No change</option>
{participants.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{/* Resolution notes */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">
Resolution Notes
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add notes about this resolution..."
rows={2}
className="w-full px-3 py-2 bg-white border border-slate-200 rounded-lg text-sm resize-none focus:outline-none focus:ring-2 focus:ring-purple-500"
disabled={isPending}
/>
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={() => handleResolve('resolved')}
disabled={isPending}
className="flex-1 inline-flex items-center justify-center px-3 py-2 bg-emerald-100 text-emerald-700 font-medium text-sm rounded-lg hover:bg-emerald-200 transition-colors disabled:opacity-50"
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<CheckCircle className="w-4 h-4 mr-1" />
Resolve
</>
)}
</button>
<button
onClick={() => handleResolve('rejected')}
disabled={isPending}
className="flex-1 inline-flex items-center justify-center px-3 py-2 bg-red-100 text-red-700 font-medium text-sm rounded-lg hover:bg-red-200 transition-colors disabled:opacity-50"
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<XCircle className="w-4 h-4 mr-1" />
Reject
</>
)}
</button>
</div>
</div>
)}
</div>
</Card>
);
}
function MatchResultsSkeleton() {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Card key={i} variant="bordered" padding="md">
<div className="space-y-3">
<div className="h-3 w-32 bg-slate-200 rounded animate-pulse" />
<div className="space-y-2">
<div className="h-10 bg-slate-100 rounded-lg animate-pulse" />
<div className="h-10 bg-slate-100 rounded-lg animate-pulse" />
</div>
</div>
</Card>
))}
</div>
);
}
export default function ResultsTab({ competition, clubId }: ResultsTabProps) {
const [disputeFilter, setDisputeFilter] = useState<DisputeStatus | 'all'>('all');
const [activeStageId, setActiveStageId] = useState<number | null>(
competition.stages?.[0]?.stage_id ?? null
);
const {
data: fixtures,
isLoading: fixturesLoading,
} = useCompetitionFixtures(
competition.competition_id,
activeStageId ?? 0
);
const {
data: disputes,
isLoading: disputesLoading,
} = useCompetitionDisputes(
competition.competition_id,
disputeFilter === 'all' ? undefined : { status: disputeFilter }
);
const setWinnerMutation = useSetMatchWinner(
competition.competition_id,
activeStageId ?? 0
);
const resolveDisputeMutation = useResolveDispute(
competition.competition_id,
activeStageId ?? undefined
);
// Separate matches by status
const scheduledMatches = fixtures?.filter((f) => f.status === 'scheduled' || f.status === 'in_progress') ?? [];
const completedMatches = fixtures?.filter((f) => f.status === 'completed') ?? [];
// Count pending disputes
const pendingDisputeCount = disputes?.filter((d) => d.status === 'pending').length ?? 0;
function handleSetWinner(matchId: number, participantId: number) {
setWinnerMutation.mutate({
matchId,
request: { winner_participant_id: participantId },
});
}
function handleResolveDispute(
disputeId: number,
status: 'resolved' | 'rejected',
correctWinner?: number,
notes?: string
) {
resolveDisputeMutation.mutate({
disputeId,
request: {
status,
correct_winner_participant_id: correctWinner ?? null,
resolution_notes: notes ?? null,
},
});
}
return (
<div className="space-y-8">
{/* Stage selector */}
{competition.stages && competition.stages.length > 1 && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-slate-600">Stage:</label>
<select
value={activeStageId ?? ''}
onChange={(e) => setActiveStageId(parseInt(e.target.value, 10))}
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"
>
{competition.stages.map((stage) => (
<option key={stage.stage_id} value={stage.stage_id}>
{stage.name}
</option>
))}
</select>
</div>
)}
{/* Disputes Section */}
{pendingDisputeCount > 0 && (
<div className="p-4 bg-amber-50 border border-amber-200 rounded-xl">
<div className="flex items-center gap-2 mb-1">
<AlertTriangle className="w-5 h-5 text-amber-600" />
<span className="font-semibold text-amber-800">
{pendingDisputeCount} Pending Dispute{pendingDisputeCount !== 1 ? 's' : ''}
</span>
</div>
<p className="text-sm text-amber-700">
Review and resolve disputes before finalizing results.
</p>
</div>
)}
{/* Disputes List */}
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-800 flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-500" />
Disputes
</h3>
<select
value={disputeFilter}
onChange={(e) => setDisputeFilter(e.target.value as DisputeStatus | 'all')}
className="px-3 py-1.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="all">All ({disputes?.length ?? 0})</option>
<option value="pending">Pending</option>
<option value="resolved">Resolved</option>
<option value="rejected">Rejected</option>
</select>
</div>
{disputesLoading ? (
<MatchResultsSkeleton />
) : disputes && disputes.length > 0 ? (
<div className="space-y-4">
{disputes.map((dispute) => {
// Find match participants
const match = fixtures?.find((f) => f.competition_match_id === dispute.competition_match_id);
const participants = [
match?.home_participant_id && match?.home_participant
? { id: match.home_participant_id, name: match.home_participant.display_name }
: null,
match?.away_participant_id && match?.away_participant
? { id: match.away_participant_id, name: match.away_participant.display_name }
: null,
].filter((p): p is { id: number; name: string } => p !== null);
return (
<DisputeCard
key={dispute.dispute_id}
dispute={dispute}
participants={participants}
onResolve={(status, winnerId, notes) =>
handleResolveDispute(dispute.dispute_id, status, winnerId, notes)
}
isPending={resolveDisputeMutation.isPending}
/>
);
})}
</div>
) : (
<Card variant="bordered" padding="md">
<p className="text-center text-slate-500">No disputes</p>
</Card>
)}
</div>
{/* Matches Needing Results */}
<div>
<h3 className="text-lg font-semibold text-slate-800 flex items-center gap-2 mb-4">
<Trophy className="w-5 h-5 text-purple-500" />
Awaiting Results ({scheduledMatches.length})
</h3>
{fixturesLoading ? (
<MatchResultsSkeleton />
) : scheduledMatches.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
{scheduledMatches.map((match) => (
<MatchResultCard
key={match.competition_match_id}
match={match}
onSetWinner={(participantId) =>
handleSetWinner(match.competition_match_id, participantId)
}
isPending={setWinnerMutation.isPending}
/>
))}
</div>
) : (
<Card variant="bordered" padding="md">
<p className="text-center text-slate-500">No matches awaiting results</p>
</Card>
)}
</div>
{/* Completed Matches */}
<div>
<h3 className="text-lg font-semibold text-slate-800 flex items-center gap-2 mb-4">
<CheckCircle className="w-5 h-5 text-emerald-500" />
Completed ({completedMatches.length})
</h3>
{fixturesLoading ? (
<MatchResultsSkeleton />
) : completedMatches.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
{completedMatches.map((match) => (
<MatchResultCard
key={match.competition_match_id}
match={match}
onSetWinner={() => {}}
isPending={false}
/>
))}
</div>
) : (
<Card variant="bordered" padding="md">
<p className="text-center text-slate-500">No completed matches yet</p>
</Card>
)}
</div>
</div>
);
}

@ -0,0 +1,360 @@
'use client';
import React, { useState } from 'react';
import {
Calendar,
Clock,
AlertCircle,
Loader2,
CalendarPlus,
CalendarX,
} from 'lucide-react';
import Card from '@/src/components/cards/Card';
import { useCompetitionFixtures } from '@/src/hooks/queries/useCompetitionQueries';
import {
useLinkMatchToSlot,
useUnlinkMatchFromSlot,
} from '@/src/hooks/mutations/useCompetitionMutations';
import SlotPickerModal from './SlotPickerModal';
import type {
Competition,
CompetitionMatch,
CompetitionStage,
} from '@/src/types/competition';
interface SchedulingTabProps {
competition: Competition;
clubId: number;
}
const schedulingStatusConfig: Record<
'unscheduled' | 'scheduled',
{ label: string; bgColor: string; textColor: string }
> = {
unscheduled: { label: 'Needs Scheduling', bgColor: 'bg-amber-100', textColor: 'text-amber-700' },
scheduled: { label: 'Scheduled', bgColor: 'bg-emerald-100', textColor: 'text-emerald-700' },
};
function MatchSchedulingRow({
match,
competitionId,
stageId,
onSchedule,
}: {
match: CompetitionMatch;
competitionId: number;
stageId: number;
onSchedule: (match: CompetitionMatch) => void;
}) {
const unlinkMutation = useUnlinkMatchFromSlot(competitionId, stageId);
const homeName = match.home_participant?.display_name ?? 'TBD';
const awayName = match.away_participant?.display_name ?? 'TBD';
const isScheduled = match.status === 'scheduled' || match.slot_instance_id != null;
const config = schedulingStatusConfig[isScheduled ? 'scheduled' : 'unscheduled'];
function handleUnschedule() {
unlinkMutation.mutate(match.competition_match_id);
}
return (
<div className="flex items-center gap-4 p-4 bg-white border border-slate-200 rounded-xl">
{/* Match Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<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="flex items-center gap-2">
<span className={`font-medium ${homeName === 'TBD' ? 'text-slate-400' : 'text-slate-800'}`}>
{homeName}
</span>
<span className="text-xs text-slate-400">vs</span>
<span className={`font-medium ${awayName === 'TBD' ? 'text-slate-400' : 'text-slate-800'}`}>
{awayName}
</span>
</div>
{match.scheduled_at && (
<div className="flex items-center gap-1.5 mt-1 text-sm text-slate-600">
<Clock className="w-3.5 h-3.5" />
{new Date(match.scheduled_at).toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{isScheduled ? (
<button
onClick={handleUnschedule}
disabled={unlinkMutation.isPending}
className="inline-flex items-center px-3 py-2 bg-red-100 text-red-700 font-medium text-sm rounded-lg hover:bg-red-200 transition-colors disabled:opacity-50"
>
{unlinkMutation.isPending ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
) : (
<CalendarX className="w-4 h-4 mr-1.5" />
)}
Unschedule
</button>
) : (
<button
onClick={() => onSchedule(match)}
className="inline-flex items-center px-3 py-2 bg-purple-100 text-purple-700 font-medium text-sm rounded-lg hover:bg-purple-200 transition-colors"
>
<CalendarPlus className="w-4 h-4 mr-1.5" />
Schedule
</button>
)}
</div>
</div>
);
}
function MatchSchedulingSkeleton() {
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-4 w-24 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-20 bg-slate-200 rounded-full animate-pulse" />
</div>
<div className="flex items-center gap-2">
<div className="h-5 w-28 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-8 bg-slate-200 rounded animate-pulse" />
<div className="h-5 w-28 bg-slate-200 rounded animate-pulse" />
</div>
</div>
<div className="h-9 w-24 bg-slate-200 rounded-lg animate-pulse" />
</div>
);
}
function StageScheduling({
stage,
competitionId,
clubId,
}: {
stage: CompetitionStage;
competitionId: number;
clubId: number;
}) {
const [selectedMatch, setSelectedMatch] = useState<CompetitionMatch | null>(null);
const { data: fixtures, isLoading, error } = useCompetitionFixtures(competitionId, stage.stage_id);
const linkMutation = useLinkMatchToSlot(competitionId, stage.stage_id);
const hasFixtures = fixtures && fixtures.length > 0;
// Separate matches by scheduling status
const unscheduledMatches =
fixtures?.filter(
(m) =>
(m.status === 'draft' || m.status === 'needs_scheduling') &&
m.slot_instance_id == null
) ?? [];
const scheduledMatches =
fixtures?.filter(
(m) => m.status === 'scheduled' || m.slot_instance_id != null
) ?? [];
async function handleSlotSelect(slotInstanceId: number) {
if (!selectedMatch) return;
await linkMutation.mutateAsync({
matchId: selectedMatch.competition_match_id,
request: { slot_instance_id: slotInstanceId },
});
setSelectedMatch(null);
}
if (isLoading) {
return (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<MatchSchedulingSkeleton key={i} />
))}
</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 (
<Card variant="bordered" padding="lg">
<div className="text-center py-8">
<Calendar className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h4 className="font-semibold text-slate-800 mb-1">No fixtures yet</h4>
<p className="text-slate-500">
Generate fixtures in the Fixtures tab to schedule matches.
</p>
</div>
</Card>
);
}
return (
<div className="space-y-6">
{/* Unscheduled Matches */}
{unscheduledMatches.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<h4 className="font-semibold text-slate-700">Needs Scheduling</h4>
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700">
{unscheduledMatches.length}
</span>
</div>
<div className="space-y-3">
{unscheduledMatches
.sort((a, b) => a.round_index - b.round_index || a.match_index - b.match_index)
.map((match) => (
<MatchSchedulingRow
key={match.competition_match_id}
match={match}
competitionId={competitionId}
stageId={stage.stage_id}
onSchedule={setSelectedMatch}
/>
))}
</div>
</div>
)}
{/* Scheduled Matches */}
{scheduledMatches.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<h4 className="font-semibold text-slate-700">Scheduled</h4>
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700">
{scheduledMatches.length}
</span>
</div>
<div className="space-y-3">
{scheduledMatches
.sort((a, b) => {
if (a.scheduled_at && b.scheduled_at) {
return new Date(a.scheduled_at).getTime() - new Date(b.scheduled_at).getTime();
}
return a.round_index - b.round_index || a.match_index - b.match_index;
})
.map((match) => (
<MatchSchedulingRow
key={match.competition_match_id}
match={match}
competitionId={competitionId}
stageId={stage.stage_id}
onSchedule={setSelectedMatch}
/>
))}
</div>
</div>
)}
{/* All Scheduled Message */}
{unscheduledMatches.length === 0 && scheduledMatches.length > 0 && (
<div className="flex items-center gap-2 p-4 bg-emerald-50 border border-emerald-200 rounded-xl">
<Calendar className="w-5 h-5 text-emerald-600" />
<p className="text-emerald-700 font-medium">
All matches are scheduled!
</p>
</div>
)}
{/* Slot Picker Modal */}
{selectedMatch && (
<SlotPickerModal
isOpen={!!selectedMatch}
onClose={() => setSelectedMatch(null)}
onSelect={handleSlotSelect}
matchInfo={{
home: selectedMatch.home_participant?.display_name ?? 'TBD',
away: selectedMatch.away_participant?.display_name ?? 'TBD',
round: selectedMatch.round_index + 1,
}}
clubId={clubId}
isLoading={linkMutation.isPending}
error={linkMutation.error instanceof Error ? linkMutation.error.message : null}
/>
)}
</div>
);
}
export default function SchedulingTab({
competition,
clubId,
}: SchedulingTabProps) {
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 schedule matches.
</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>
)}
{/* Scheduling */}
{selectedStage && (
<StageScheduling
stage={selectedStage}
competitionId={competition.competition_id}
clubId={clubId}
/>
)}
</div>
);
}

@ -0,0 +1,267 @@
'use client';
import React, { useState, useMemo } from 'react';
import { X, Loader2, Calendar, Clock, MapPin, ChevronLeft, ChevronRight, AlertCircle } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { getSlotInstances } from '@/src/lib/api/slot-instances';
import type { SlotInstance } from '@/src/types/slot-instances';
interface SlotPickerModalProps {
isOpen: boolean;
onClose: () => void;
onSelect: (slotInstanceId: number) => void;
matchInfo: {
home: string;
away: string;
round: number;
};
clubId: number;
isLoading?: boolean;
error?: string | null;
}
function formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
function formatDisplayDate(date: Date): string {
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
}
function formatTime(isoString: string): string {
return new Date(isoString).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
}
function addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
export default function SlotPickerModal({
isOpen,
onClose,
onSelect,
matchInfo,
clubId,
isLoading: isSaving,
error: saveError,
}: SlotPickerModalProps) {
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [selectedSlotId, setSelectedSlotId] = useState<number | null>(null);
const dateString = formatDate(selectedDate);
const { data: slotsData, isLoading, error } = useQuery({
queryKey: ['slotInstances', clubId, dateString],
queryFn: async () => {
const result = await getSlotInstances(clubId, dateString, { status: 'open' });
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: isOpen && clubId > 0,
});
// Filter to only show future slots that are open
const availableSlots = useMemo(() => {
if (!slotsData?.slots) return [];
return slotsData.slots.filter(
(slot) =>
slot.status === 'open' &&
new Date(slot.starts_at) > new Date()
);
}, [slotsData]);
// Group slots by time
const slotsByTime = useMemo(() => {
const grouped: Record<string, SlotInstance[]> = {};
availableSlots.forEach((slot) => {
const time = formatTime(slot.starts_at);
if (!grouped[time]) grouped[time] = [];
grouped[time].push(slot);
});
return grouped;
}, [availableSlots]);
function handlePrevDay() {
setSelectedDate((d) => addDays(d, -1));
setSelectedSlotId(null);
}
function handleNextDay() {
setSelectedDate((d) => addDays(d, 1));
setSelectedSlotId(null);
}
function handleSelectSlot(slotId: number) {
setSelectedSlotId(slotId);
}
function handleConfirm() {
if (selectedSlotId != null) {
onSelect(selectedSlotId);
}
}
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative z-10 w-full max-w-lg bg-white rounded-2xl shadow-xl mx-4 max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-xl flex items-center justify-center">
<Calendar className="w-5 h-5 text-purple-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900">Schedule Match</h2>
<p className="text-sm text-slate-500">
Round {matchInfo.round}: {matchInfo.home} vs {matchInfo.away}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Date Selector */}
<div className="px-6 py-4 border-b border-slate-200">
<div className="flex items-center justify-between">
<button
onClick={handlePrevDay}
disabled={formatDate(selectedDate) <= formatDate(new Date())}
className="p-2 text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div className="text-center">
<p className="font-semibold text-slate-800">{formatDisplayDate(selectedDate)}</p>
<p className="text-xs text-slate-500">{dateString}</p>
</div>
<button
onClick={handleNextDay}
className="p-2 text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-lg transition-colors"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
{/* Slots */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
</div>
) : error ? (
<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">{error instanceof Error ? error.message : 'Failed to load slots'}</p>
</div>
) : Object.keys(slotsByTime).length === 0 ? (
<div className="text-center py-12">
<Clock className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h4 className="font-semibold text-slate-800 mb-1">No available slots</h4>
<p className="text-slate-500">
Try selecting a different date.
</p>
</div>
) : (
<div className="space-y-4">
{Object.entries(slotsByTime)
.sort(([a], [b]) => a.localeCompare(b))
.map(([time, timeSlots]) => (
<div key={time}>
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-slate-400" />
<span className="text-sm font-medium text-slate-600">{time}</span>
</div>
<div className="grid grid-cols-2 gap-2">
{timeSlots.map((slot) => {
const isSelected = selectedSlotId === slot.slot_instance_id;
return (
<button
key={slot.slot_instance_id}
onClick={() => handleSelectSlot(slot.slot_instance_id)}
className={`flex items-center gap-2 p-3 rounded-xl border transition-colors ${
isSelected
? 'border-purple-500 bg-purple-50 ring-2 ring-purple-500'
: 'border-slate-200 bg-white hover:border-purple-300 hover:bg-purple-50'
}`}
>
<MapPin className={`w-4 h-4 ${isSelected ? 'text-purple-600' : 'text-slate-400'}`} />
<span className={`text-sm font-medium ${isSelected ? 'text-purple-700' : 'text-slate-700'}`}>
{slot.court_name}
</span>
</button>
);
})}
</div>
</div>
))}
</div>
)}
</div>
{/* Error */}
{saveError && (
<div className="px-6 py-3 border-t border-slate-200">
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-xl">
<AlertCircle className="w-4 h-4 text-red-600" />
<p className="text-sm text-red-700">{saveError}</p>
</div>
</div>
)}
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-200 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-700 font-medium hover:bg-slate-100 rounded-xl transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={selectedSlotId == null || isSaving}
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 disabled:cursor-not-allowed"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Scheduling...
</>
) : (
<>
<Calendar className="w-4 h-4 mr-2" />
Schedule Match
</>
)}
</button>
</div>
</div>
</div>
);
}

@ -0,0 +1,345 @@
'use client';
import React, { useState } from 'react';
import { X, Loader2, AlertCircle, Calendar, Clock, RefreshCw } from 'lucide-react';
import { useCreateSeries } from '@/src/hooks/mutations/useCompetitionMutations';
import type { CompetitionTemplate, CreateSeriesRequest, CarryOverPolicy } from '@/src/types/competition';
interface CreateSeriesModalProps {
isOpen: boolean;
onClose: () => void;
clubId: number;
templates: CompetitionTemplate[];
}
type RecurrenceFrequency = 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY';
const frequencyOptions: { value: RecurrenceFrequency; label: string; rrule: string }[] = [
{ value: 'WEEKLY', label: 'Weekly', rrule: 'FREQ=WEEKLY;INTERVAL=1' },
{ value: 'BIWEEKLY', label: 'Every 2 weeks', rrule: 'FREQ=WEEKLY;INTERVAL=2' },
{ value: 'MONTHLY', label: 'Monthly', rrule: 'FREQ=MONTHLY;INTERVAL=1' },
];
const dayOptions = [
{ value: 'MO', label: 'Monday' },
{ value: 'TU', label: 'Tuesday' },
{ value: 'WE', label: 'Wednesday' },
{ value: 'TH', label: 'Thursday' },
{ value: 'FR', label: 'Friday' },
{ value: 'SA', label: 'Saturday' },
{ value: 'SU', label: 'Sunday' },
];
export default function CreateSeriesModal({
isOpen,
onClose,
clubId,
templates,
}: CreateSeriesModalProps) {
const createMutation = useCreateSeries(clubId);
// Form state
const [templateId, setTemplateId] = useState<number | null>(null);
const [frequency, setFrequency] = useState<RecurrenceFrequency>('WEEKLY');
const [dayOfWeek, setDayOfWeek] = useState('MO');
const [nextRunDate, setNextRunDate] = useState('');
const [nextRunTime, setNextRunTime] = useState('09:00');
const [autoPublish, setAutoPublish] = useState(true);
const [carryParticipants, setCarryParticipants] = useState(false);
const [carrySeeds, setCarrySeeds] = useState(false);
const [resetStandings, setResetStandings] = useState(true);
const [error, setError] = useState<string | null>(null);
if (!isOpen) return null;
function buildRRule(): string {
const base = frequencyOptions.find((f) => f.value === frequency)?.rrule ?? 'FREQ=WEEKLY;INTERVAL=1';
return `${base};BYDAY=${dayOfWeek}`;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!templateId) {
setError('Please select a competition template');
return;
}
if (!nextRunDate) {
setError('Please select when the first competition should be created');
return;
}
const carryOverPolicy: CarryOverPolicy = {
carry_participants: carryParticipants,
carry_seeds: carrySeeds,
reset_standings: resetStandings,
};
const nextRunAt = `${nextRunDate}T${nextRunTime}:00`;
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const request: CreateSeriesRequest = {
scope: 'facility',
template_id: templateId,
timezone,
rrule: buildRRule(),
next_run_at: nextRunAt,
auto_publish: autoPublish,
carry_over_policy: carryOverPolicy,
};
createMutation.mutate(request, {
onSuccess: () => {
onClose();
resetForm();
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Failed to create series');
},
});
}
function resetForm() {
setTemplateId(null);
setFrequency('WEEKLY');
setDayOfWeek('MO');
setNextRunDate('');
setNextRunTime('09:00');
setAutoPublish(true);
setCarryParticipants(false);
setCarrySeeds(false);
setResetStandings(true);
setError(null);
}
function handleClose() {
if (!createMutation.isPending) {
onClose();
resetForm();
}
}
// Get minimum date (today)
const today = new Date().toISOString().split('T')[0];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={handleClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-xl">
<RefreshCw className="w-5 h-5 text-purple-600" />
</div>
<div>
<h2 className="text-lg font-bold text-slate-900">Create Series</h2>
<p className="text-sm text-slate-500">Auto-generate competitions on a schedule</p>
</div>
</div>
<button
onClick={handleClose}
disabled={createMutation.isPending}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-xl">
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0" />
<p className="text-sm text-red-700">{error}</p>
</div>
)}
{/* Template Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Competition Template *
</label>
<select
value={templateId ?? ''}
onChange={(e) => setTemplateId(e.target.value ? parseInt(e.target.value, 10) : null)}
className="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
disabled={createMutation.isPending}
>
<option value="">Select a template...</option>
{templates.map((t) => (
<option key={t.template_id} value={t.template_id}>
{t.name}
</option>
))}
</select>
{templates.length === 0 && (
<p className="text-xs text-amber-600 mt-1">
No templates available. Create a template first.
</p>
)}
</div>
{/* Recurrence Pattern */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Frequency
</label>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value as RecurrenceFrequency)}
className="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
disabled={createMutation.isPending}
>
{frequencyOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Day of Week
</label>
<select
value={dayOfWeek}
onChange={(e) => setDayOfWeek(e.target.value)}
className="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
disabled={createMutation.isPending}
>
{dayOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
{/* First Run */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4" />
First Competition Created On *
</div>
</label>
<div className="grid grid-cols-2 gap-4">
<input
type="date"
value={nextRunDate}
onChange={(e) => setNextRunDate(e.target.value)}
min={today}
className="w-full px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
disabled={createMutation.isPending}
/>
<div className="relative">
<Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="time"
value={nextRunTime}
onChange={(e) => setNextRunTime(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
disabled={createMutation.isPending}
/>
</div>
</div>
</div>
{/* Auto-publish */}
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl">
<div>
<p className="font-medium text-slate-700">Auto-publish</p>
<p className="text-sm text-slate-500">Automatically publish new competitions</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={autoPublish}
onChange={(e) => setAutoPublish(e.target.checked)}
className="sr-only peer"
disabled={createMutation.isPending}
/>
<div className="w-11 h-6 bg-slate-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600" />
</label>
</div>
{/* Carry-over Policy */}
<div className="space-y-3">
<p className="text-sm font-medium text-slate-700">Carry-over Policy</p>
<div className="space-y-2 p-4 bg-slate-50 rounded-xl">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={carryParticipants}
onChange={(e) => setCarryParticipants(e.target.checked)}
className="w-4 h-4 text-purple-600 border-slate-300 rounded focus:ring-purple-500"
disabled={createMutation.isPending}
/>
<span className="text-sm text-slate-700">Carry over participants from previous instance</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={carrySeeds}
onChange={(e) => setCarrySeeds(e.target.checked)}
className="w-4 h-4 text-purple-600 border-slate-300 rounded focus:ring-purple-500"
disabled={createMutation.isPending}
/>
<span className="text-sm text-slate-700">Carry over seeding/rankings</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={resetStandings}
onChange={(e) => setResetStandings(e.target.checked)}
className="w-4 h-4 text-purple-600 border-slate-300 rounded focus:ring-purple-500"
disabled={createMutation.isPending}
/>
<span className="text-sm text-slate-700">Reset standings for new instance</span>
</label>
</div>
</div>
{/* Submit */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={handleClose}
disabled={createMutation.isPending}
className="flex-1 px-4 py-2.5 bg-slate-100 text-slate-700 font-semibold rounded-xl hover:bg-slate-200 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={createMutation.isPending || templates.length === 0}
className="flex-1 inline-flex items-center justify-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 disabled:opacity-50"
>
{createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
'Create Series'
)}
</button>
</div>
</form>
</div>
</div>
);
}

@ -0,0 +1,271 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import {
ArrowLeft,
Loader2,
AlertCircle,
Plus,
RefreshCw,
Pause,
Play,
Calendar,
Clock,
FileText,
} from 'lucide-react';
import useTranslation from '@/src/hooks/useTranslation';
import Card from '@/src/components/cards/Card';
import { useCompetitionSeries } from '@/src/hooks/queries/useCompetitionQueries';
import { useCompetitionTemplates } from '@/src/hooks/queries/useCompetitionQueries';
import { useUpdateSeries, useCancelSeries, useGenerateSeriesInstance } from '@/src/hooks/mutations/useCompetitionMutations';
import CreateSeriesModal from './CreateSeriesModal';
import type { CompetitionSeries, SeriesStatus } from '@/src/types/competition';
interface SeriesListComponentProps {
clubId: number;
}
const statusConfig: Record<SeriesStatus, {
label: string;
bgColor: string;
textColor: string;
}> = {
active: { label: 'Active', bgColor: 'bg-emerald-100', textColor: 'text-emerald-700' },
paused: { label: 'Paused', bgColor: 'bg-amber-100', textColor: 'text-amber-700' },
cancelled: { label: 'Cancelled', bgColor: 'bg-red-100', textColor: 'text-red-700' },
};
function SeriesCard({
series,
clubId,
}: {
series: CompetitionSeries;
clubId: number;
}) {
const { locale } = useTranslation();
const updateMutation = useUpdateSeries(series.series_id);
const generateMutation = useGenerateSeriesInstance(series.series_id);
const config = statusConfig[series.status];
const isPending = updateMutation.isPending || generateMutation.isPending;
function handleToggleStatus() {
if (series.status === 'active') {
updateMutation.mutate({ status: 'paused' });
} else if (series.status === 'paused') {
updateMutation.mutate({ status: 'active' });
}
}
function handleGenerateNext() {
generateMutation.mutate();
}
return (
<Card variant="bordered" padding="md">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${config.bgColor} ${config.textColor}`}>
{config.label}
</span>
</div>
<h3 className="font-semibold text-slate-800 mb-1">
{series.template_name ?? `Series #${series.series_id}`}
</h3>
<div className="flex flex-wrap items-center gap-4 text-sm text-slate-600">
<div className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
<span>RRULE: {series.rrule.substring(0, 30)}...</span>
</div>
{series.next_run_at && (
<div className="flex items-center gap-1.5">
<Clock className="w-4 h-4" />
<span>
Next: {new Date(series.next_run_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
</div>
)}
{series.instance_count !== undefined && (
<div className="flex items-center gap-1.5">
<FileText className="w-4 h-4" />
<span>{series.instance_count} instances</span>
</div>
)}
</div>
</div>
{series.status !== 'cancelled' && (
<div className="flex items-center gap-2">
{series.status === 'active' && (
<button
onClick={handleGenerateNext}
disabled={isPending}
className="inline-flex items-center px-3 py-2 bg-purple-100 text-purple-700 font-medium text-sm rounded-lg hover:bg-purple-200 transition-colors disabled:opacity-50"
title="Generate next competition now"
>
{generateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
</button>
)}
<button
onClick={handleToggleStatus}
disabled={isPending}
className={`inline-flex items-center px-3 py-2 font-medium text-sm rounded-lg transition-colors disabled:opacity-50 ${
series.status === 'active'
? 'bg-amber-100 text-amber-700 hover:bg-amber-200'
: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200'
}`}
title={series.status === 'active' ? 'Pause series' : 'Resume series'}
>
{updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : series.status === 'active' ? (
<Pause className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
</button>
</div>
)}
</div>
</Card>
);
}
function SeriesCardSkeleton() {
return (
<Card variant="bordered" padding="md">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="h-5 w-16 bg-slate-200 rounded-full mb-2 animate-pulse" />
<div className="h-6 w-48 bg-slate-200 rounded mb-2 animate-pulse" />
<div className="flex items-center gap-4">
<div className="h-4 w-32 bg-slate-200 rounded animate-pulse" />
<div className="h-4 w-24 bg-slate-200 rounded animate-pulse" />
</div>
</div>
<div className="flex gap-2">
<div className="h-9 w-9 bg-slate-200 rounded-lg animate-pulse" />
<div className="h-9 w-9 bg-slate-200 rounded-lg animate-pulse" />
</div>
</div>
</Card>
);
}
export default function SeriesListComponent({ clubId }: SeriesListComponentProps) {
const { locale } = useTranslation();
const [showCreateModal, setShowCreateModal] = useState(false);
const [statusFilter, setStatusFilter] = useState<SeriesStatus | 'all'>('all');
const { data: series, isLoading, error } = useCompetitionSeries(
clubId,
statusFilter === 'all' ? undefined : { status: statusFilter }
);
const { data: templates } = useCompetitionTemplates(clubId);
const activeCount = series?.filter((s) => s.status === 'active').length ?? 0;
const pausedCount = series?.filter((s) => s.status === 'paused').length ?? 0;
return (
<div className="py-8">
{/* 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="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 className="text-2xl font-bold text-slate-900">Competition Series</h1>
<p className="text-slate-600 mt-1">
Manage recurring competitions that auto-generate on a schedule
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
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"
>
<Plus className="w-4 h-4 mr-2" />
Create Series
</button>
</div>
{/* Filters */}
<div className="flex items-center gap-4 mb-6">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as SeriesStatus | '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 ({series?.length ?? 0})</option>
<option value="active">Active ({activeCount})</option>
<option value="paused">Paused ({pausedCount})</option>
<option value="cancelled">Cancelled</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">{error instanceof Error ? error.message : 'Failed to load series'}</p>
</div>
)}
{/* List */}
{isLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<SeriesCardSkeleton key={i} />
))}
</div>
) : series && series.length > 0 ? (
<div className="space-y-4">
{series.map((s) => (
<SeriesCard key={s.series_id} series={s} clubId={clubId} />
))}
</div>
) : (
<Card variant="bordered" padding="lg">
<div className="text-center py-8">
<RefreshCw className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h3 className="font-semibold text-slate-800 mb-1">No series yet</h3>
<p className="text-slate-500 mb-4">
Create a series to automatically generate competitions on a recurring schedule.
</p>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-4 py-2 bg-purple-600 text-white font-semibold rounded-xl hover:bg-purple-700 transition-colors"
>
<Plus className="w-4 h-4 mr-2" />
Create Series
</button>
</div>
</Card>
)}
{/* Create Series Modal */}
<CreateSeriesModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
clubId={clubId}
templates={templates ?? []}
/>
</div>
);
}

@ -0,0 +1,12 @@
import SeriesListComponent from './SeriesListComponent';
export default async function SeriesPage({
params,
}: {
params: Promise<{ club_id: string }>;
}) {
const { club_id } = await params;
const clubId = parseInt(club_id, 10);
return <SeriesListComponent clubId={clubId} />;
}

@ -8,6 +8,7 @@ import {
startCompetition,
finishCompetition,
cancelCompetition,
saveCompetitionAsTemplate,
approveRegistration,
rejectRegistration,
createParticipant,
@ -21,6 +22,12 @@ import {
voidMatch,
recalculateStandings,
recalculateLeaderboard,
createSeries,
updateSeries,
cancelSeries,
generateSeriesInstance,
createMatchDispute,
resolveDispute,
} from '@/src/lib/api/competition-admin';
import { competitionQueryKeys } from '@/src/hooks/queries/useCompetitionQueries';
import type {
@ -32,6 +39,10 @@ import type {
GenerateFixturesRequest,
LinkSlotRequest,
SetWinnerRequest,
CreateSeriesRequest,
UpdateSeriesRequest,
CreateDisputeRequest,
ResolveDisputeRequest,
} from '@/src/types/competition';
// ============================================================================
@ -226,6 +237,26 @@ export function useCancelCompetition(competitionId: number) {
});
}
/**
* Save competition as template
*/
export function useSaveAsTemplate(competitionId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; description?: string }) => {
const result = await saveCompetitionAsTemplate(competitionId, data);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.templates() });
},
});
}
// ============================================================================
// Registration Mutations
// ============================================================================
@ -579,6 +610,171 @@ export function useRecalculateLeaderboard(competitionId: number, stageId: number
});
}
// ============================================================================
// Series Mutations
// ============================================================================
/**
* Create a new series
*/
export function useCreateSeries(facilityId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateSeriesRequest) => {
const result = await createSeries(facilityId, data);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.series() });
},
});
}
/**
* Update an existing series
*/
export function useUpdateSeries(seriesId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: UpdateSeriesRequest) => {
const result = await updateSeries(seriesId, data);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.series() });
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.seriesDetail(seriesId),
});
},
});
}
/**
* Cancel a series
*/
export function useCancelSeries(seriesId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const result = await cancelSeries(seriesId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.series() });
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.seriesDetail(seriesId),
});
},
});
}
/**
* Generate next competition instance for a series
*/
export function useGenerateSeriesInstance(seriesId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const result = await generateSeriesInstance(seriesId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.seriesInstances(seriesId),
});
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.seriesDetail(seriesId),
});
queryClient.invalidateQueries({ queryKey: competitionQueryKeys.lists() });
},
});
}
// ============================================================================
// Dispute Mutations
// ============================================================================
/**
* Create a dispute on a match
*/
export function useCreateMatchDispute(competitionId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
matchId,
request,
}: {
matchId: number;
request: CreateDisputeRequest;
}) => {
const result = await createMatchDispute(matchId, request);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.disputes(competitionId),
});
},
});
}
/**
* Resolve or reject a dispute
*/
export function useResolveDispute(competitionId: number, stageId?: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
disputeId,
request,
}: {
disputeId: number;
request: ResolveDisputeRequest;
}) => {
const result = await resolveDispute(disputeId, request);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.disputes(competitionId),
});
// Also invalidate fixtures and standings if winner was changed
if (stageId) {
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.fixtures(competitionId, stageId),
});
queryClient.invalidateQueries({
queryKey: competitionQueryKeys.standings(competitionId, stageId),
});
}
},
});
}
// ============================================================================
// Type exports for mutation results
// ============================================================================
@ -590,3 +786,7 @@ export type UpdateCompetitionMutation = ReturnType<typeof useUpdateCompetition>;
export type PublishCompetitionMutation = ReturnType<typeof usePublishCompetition>;
export type ApproveRegistrationMutation = ReturnType<typeof useApproveRegistration>;
export type GenerateFixturesMutation = ReturnType<typeof useGenerateFixtures>;
export type CreateSeriesMutation = ReturnType<typeof useCreateSeries>;
export type UpdateSeriesMutation = ReturnType<typeof useUpdateSeries>;
export type CreateDisputeMutation = ReturnType<typeof useCreateMatchDispute>;
export type ResolveDisputeMutation = ReturnType<typeof useResolveDispute>;

@ -10,6 +10,10 @@ import {
getMatch,
getStandings,
getLeaderboard,
listSeries,
getSeries,
listSeriesInstances,
listCompetitionDisputes,
} from '@/src/lib/api/competition-admin';
import type {
CompetitionListFilters,
@ -17,6 +21,8 @@ import type {
ParticipantListFilters,
FixtureListFilters,
StandingsFilters,
SeriesListFilters,
DisputeStatus,
} from '@/src/types/competition';
// ============================================================================
@ -70,6 +76,21 @@ export const competitionQueryKeys = {
// Leaderboard
leaderboard: (competitionId: number, stageId: number) =>
[...competitionQueryKeys.detail(competitionId), 'leaderboard', stageId] as const,
// Series
series: () => [...competitionQueryKeys.all, 'series'] as const,
seriesList: (facilityId: number, filters?: SeriesListFilters) =>
[...competitionQueryKeys.series(), 'list', facilityId, filters] as const,
seriesDetail: (seriesId: number) =>
[...competitionQueryKeys.series(), 'detail', seriesId] as const,
seriesInstances: (seriesId: number) =>
[...competitionQueryKeys.seriesDetail(seriesId), 'instances'] as const,
// Disputes
disputes: (competitionId: number) =>
[...competitionQueryKeys.detail(competitionId), 'disputes'] as const,
disputeList: (competitionId: number, filters?: { status?: DisputeStatus }) =>
[...competitionQueryKeys.disputes(competitionId), filters] as const,
};
// ============================================================================
@ -291,3 +312,82 @@ export function useCompetitionLeaderboard(
enabled: competitionId > 0 && stageId > 0,
});
}
// ============================================================================
// Series Query Hooks
// ============================================================================
/**
* Fetch series for a facility
*/
export function useCompetitionSeries(facilityId: number, filters?: SeriesListFilters) {
return useQuery({
queryKey: competitionQueryKeys.seriesList(facilityId, filters),
queryFn: async () => {
const result = await listSeries(facilityId, filters);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: facilityId > 0,
});
}
/**
* Fetch a single series
*/
export function useSeriesDetail(seriesId: number) {
return useQuery({
queryKey: competitionQueryKeys.seriesDetail(seriesId),
queryFn: async () => {
const result = await getSeries(seriesId);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: seriesId > 0,
});
}
/**
* Fetch competitions generated by a series
*/
export function useSeriesInstances(seriesId: number, limit: number = 20, offset: number = 0) {
return useQuery({
queryKey: [...competitionQueryKeys.seriesInstances(seriesId), limit, offset],
queryFn: async () => {
const result = await listSeriesInstances(seriesId, limit, offset);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: seriesId > 0,
});
}
// ============================================================================
// Dispute Query Hooks
// ============================================================================
/**
* Fetch disputes for a competition
*/
export function useCompetitionDisputes(
competitionId: number,
filters?: { status?: DisputeStatus }
) {
return useQuery({
queryKey: competitionQueryKeys.disputeList(competitionId, filters),
queryFn: async () => {
const result = await listCompetitionDisputes(competitionId, filters);
if (!result.success) {
throw new Error(result.error.detail);
}
return result.data;
},
enabled: competitionId > 0,
});
}

@ -29,6 +29,14 @@ import type {
StandingsFilters,
LeaderboardEntry,
LeaderboardStats,
CompetitionSeries,
CreateSeriesRequest,
UpdateSeriesRequest,
SeriesListFilters,
MatchDispute,
CreateDisputeRequest,
ResolveDisputeRequest,
DisputeStatus,
CompetitionAdminError,
CompetitionApiResult,
} from '@/src/types/competition';
@ -388,6 +396,31 @@ export async function cancelCompetition(
}
}
/**
* POST /competitions/{competition_id}/save-as-template
* Create a template from an existing competition's config
*/
export async function saveCompetitionAsTemplate(
competitionId: number,
request: { name: string; description?: string }
): Promise<CompetitionApiResult<CompetitionTemplate>> {
try {
const response = await apiFetch(`/competitions/${competitionId}/save-as-template`, {
method: 'POST',
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 save as template');
}
}
// ============================================================================
// Registrations
// ============================================================================
@ -868,3 +901,254 @@ export async function recalculateLeaderboard(
return buildNetworkError(error instanceof Error ? error.message : 'Failed to recalculate leaderboard');
}
}
// ============================================================================
// Competition Series
// ============================================================================
/**
* GET /competitions/series
* List all series for a facility
*/
export async function listSeries(
facilityId: number,
filters?: SeriesListFilters
): Promise<CompetitionApiResult<CompetitionSeries[]>> {
try {
const params = new URLSearchParams();
params.set('facility_id', String(facilityId));
if (filters?.status) params.set('status', filters.status);
const response = await apiFetch(`/competitions/series?${params.toString()}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ series: CompetitionSeries[] }>(response);
if (result.success && result.data.series) {
return { success: true, data: result.data.series };
}
return result as CompetitionApiResult<CompetitionSeries[]>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to list series');
}
}
/**
* GET /competitions/series/{series_id}
* Get series details
*/
export async function getSeries(
seriesId: number
): Promise<CompetitionApiResult<CompetitionSeries>> {
try {
const response = await apiFetch(`/competitions/series/${seriesId}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
return handleApiResponse<CompetitionSeries>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to get series');
}
}
/**
* POST /competitions/series
* Create a new series
*/
export async function createSeries(
facilityId: number,
request: CreateSeriesRequest
): Promise<CompetitionApiResult<CompetitionSeries>> {
try {
const response = await apiFetch('/competitions/series', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...request,
scope: 'facility',
facility_id: facilityId,
}),
});
return handleApiResponse<CompetitionSeries>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to create series');
}
}
/**
* PATCH /competitions/series/{series_id}
* Update a series
*/
export async function updateSeries(
seriesId: number,
request: UpdateSeriesRequest
): Promise<CompetitionApiResult<CompetitionSeries>> {
try {
const response = await apiFetch(`/competitions/series/${seriesId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
return handleApiResponse<CompetitionSeries>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to update series');
}
}
/**
* DELETE /competitions/series/{series_id}
* Cancel a series (soft delete)
*/
export async function cancelSeries(
seriesId: number
): Promise<CompetitionApiResult<void>> {
try {
const response = await apiFetch(`/competitions/series/${seriesId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
return handleApiResponse<void>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to cancel series');
}
}
/**
* GET /competitions/series/{series_id}/instances
* List competitions generated by this series
*/
export async function listSeriesInstances(
seriesId: number,
limit: number = 20,
offset: number = 0
): Promise<CompetitionApiResult<Competition[]>> {
try {
const params = new URLSearchParams();
if (limit !== 20) params.set('limit', String(limit));
if (offset !== 0) params.set('offset', String(offset));
const queryString = params.toString();
const endpoint = `/competitions/series/${seriesId}/instances${queryString ? `?${queryString}` : ''}`;
const response = await apiFetch(endpoint, {
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 series instances');
}
}
/**
* POST /competitions/series/{series_id}/generate
* Manually trigger generation of next competition instance
*/
export async function generateSeriesInstance(
seriesId: number
): Promise<CompetitionApiResult<Competition>> {
try {
const response = await apiFetch(`/competitions/series/${seriesId}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
return handleApiResponse<Competition>(response);
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to generate series instance');
}
}
// ============================================================================
// Match Disputes
// ============================================================================
/**
* GET /competitions/{competition_id}/disputes
* List disputes for a competition
*/
export async function listCompetitionDisputes(
competitionId: number,
filters?: { status?: DisputeStatus }
): Promise<CompetitionApiResult<MatchDispute[]>> {
try {
const params = new URLSearchParams();
if (filters?.status) params.set('status', filters.status);
const queryString = params.toString();
const endpoint = `/competitions/${competitionId}/disputes${queryString ? `?${queryString}` : ''}`;
const response = await apiFetch(endpoint, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const result = await handleApiResponse<{ disputes: MatchDispute[] }>(response);
if (result.success && result.data.disputes) {
return { success: true, data: result.data.disputes };
}
return result as CompetitionApiResult<MatchDispute[]>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to list disputes');
}
}
/**
* POST /competitions/matches/{match_id}/dispute
* Raise a dispute on a match
*/
export async function createMatchDispute(
matchId: number,
request: CreateDisputeRequest
): Promise<CompetitionApiResult<MatchDispute>> {
try {
const response = await apiFetch(`/competitions/matches/${matchId}/dispute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
const result = await handleApiResponse<{ dispute: MatchDispute }>(response);
if (result.success && result.data.dispute) {
return { success: true, data: result.data.dispute };
}
return result as CompetitionApiResult<MatchDispute>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to create dispute');
}
}
/**
* POST /competitions/disputes/{dispute_id}/resolve
* Resolve or reject a dispute
*/
export async function resolveDispute(
disputeId: number,
request: ResolveDisputeRequest
): Promise<CompetitionApiResult<MatchDispute>> {
try {
const response = await apiFetch(`/competitions/disputes/${disputeId}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
const result = await handleApiResponse<{ dispute: MatchDispute }>(response);
if (result.success && result.data.dispute) {
return { success: true, data: result.data.dispute };
}
return result as CompetitionApiResult<MatchDispute>;
} catch (error) {
return buildNetworkError(error instanceof Error ? error.message : 'Failed to resolve dispute');
}
}

@ -386,6 +386,93 @@ export interface ExperienceConfig {
badges_enabled?: boolean;
}
// ============================================================================
// Match Disputes
// ============================================================================
export type DisputeStatus = 'pending' | 'resolved' | 'rejected';
export interface MatchDispute {
dispute_id: number;
competition_match_id: number;
raised_by_app_user_id: number;
raised_by_display_name: string;
reason: string;
evidence_url: string | null;
status: DisputeStatus;
resolution_notes: string | null;
resolved_by_app_user_id: number | null;
resolved_at: string | null;
created_at: string;
// Joined data
match?: CompetitionMatch;
}
export interface CreateDisputeRequest {
reason: string;
evidence_url?: string | null;
}
export interface ResolveDisputeRequest {
status: 'resolved' | 'rejected';
resolution_notes?: string | null;
correct_winner_participant_id?: number | null;
}
// ============================================================================
// Competition Series (Recurring)
// ============================================================================
export type SeriesScope = 'platform' | 'facility' | 'user';
export type SeriesStatus = 'active' | 'paused' | 'cancelled';
export interface CarryOverPolicy {
carry_participants?: boolean;
carry_seeds?: boolean;
reset_standings?: boolean;
}
export interface CompetitionSeries {
series_id: number;
scope: SeriesScope;
facility_id: number | null;
owner_app_user_id: number | null;
template_id: number;
timezone: string;
rrule: string;
next_run_at: string | null;
auto_publish: boolean;
carry_over_policy: CarryOverPolicy;
status: SeriesStatus;
created_by_app_user_id: number;
created_at: string;
// Joined data
template_name?: string;
instance_count?: number;
}
export interface CreateSeriesRequest {
scope?: SeriesScope;
template_id: number;
timezone: string;
rrule: string;
next_run_at: string;
auto_publish?: boolean;
carry_over_policy?: CarryOverPolicy;
}
export interface UpdateSeriesRequest {
rrule?: string;
next_run_at?: string;
auto_publish?: boolean;
carry_over_policy?: CarryOverPolicy;
status?: SeriesStatus;
}
export interface SeriesListFilters {
status?: SeriesStatus;
}
// ============================================================================
// Error Types (RFC-7807)
// ============================================================================

Loading…
Cancel
Save