diff --git a/apps/web/app/(admin)/tournaments/[id]/page.tsx b/apps/web/app/(admin)/tournaments/[id]/page.tsx new file mode 100644 index 0000000..58943a5 --- /dev/null +++ b/apps/web/app/(admin)/tournaments/[id]/page.tsx @@ -0,0 +1,762 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { TournamentForm } from "@/components/tournaments/tournament-form"; +import { BracketView } from "@/components/tournaments/bracket-view"; +import { InscriptionsList } from "@/components/tournaments/inscriptions-list"; +import { MatchScoreDialog } from "@/components/tournaments/match-score-dialog"; +import { cn, formatCurrency, formatDate } from "@/lib/utils"; + +interface Client { + id: string; + firstName: string; + lastName: string; + email: string | null; + phone: string | null; + level: string | null; +} + +interface Inscription { + id: string; + clientId: string; + partnerId: string | null; + teamName: string | null; + seedNumber: number | null; + isPaid: boolean; + paidAmount: number; + registeredAt: string; + client: Client; + partner?: Client | null; +} + +interface Match { + id: string; + round: number; + position: number; + status: string; + team1Players: string[]; + team2Players: string[]; + team1Score: number[] | null; + team2Score: number[] | null; + winnerId: string | null; + scheduledAt: string | null; + court: { + id: string; + name: string; + } | null; +} + +interface Tournament { + id: string; + name: string; + description: string | null; + type: string; + status: string; + startDate: string; + endDate: string | null; + maxPlayers: number | null; + entryFee: string | number; + rules: string | null; + isPublic: boolean; + settings: { + tournamentFormat?: string; + category?: string | null; + } | null; + site: { + id: string; + name: string; + slug: string; + address: string | null; + phone: string | null; + email: string | null; + }; + inscriptions: Inscription[]; + matches: Match[]; + _count: { + inscriptions: number; + matches: number; + }; +} + +const tabs = [ + { id: "overview", label: "Resumen" }, + { id: "inscriptions", label: "Inscripciones" }, + { id: "bracket", label: "Bracket" }, +]; + +const statusConfig: Record = { + DRAFT: { + label: "Borrador", + className: "bg-gray-100 text-gray-700 border-gray-300", + }, + REGISTRATION_OPEN: { + label: "Inscripciones Abiertas", + className: "bg-green-100 text-green-700 border-green-300", + }, + REGISTRATION_CLOSED: { + label: "Inscripciones Cerradas", + className: "bg-yellow-100 text-yellow-700 border-yellow-300", + }, + IN_PROGRESS: { + label: "En Progreso", + className: "bg-blue-100 text-blue-700 border-blue-300", + }, + COMPLETED: { + label: "Finalizado", + className: "bg-purple-100 text-purple-700 border-purple-300", + }, + CANCELLED: { + label: "Cancelado", + className: "bg-red-100 text-red-700 border-red-300", + }, +}; + +const typeLabels: Record = { + BRACKET: "Eliminacion Directa", + AMERICANO: "Americano", + MEXICANO: "Mexicano", + ROUND_ROBIN: "Round Robin", + LEAGUE: "Liga", +}; + +export default function TournamentDetailPage() { + const params = useParams(); + const router = useRouter(); + const tournamentId = params.id as string; + + const [tournament, setTournament] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState("overview"); + const [showEditForm, setShowEditForm] = useState(false); + const [actionLoading, setActionLoading] = useState(null); + const [selectedMatch, setSelectedMatch] = useState(null); + + // Build players map for bracket view + const playersMap = useMemo(() => { + const map = new Map(); + if (tournament) { + tournament.inscriptions.forEach((inscription) => { + map.set(inscription.client.id, inscription.client); + if (inscription.partner) { + map.set(inscription.partner.id, inscription.partner); + } + }); + } + return map; + }, [tournament]); + + const fetchTournament = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await fetch(`/api/tournaments/${tournamentId}`); + if (!response.ok) { + if (response.status === 404) { + throw new Error("Torneo no encontrado"); + } + throw new Error("Error al cargar el torneo"); + } + const data = await response.json(); + setTournament(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Error desconocido"); + } finally { + setLoading(false); + } + }, [tournamentId]); + + useEffect(() => { + fetchTournament(); + }, [fetchTournament]); + + const handleStatusChange = async (newStatus: string) => { + if (!tournament) return; + setActionLoading("status"); + try { + const response = await fetch(`/api/tournaments/${tournamentId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: newStatus }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Error al actualizar estado"); + } + + await fetchTournament(); + } catch (err) { + alert(err instanceof Error ? err.message : "Error desconocido"); + } finally { + setActionLoading(null); + } + }; + + const handleGenerateBracket = async () => { + if (!tournament) return; + if (tournament.inscriptions.length < 2) { + alert("Se necesitan al menos 2 equipos para generar el bracket"); + return; + } + if (!confirm("¿Generar el bracket? Esto iniciara el torneo y cerrara las inscripciones.")) { + return; + } + + setActionLoading("bracket"); + try { + const response = await fetch(`/api/tournaments/${tournamentId}/generate-bracket`, { + method: "POST", + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Error al generar bracket"); + } + + await fetchTournament(); + setActiveTab("bracket"); + } catch (err) { + alert(err instanceof Error ? err.message : "Error desconocido"); + } finally { + setActionLoading(null); + } + }; + + const handleDelete = async () => { + if (!tournament) return; + if (tournament.status !== "DRAFT") { + alert("Solo se pueden eliminar torneos en estado Borrador"); + return; + } + if (!confirm("¿Estas seguro de eliminar este torneo? Esta accion no se puede deshacer.")) { + return; + } + + setActionLoading("delete"); + try { + const response = await fetch(`/api/tournaments/${tournamentId}`, { + method: "DELETE", + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Error al eliminar torneo"); + } + + router.push("/tournaments"); + } catch (err) { + alert(err instanceof Error ? err.message : "Error desconocido"); + } finally { + setActionLoading(null); + } + }; + + const handleUpdateTournament = async (data: { + name: string; + description: string; + date: string; + endDate: string; + type: string; + category: string; + maxTeams: number; + price: number; + siteId: string; + }) => { + setActionLoading("edit"); + try { + const response = await fetch(`/api/tournaments/${tournamentId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: data.name, + description: data.description, + date: new Date(data.date).toISOString(), + endDate: data.endDate ? new Date(data.endDate).toISOString() : null, + type: data.type, + category: data.category || null, + maxTeams: data.maxTeams, + price: data.price, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Error al actualizar torneo"); + } + + setShowEditForm(false); + await fetchTournament(); + } catch (err) { + throw err; + } finally { + setActionLoading(null); + } + }; + + const handleRemoveInscription = async (inscriptionId: string) => { + try { + const response = await fetch( + `/api/tournaments/${tournamentId}/inscriptions/${inscriptionId}`, + { method: "DELETE" } + ); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Error al eliminar inscripcion"); + } + + await fetchTournament(); + } catch (err) { + alert(err instanceof Error ? err.message : "Error desconocido"); + } + }; + + const handleTogglePaid = async (inscriptionId: string, isPaid: boolean) => { + try { + const response = await fetch( + `/api/tournaments/${tournamentId}/inscriptions/${inscriptionId}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isPaid }), + } + ); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Error al actualizar pago"); + } + + await fetchTournament(); + } catch (err) { + alert(err instanceof Error ? err.message : "Error desconocido"); + } + }; + + const handleSaveMatchScore = async ( + matchId: string, + data: { + score1: number[]; + score2: number[]; + winnerId: string; + status: string; + } + ) => { + const response = await fetch( + `/api/tournaments/${tournamentId}/matches/${matchId}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Error al guardar resultado"); + } + + setSelectedMatch(null); + await fetchTournament(); + }; + + if (loading) { + return ( +
+
+
+

Cargando torneo...

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + if (!tournament) return null; + + const status = statusConfig[tournament.status] || statusConfig.DRAFT; + const typeLabel = typeLabels[tournament.type] || tournament.type; + const entryFee = + typeof tournament.entryFee === "string" + ? parseFloat(tournament.entryFee) + : tournament.entryFee; + const canEdit = ["DRAFT", "REGISTRATION_OPEN"].includes(tournament.status); + const canGenerateBracket = + tournament.status === "REGISTRATION_OPEN" && tournament.inscriptions.length >= 2; + const canOpenRegistration = tournament.status === "DRAFT"; + const canDelete = tournament.status === "DRAFT"; + + return ( +
+ {/* Header */} +
+
+
+ +

{tournament.name}

+ + {status.label} + +
+ {tournament.description && ( +

{tournament.description}

+ )} +
+ + {/* Actions */} +
+ {canEdit && ( + + )} + + {canOpenRegistration && ( + + )} + + {canGenerateBracket && ( + + )} + + {canDelete && ( + + )} +
+
+ + {/* Edit Form Modal */} + {showEditForm && ( +
+
+ setShowEditForm(false)} + isLoading={actionLoading === "edit"} + mode="edit" + /> +
+
+ )} + + {/* Match Score Dialog */} + {selectedMatch && ( + setSelectedMatch(null)} + /> + )} + + {/* Tabs */} +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ + {/* Tab Content */} +
+ {/* Overview Tab */} + {activeTab === "overview" && ( +
+ {/* Tournament Info */} + + + Informacion del Torneo + + +
+
+

Tipo

+

{typeLabel}

+
+
+

Categoria

+

+ {tournament.settings?.category || "Sin categoria"} +

+
+
+

Fecha de Inicio

+

+ {formatDate(tournament.startDate)} +

+
+
+

Fecha de Fin

+

+ {tournament.endDate ? formatDate(tournament.endDate) : "-"} +

+
+
+

Precio Inscripcion

+

+ {entryFee > 0 ? formatCurrency(entryFee) : "Gratis"} +

+
+
+

Maximo Equipos

+

+ {tournament.maxPlayers || "Sin limite"} +

+
+
+ + {/* Site Info */} +
+

Sede

+
+

{tournament.site.name}

+ {tournament.site.address && ( +

{tournament.site.address}

+ )} + {tournament.site.phone && ( +

{tournament.site.phone}

+ )} +
+
+
+
+ + {/* Stats */} +
+ + +
+

+ {tournament._count.inscriptions} +

+

+ {tournament.maxPlayers + ? `de ${tournament.maxPlayers} equipos` + : "equipos inscritos"} +

+
+ {tournament.maxPlayers && ( +
+
+
+
+
+ )} + + + + {tournament.matches.length > 0 && ( + + +
+

+ {tournament.matches.filter((m) => m.status === "COMPLETED").length} +

+

+ de {tournament.matches.length} partidos jugados +

+
+
+
+ )} +
+
+ )} + + {/* Inscriptions Tab */} + {activeTab === "inscriptions" && ( + + + + + + )} + + {/* Bracket Tab */} + {activeTab === "bracket" && ( + + + setSelectedMatch(match) + : undefined + } + /> + + + )} +
+
+ ); +} diff --git a/apps/web/app/(admin)/tournaments/page.tsx b/apps/web/app/(admin)/tournaments/page.tsx new file mode 100644 index 0000000..df2637e --- /dev/null +++ b/apps/web/app/(admin)/tournaments/page.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { TournamentCard } from "@/components/tournaments/tournament-card"; +import { TournamentForm } from "@/components/tournaments/tournament-form"; +import { cn } from "@/lib/utils"; + +interface Tournament { + id: string; + name: string; + description: string | null; + type: string; + status: string; + startDate: string; + endDate: string | null; + maxPlayers: number | null; + entryFee: number | string; + settings: { + tournamentFormat?: string; + category?: string | null; + } | null; + site: { + id: string; + name: string; + }; + _count: { + inscriptions: number; + matches: number; + }; +} + +const statusFilters = [ + { value: "", label: "Todos" }, + { value: "DRAFT", label: "Borrador" }, + { value: "REGISTRATION_OPEN", label: "Inscripciones Abiertas" }, + { value: "IN_PROGRESS", label: "En Progreso" }, + { value: "COMPLETED", label: "Finalizados" }, + { value: "CANCELLED", label: "Cancelados" }, +]; + +export default function TournamentsPage() { + const router = useRouter(); + const [tournaments, setTournaments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [showForm, setShowForm] = useState(false); + const [formLoading, setFormLoading] = useState(false); + + const fetchTournaments = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (statusFilter) { + params.append("status", statusFilter); + } + + const response = await fetch(`/api/tournaments?${params.toString()}`); + if (!response.ok) { + throw new Error("Error al cargar torneos"); + } + const data = await response.json(); + setTournaments(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Error desconocido"); + } finally { + setLoading(false); + } + }, [statusFilter]); + + useEffect(() => { + fetchTournaments(); + }, [fetchTournaments]); + + // Filter tournaments by search query (client-side) + const filteredTournaments = tournaments.filter((tournament) => { + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + return ( + tournament.name.toLowerCase().includes(query) || + tournament.description?.toLowerCase().includes(query) || + tournament.site.name.toLowerCase().includes(query) + ); + }); + + const handleCreateTournament = async (data: { + name: string; + description: string; + date: string; + endDate: string; + type: string; + category: string; + maxTeams: number; + price: number; + siteId: string; + }) => { + setFormLoading(true); + try { + const response = await fetch("/api/tournaments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...data, + date: new Date(data.date).toISOString(), + endDate: data.endDate ? new Date(data.endDate).toISOString() : undefined, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Error al crear torneo"); + } + + const newTournament = await response.json(); + setShowForm(false); + router.push(`/tournaments/${newTournament.id}`); + } catch (err) { + throw err; + } finally { + setFormLoading(false); + } + }; + + const handleTournamentClick = (tournamentId: string) => { + router.push(`/tournaments/${tournamentId}`); + }; + + return ( +
+ {/* Header */} +
+
+

Torneos

+

+ Gestiona torneos y competiciones de tu club +

+
+ +
+ + {/* Filters */} +
+ {/* Search */} +
+ setSearchQuery(e.target.value)} + className="w-full" + /> +
+ + {/* Status Filter */} +
+ {statusFilters.map((filter) => ( + + ))} +
+
+ + {/* Create Form Modal */} + {showForm && ( +
+
+ setShowForm(false)} + isLoading={formLoading} + mode="create" + /> +
+
+ )} + + {/* Loading State */} + {loading && ( +
+
+
+

Cargando torneos...

+
+
+ )} + + {/* Error State */} + {error && !loading && ( +
+
+

{error}

+ +
+
+ )} + + {/* Empty State */} + {!loading && !error && filteredTournaments.length === 0 && ( +
+
+ + + +

No hay torneos

+

+ {searchQuery || statusFilter + ? "Intenta con otros filtros" + : "Crea tu primer torneo para comenzar"} +

+ {!searchQuery && !statusFilter && ( + + )} +
+
+ )} + + {/* Tournaments Grid */} + {!loading && !error && filteredTournaments.length > 0 && ( +
+ {filteredTournaments.map((tournament) => ( + handleTournamentClick(tournament.id)} + /> + ))} +
+ )} + + {/* Results count */} + {!loading && !error && filteredTournaments.length > 0 && ( +
+ Mostrando {filteredTournaments.length} de {tournaments.length} torneos +
+ )} +
+ ); +} diff --git a/apps/web/components/tournaments/bracket-view.tsx b/apps/web/components/tournaments/bracket-view.tsx new file mode 100644 index 0000000..606fef4 --- /dev/null +++ b/apps/web/components/tournaments/bracket-view.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@/lib/utils"; + +interface Player { + id: string; + firstName: string; + lastName: string; + level?: string | null; +} + +interface Match { + id: string; + round: number; + position: number; + status: string; + team1Players: string[]; + team2Players: string[]; + team1Score: number[] | null; + team2Score: number[] | null; + winnerId: string | null; + scheduledAt: string | null; + court: { + id: string; + name: string; + } | null; +} + +interface BracketViewProps { + matches: Match[]; + players: Map; + onMatchClick?: (match: Match) => void; +} + +const statusColors: Record = { + SCHEDULED: "bg-gray-50 border-gray-300", + IN_PROGRESS: "bg-blue-50 border-blue-400", + COMPLETED: "bg-green-50 border-green-300", + CANCELLED: "bg-red-50 border-red-300", + WALKOVER: "bg-yellow-50 border-yellow-300", +}; + +function getRoundName(round: number, totalRounds: number): string { + const roundsFromEnd = totalRounds - round + 1; + switch (roundsFromEnd) { + case 1: + return "Final"; + case 2: + return "Semifinal"; + case 3: + return "Cuartos"; + case 4: + return "Octavos"; + case 5: + return "Dieciseisavos"; + default: + return `Ronda ${round}`; + } +} + +function formatScore(scores: number[] | null): string { + if (!scores || scores.length === 0) return "-"; + return scores.join("-"); +} + +function TeamSlot({ + playerIds, + players, + score, + isWinner, + isEmpty, +}: { + playerIds: string[]; + players: Map; + score: number[] | null; + isWinner: boolean; + isEmpty: boolean; +}) { + if (isEmpty || playerIds.length === 0) { + return ( +
+ TBD + - +
+ ); + } + + const playerNames = playerIds + .map((id) => { + const player = players.get(id); + if (!player) return "Desconocido"; + return `${player.firstName} ${player.lastName.charAt(0)}.`; + }) + .join(" / "); + + return ( +
+ + {playerNames} + + + {formatScore(score)} + +
+ ); +} + +function MatchBox({ + match, + players, + onClick, +}: { + match: Match; + players: Map; + onClick?: () => void; +}) { + const team1IsWinner = + match.winnerId && match.team1Players.includes(match.winnerId); + const team2IsWinner = + match.winnerId && match.team2Players.includes(match.winnerId); + + const isClickable = + match.status !== "COMPLETED" && + match.status !== "WALKOVER" && + match.team1Players.length > 0 && + match.team2Players.length > 0; + + return ( +
+ {/* Match Info Header */} +
+ Partido {match.position} + {match.court && {match.court.name}} +
+ + {/* Teams */} +
+ +
vs
+ +
+ + {/* Status */} +
+ + {match.status === "SCHEDULED" && "Programado"} + {match.status === "IN_PROGRESS" && "En Juego"} + {match.status === "COMPLETED" && "Finalizado"} + {match.status === "WALKOVER" && "Walkover"} + {match.status === "CANCELLED" && "Cancelado"} + +
+
+ ); +} + +export function BracketView({ matches, players, onMatchClick }: BracketViewProps) { + // Group matches by round + const matchesByRound = useMemo(() => { + const grouped = new Map(); + matches.forEach((match) => { + const roundMatches = grouped.get(match.round) || []; + roundMatches.push(match); + grouped.set(match.round, roundMatches); + }); + // Sort matches in each round by position + grouped.forEach((roundMatches) => { + roundMatches.sort((a, b) => a.position - b.position); + }); + return grouped; + }, [matches]); + + const totalRounds = Math.max(...Array.from(matchesByRound.keys()), 0); + const rounds = Array.from({ length: totalRounds }, (_, i) => i + 1); + + if (matches.length === 0) { + return ( +
+
+ + + +

No hay bracket generado

+

Genera el bracket para comenzar el torneo

+
+
+ ); + } + + return ( +
+
+ {rounds.map((round) => { + const roundMatches = matchesByRound.get(round) || []; + const roundName = getRoundName(round, totalRounds); + + // Calculate vertical spacing for bracket alignment + const matchesInPreviousRound = round > 1 ? matchesByRound.get(round - 1)?.length || 0 : roundMatches.length * 2; + const spacingMultiplier = Math.pow(2, round - 1); + + return ( +
+ {/* Round Header */} +
+

{roundName}

+

+ {roundMatches.length} {roundMatches.length === 1 ? "partido" : "partidos"} +

+
+ + {/* Matches */} +
+ {roundMatches.map((match) => ( + onMatchClick?.(match)} + /> + ))} +
+
+ ); + })} +
+ + {/* Legend */} +
+
+
+ Programado +
+
+
+ En Juego +
+
+
+ Finalizado +
+
+
+ Walkover +
+
+
+ ); +} diff --git a/apps/web/components/tournaments/inscriptions-list.tsx b/apps/web/components/tournaments/inscriptions-list.tsx new file mode 100644 index 0000000..aa3dc52 --- /dev/null +++ b/apps/web/components/tournaments/inscriptions-list.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn, formatCurrency } from "@/lib/utils"; + +interface Client { + id: string; + firstName: string; + lastName: string; + email: string | null; + phone: string | null; + level: string | null; +} + +interface Inscription { + id: string; + clientId: string; + partnerId: string | null; + teamName: string | null; + seedNumber: number | null; + isPaid: boolean; + paidAmount: number; + registeredAt: string; + client: Client; + partner?: Client | null; +} + +interface InscriptionsListProps { + inscriptions: Inscription[]; + entryFee: number; + onRemove?: (inscriptionId: string) => Promise; + onTogglePaid?: (inscriptionId: string, isPaid: boolean) => Promise; + canEdit?: boolean; +} + +export function InscriptionsList({ + inscriptions, + entryFee, + onRemove, + onTogglePaid, + canEdit = false, +}: InscriptionsListProps) { + const [loadingId, setLoadingId] = useState(null); + + const handleRemove = async (inscriptionId: string) => { + if (!onRemove) return; + if (!confirm("¿Estas seguro de eliminar esta inscripcion?")) return; + + setLoadingId(inscriptionId); + try { + await onRemove(inscriptionId); + } finally { + setLoadingId(null); + } + }; + + const handleTogglePaid = async (inscription: Inscription) => { + if (!onTogglePaid) return; + setLoadingId(inscription.id); + try { + await onTogglePaid(inscription.id, !inscription.isPaid); + } finally { + setLoadingId(null); + } + }; + + const getTeamName = (inscription: Inscription): string => { + if (inscription.teamName) return inscription.teamName; + const p1 = `${inscription.client.firstName} ${inscription.client.lastName}`; + if (inscription.partner) { + return `${p1} / ${inscription.partner.firstName} ${inscription.partner.lastName}`; + } + return p1; + }; + + const getPlayerNames = (inscription: Inscription): string => { + const p1 = `${inscription.client.firstName} ${inscription.client.lastName}`; + if (inscription.partner) { + return `${p1}, ${inscription.partner.firstName} ${inscription.partner.lastName}`; + } + return p1; + }; + + const paidCount = inscriptions.filter((i) => i.isPaid).length; + const totalCollected = inscriptions + .filter((i) => i.isPaid) + .reduce((sum, i) => sum + (i.paidAmount || entryFee), 0); + + if (inscriptions.length === 0) { + return ( +
+ + + +

No hay inscripciones todavia

+

Abre las inscripciones para recibir equipos

+
+ ); + } + + return ( +
+ {/* Summary */} +
+
+

Total Equipos

+

+ {inscriptions.length} +

+
+
+

Pagados

+

+ {paidCount} / {inscriptions.length} +

+
+
+

Recaudado

+

+ {formatCurrency(totalCollected)} +

+
+
+ + {/* Table */} +
+ + + + + + + + {canEdit && ( + + )} + + + + {inscriptions.map((inscription, index) => ( + + + + + + {canEdit && ( + + )} + + ))} + +
+ # + + Equipo / Jugadores + + Contacto + + Estado Pago + + Acciones +
+ {inscription.seedNumber || index + 1} + +
+

+ {getTeamName(inscription)} +

+

+ {getPlayerNames(inscription)} +

+
+
+
+ {inscription.client.email && ( +

{inscription.client.email}

+ )} + {inscription.client.phone && ( +

{inscription.client.phone}

+ )} +
+
+ + + +
+
+
+ ); +} diff --git a/apps/web/components/tournaments/match-score-dialog.tsx b/apps/web/components/tournaments/match-score-dialog.tsx new file mode 100644 index 0000000..94d78a5 --- /dev/null +++ b/apps/web/components/tournaments/match-score-dialog.tsx @@ -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; + onSave: (matchId: string, data: { + score1: number[]; + score2: number[]; + winnerId: string; + status: string; + }) => Promise; + onClose: () => void; +} + +export function MatchScoreDialog({ + match, + players, + onSave, + onClose, +}: MatchScoreDialogProps) { + const [sets, setSets] = useState>(() => { + // 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(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 ( +
+ + +
+ Resultado del Partido + +
+

+ Ronda {match.round}, Partido {match.position} +

+
+ + + {error && ( +
+ {error} +
+ )} + + {/* Score Grid */} +
+ {/* Header */} +
+
+
Set 1
+
Set 2
+
Set 3
+
+ + {/* Team 1 */} +
+ + {sets.map((set, i) => ( + handleSetChange(i, "team1", e.target.value)} + onBlur={handleAutoSelectWinner} + className="text-center px-2" + /> + ))} +
+ + {/* Team 2 */} +
+ + {sets.map((set, i) => ( + handleSetChange(i, "team2", e.target.value)} + onBlur={handleAutoSelectWinner} + className="text-center px-2" + /> + ))} +
+
+ + {/* Winner Selection Info */} +
+ {selectedWinner ? ( + + Ganador:{" "} + + {selectedWinner === "team1" ? team1Name : team2Name} + + + ) : ( + Haz clic en el equipo ganador o ingresa el marcador + )} +
+
+ + + + + +
+
+ ); +} diff --git a/apps/web/components/tournaments/tournament-card.tsx b/apps/web/components/tournaments/tournament-card.tsx new file mode 100644 index 0000000..fe10d15 --- /dev/null +++ b/apps/web/components/tournaments/tournament-card.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { formatCurrency, formatDate, cn } from "@/lib/utils"; + +interface Tournament { + id: string; + name: string; + description: string | null; + type: string; + status: string; + startDate: string; + endDate: string | null; + maxPlayers: number | null; + entryFee: number | string; + settings: { + tournamentFormat?: string; + category?: string | null; + } | null; + site: { + id: string; + name: string; + }; + _count: { + inscriptions: number; + matches: number; + }; +} + +interface TournamentCardProps { + tournament: Tournament; + onClick?: () => void; +} + +const statusConfig: Record = { + DRAFT: { + label: "Borrador", + className: "bg-gray-100 text-gray-700 border-gray-300", + }, + REGISTRATION_OPEN: { + label: "Inscripciones Abiertas", + className: "bg-green-100 text-green-700 border-green-300", + }, + REGISTRATION_CLOSED: { + label: "Inscripciones Cerradas", + className: "bg-yellow-100 text-yellow-700 border-yellow-300", + }, + IN_PROGRESS: { + label: "En Progreso", + className: "bg-blue-100 text-blue-700 border-blue-300", + }, + COMPLETED: { + label: "Finalizado", + className: "bg-purple-100 text-purple-700 border-purple-300", + }, + CANCELLED: { + label: "Cancelado", + className: "bg-red-100 text-red-700 border-red-300", + }, +}; + +const typeLabels: Record = { + BRACKET: "Eliminacion Directa", + AMERICANO: "Americano", + MEXICANO: "Mexicano", + ROUND_ROBIN: "Round Robin", + LEAGUE: "Liga", +}; + +export function TournamentCard({ tournament, onClick }: TournamentCardProps) { + const status = statusConfig[tournament.status] || statusConfig.DRAFT; + const typeLabel = typeLabels[tournament.type] || tournament.type; + const category = tournament.settings?.category; + const entryFee = typeof tournament.entryFee === 'string' + ? parseFloat(tournament.entryFee) + : tournament.entryFee; + + return ( + + +
+ {tournament.name} + + {status.label} + +
+ {tournament.description && ( +

+ {tournament.description} +

+ )} +
+ + {/* Type and Category */} +
+ + {typeLabel} + + {category && ( + + {category} + + )} +
+ + {/* Date */} +
+ + + + {formatDate(tournament.startDate)} + {tournament.endDate && ( + <> + - + {formatDate(tournament.endDate)} + + )} +
+ + {/* Inscriptions and Price */} +
+
+ + + + + {tournament._count.inscriptions} + {tournament.maxPlayers && ` / ${tournament.maxPlayers}`} + {" equipos"} + +
+ + {entryFee > 0 ? formatCurrency(entryFee) : "Gratis"} + +
+ + {/* Site name */} +
+ {tournament.site.name} +
+
+
+ ); +} diff --git a/apps/web/components/tournaments/tournament-form.tsx b/apps/web/components/tournaments/tournament-form.tsx new file mode 100644 index 0000000..8a52a84 --- /dev/null +++ b/apps/web/components/tournaments/tournament-form.tsx @@ -0,0 +1,342 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"; + +interface TournamentFormData { + name: string; + description: string; + date: string; + endDate: string; + type: string; + category: string; + maxTeams: number; + price: number; + siteId: string; +} + +interface Site { + id: string; + name: string; +} + +interface TournamentFormProps { + initialData?: Partial; + onSubmit: (data: TournamentFormData) => Promise; + onCancel: () => void; + isLoading?: boolean; + mode?: "create" | "edit"; +} + +const tournamentTypes = [ + { value: "SINGLE_ELIMINATION", label: "Eliminacion Directa" }, + { value: "DOUBLE_ELIMINATION", label: "Doble Eliminacion" }, + { value: "ROUND_ROBIN", label: "Round Robin" }, + { value: "LEAGUE", label: "Liga" }, +]; + +const categories = [ + { value: "", label: "Sin categoria" }, + { value: "1ra", label: "1ra Categoria" }, + { value: "2da", label: "2da Categoria" }, + { value: "3ra", label: "3ra Categoria" }, + { value: "4ta", label: "4ta Categoria" }, + { value: "5ta", label: "5ta Categoria" }, + { value: "Mixto", label: "Mixto" }, + { value: "Femenil", label: "Femenil" }, + { value: "Varonil", label: "Varonil" }, + { value: "Open", label: "Open" }, +]; + +export function TournamentForm({ + initialData, + onSubmit, + onCancel, + isLoading = false, + mode = "create", +}: TournamentFormProps) { + const [sites, setSites] = useState([]); + const [loadingSites, setLoadingSites] = useState(true); + const [formData, setFormData] = useState({ + name: initialData?.name || "", + description: initialData?.description || "", + date: initialData?.date || "", + endDate: initialData?.endDate || "", + type: initialData?.type || "SINGLE_ELIMINATION", + category: initialData?.category || "", + maxTeams: initialData?.maxTeams || 16, + price: initialData?.price || 0, + siteId: initialData?.siteId || "", + }); + const [errors, setErrors] = useState>({}); + + // Fetch sites + useEffect(() => { + async function fetchSites() { + try { + const response = await fetch("/api/sites"); + if (response.ok) { + const data = await response.json(); + setSites(data); + // Set default site if not set + if (!formData.siteId && data.length > 0) { + setFormData(prev => ({ ...prev, siteId: data[0].id })); + } + } + } catch (error) { + console.error("Error fetching sites:", error); + } finally { + setLoadingSites(false); + } + } + fetchSites(); + }, []); + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value, type } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: type === "number" ? parseFloat(value) || 0 : value, + })); + // Clear error when field is modified + if (errors[name]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[name]; + return newErrors; + }); + } + }; + + const validate = (): boolean => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = "El nombre es requerido"; + } + if (!formData.date) { + newErrors.date = "La fecha de inicio es requerida"; + } + if (!formData.siteId) { + newErrors.siteId = "Selecciona una sede"; + } + if (formData.maxTeams < 2) { + newErrors.maxTeams = "Minimo 2 equipos"; + } + if (formData.price < 0) { + newErrors.price = "El precio no puede ser negativo"; + } + if (formData.endDate && new Date(formData.endDate) < new Date(formData.date)) { + newErrors.endDate = "La fecha de fin debe ser posterior a la de inicio"; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + await onSubmit(formData); + }; + + return ( + +
+ + + {mode === "create" ? "Nuevo Torneo" : "Editar Torneo"} + + + + {/* Name */} +
+ + + {errors.name && ( +

{errors.name}

+ )} +
+ + {/* Description */} +
+ +