feat: complete competition manager UI with scheduling, results, and series
continuous-integration/drone/push Build is passing
Details
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 hooksmaster
parent
5e020aa4eb
commit
2a71371683
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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} />;
|
||||
}
|
||||
Loading…
Reference in New Issue