feat(tournaments): add tournament management UI
Add complete tournament management interface including: - Tournament card component with status badges and info display - Tournament form for creating/editing tournaments - Bracket view for single elimination visualization - Inscriptions list with payment tracking - Match score dialog for entering results - Tournaments list page with filtering and search - Tournament detail page with tabs (Overview, Inscriptions, Bracket) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
313
apps/web/components/tournaments/match-score-dialog.tsx
Normal file
313
apps/web/components/tournaments/match-score-dialog.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Player {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
interface Match {
|
||||
id: string;
|
||||
round: number;
|
||||
position: number;
|
||||
status: string;
|
||||
team1Players: string[];
|
||||
team2Players: string[];
|
||||
team1Score: number[] | null;
|
||||
team2Score: number[] | null;
|
||||
winnerId: string | null;
|
||||
}
|
||||
|
||||
interface MatchScoreDialogProps {
|
||||
match: Match;
|
||||
players: Map<string, Player>;
|
||||
onSave: (matchId: string, data: {
|
||||
score1: number[];
|
||||
score2: number[];
|
||||
winnerId: string;
|
||||
status: string;
|
||||
}) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MatchScoreDialog({
|
||||
match,
|
||||
players,
|
||||
onSave,
|
||||
onClose,
|
||||
}: MatchScoreDialogProps) {
|
||||
const [sets, setSets] = useState<Array<{ team1: string; team2: string }>>(() => {
|
||||
// Initialize with existing scores or 3 empty sets
|
||||
const existingScore1 = match.team1Score || [];
|
||||
const existingScore2 = match.team2Score || [];
|
||||
const initialSets = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
initialSets.push({
|
||||
team1: existingScore1[i]?.toString() || "",
|
||||
team2: existingScore2[i]?.toString() || "",
|
||||
});
|
||||
}
|
||||
return initialSets;
|
||||
});
|
||||
const [selectedWinner, setSelectedWinner] = useState<"team1" | "team2" | null>(
|
||||
() => {
|
||||
if (match.winnerId) {
|
||||
if (match.team1Players.includes(match.winnerId)) return "team1";
|
||||
if (match.team2Players.includes(match.winnerId)) return "team2";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const getTeamName = (playerIds: string[]): string => {
|
||||
return playerIds
|
||||
.map((id) => {
|
||||
const player = players.get(id);
|
||||
if (!player) return "Desconocido";
|
||||
return `${player.firstName} ${player.lastName.charAt(0)}.`;
|
||||
})
|
||||
.join(" / ");
|
||||
};
|
||||
|
||||
const team1Name = getTeamName(match.team1Players);
|
||||
const team2Name = getTeamName(match.team2Players);
|
||||
|
||||
const handleSetChange = (setIndex: number, team: "team1" | "team2", value: string) => {
|
||||
// Only allow numbers
|
||||
if (value && !/^\d*$/.test(value)) return;
|
||||
|
||||
const newSets = [...sets];
|
||||
newSets[setIndex] = {
|
||||
...newSets[setIndex],
|
||||
[team]: value,
|
||||
};
|
||||
setSets(newSets);
|
||||
};
|
||||
|
||||
const calculateWinnerFromScore = (): "team1" | "team2" | null => {
|
||||
let team1Sets = 0;
|
||||
let team2Sets = 0;
|
||||
|
||||
sets.forEach((set) => {
|
||||
const t1 = parseInt(set.team1) || 0;
|
||||
const t2 = parseInt(set.team2) || 0;
|
||||
if (t1 > t2) team1Sets++;
|
||||
if (t2 > t1) team2Sets++;
|
||||
});
|
||||
|
||||
if (team1Sets > team2Sets) return "team1";
|
||||
if (team2Sets > team1Sets) return "team2";
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAutoSelectWinner = () => {
|
||||
const winner = calculateWinnerFromScore();
|
||||
if (winner) {
|
||||
setSelectedWinner(winner);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
|
||||
if (!selectedWinner) {
|
||||
setError("Selecciona un ganador");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build scores array (filter out empty sets)
|
||||
const score1: number[] = [];
|
||||
const score2: number[] = [];
|
||||
let hasValidScore = false;
|
||||
|
||||
sets.forEach((set) => {
|
||||
const t1 = set.team1 !== "" ? parseInt(set.team1) : null;
|
||||
const t2 = set.team2 !== "" ? parseInt(set.team2) : null;
|
||||
if (t1 !== null && t2 !== null) {
|
||||
score1.push(t1);
|
||||
score2.push(t2);
|
||||
hasValidScore = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasValidScore) {
|
||||
setError("Ingresa al menos un set con resultado");
|
||||
return;
|
||||
}
|
||||
|
||||
const winnerId =
|
||||
selectedWinner === "team1"
|
||||
? match.team1Players[0]
|
||||
: match.team2Players[0];
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onSave(match.id, {
|
||||
score1,
|
||||
score2,
|
||||
winnerId,
|
||||
status: "COMPLETED",
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Error al guardar");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">Resultado del Partido</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 hover:bg-primary-100 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 text-primary-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-primary-500 mt-1">
|
||||
Ronda {match.round}, Partido {match.position}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Score Grid */}
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-[1fr,repeat(3,48px)] gap-2 text-center text-sm text-primary-500">
|
||||
<div></div>
|
||||
<div>Set 1</div>
|
||||
<div>Set 2</div>
|
||||
<div>Set 3</div>
|
||||
</div>
|
||||
|
||||
{/* Team 1 */}
|
||||
<div className="grid grid-cols-[1fr,repeat(3,48px)] gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedWinner("team1")}
|
||||
className={cn(
|
||||
"text-left px-3 py-2 rounded-md transition-all text-sm",
|
||||
selectedWinner === "team1"
|
||||
? "bg-green-100 text-green-800 ring-2 ring-green-500"
|
||||
: "bg-primary-50 text-primary-700 hover:bg-primary-100"
|
||||
)}
|
||||
>
|
||||
<span className="block truncate font-medium">{team1Name}</span>
|
||||
</button>
|
||||
{sets.map((set, i) => (
|
||||
<Input
|
||||
key={`t1-${i}`}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
value={set.team1}
|
||||
onChange={(e) => handleSetChange(i, "team1", e.target.value)}
|
||||
onBlur={handleAutoSelectWinner}
|
||||
className="text-center px-2"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Team 2 */}
|
||||
<div className="grid grid-cols-[1fr,repeat(3,48px)] gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedWinner("team2")}
|
||||
className={cn(
|
||||
"text-left px-3 py-2 rounded-md transition-all text-sm",
|
||||
selectedWinner === "team2"
|
||||
? "bg-green-100 text-green-800 ring-2 ring-green-500"
|
||||
: "bg-primary-50 text-primary-700 hover:bg-primary-100"
|
||||
)}
|
||||
>
|
||||
<span className="block truncate font-medium">{team2Name}</span>
|
||||
</button>
|
||||
{sets.map((set, i) => (
|
||||
<Input
|
||||
key={`t2-${i}`}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={2}
|
||||
value={set.team2}
|
||||
onChange={(e) => handleSetChange(i, "team2", e.target.value)}
|
||||
onBlur={handleAutoSelectWinner}
|
||||
className="text-center px-2"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Winner Selection Info */}
|
||||
<div className="text-center text-sm text-primary-500">
|
||||
{selectedWinner ? (
|
||||
<span>
|
||||
Ganador:{" "}
|
||||
<span className="font-medium text-green-700">
|
||||
{selectedWinner === "team1" ? team1Name : team2Name}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>Haz clic en el equipo ganador o ingresa el marcador</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-end gap-3 border-t border-primary-200 bg-primary-50 pt-4">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading || !selectedWinner}>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
Guardando...
|
||||
</span>
|
||||
) : (
|
||||
"Guardar Resultado"
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user