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:
762
apps/web/app/(admin)/tournaments/[id]/page.tsx
Normal file
762
apps/web/app/(admin)/tournaments/[id]/page.tsx
Normal file
@@ -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<string, { label: string; className: string }> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<Tournament | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
|
const [showEditForm, setShowEditForm] = useState(false);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||||
|
|
||||||
|
// Build players map for bracket view
|
||||||
|
const playersMap = useMemo(() => {
|
||||||
|
const map = new Map<string, Client>();
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
|
||||||
|
<p className="text-primary-500">Cargando torneo...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center text-red-500">
|
||||||
|
<p className="text-lg font-medium">{error}</p>
|
||||||
|
<Button variant="outline" className="mt-4" onClick={() => router.push("/tournaments")}>
|
||||||
|
Volver a Torneos
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push("/tournaments")}
|
||||||
|
className="p-1"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-2xl font-bold text-primary-800">{tournament.name}</h1>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1 text-sm font-medium rounded-full border",
|
||||||
|
status.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{tournament.description && (
|
||||||
|
<p className="text-primary-600 ml-8">{tournament.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{canEdit && (
|
||||||
|
<Button variant="outline" onClick={() => setShowEditForm(true)}>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canOpenRegistration && (
|
||||||
|
<Button
|
||||||
|
variant="accent"
|
||||||
|
onClick={() => handleStatusChange("REGISTRATION_OPEN")}
|
||||||
|
disabled={actionLoading === "status"}
|
||||||
|
>
|
||||||
|
{actionLoading === "status" ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white mr-2" />
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
Abrir Inscripciones
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canGenerateBracket && (
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerateBracket}
|
||||||
|
disabled={actionLoading === "bracket"}
|
||||||
|
>
|
||||||
|
{actionLoading === "bracket" ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white mr-2" />
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
Generar Bracket
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canDelete && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={actionLoading === "delete"}
|
||||||
|
>
|
||||||
|
{actionLoading === "delete" ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white mr-2" />
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Form Modal */}
|
||||||
|
{showEditForm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 overflow-y-auto">
|
||||||
|
<div className="my-8">
|
||||||
|
<TournamentForm
|
||||||
|
initialData={{
|
||||||
|
name: tournament.name,
|
||||||
|
description: tournament.description || "",
|
||||||
|
date: tournament.startDate.slice(0, 16),
|
||||||
|
endDate: tournament.endDate?.slice(0, 16) || "",
|
||||||
|
type: tournament.settings?.tournamentFormat || "SINGLE_ELIMINATION",
|
||||||
|
category: tournament.settings?.category || "",
|
||||||
|
maxTeams: tournament.maxPlayers || 16,
|
||||||
|
price: entryFee,
|
||||||
|
siteId: tournament.site.id,
|
||||||
|
}}
|
||||||
|
onSubmit={handleUpdateTournament}
|
||||||
|
onCancel={() => setShowEditForm(false)}
|
||||||
|
isLoading={actionLoading === "edit"}
|
||||||
|
mode="edit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Match Score Dialog */}
|
||||||
|
{selectedMatch && (
|
||||||
|
<MatchScoreDialog
|
||||||
|
match={selectedMatch}
|
||||||
|
players={playersMap}
|
||||||
|
onSave={handleSaveMatchScore}
|
||||||
|
onClose={() => setSelectedMatch(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-primary-200">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||||
|
activeTab === tab.id
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-primary-500 hover:text-primary-700 hover:border-primary-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{tab.id === "inscriptions" && (
|
||||||
|
<span className="ml-2 px-2 py-0.5 text-xs bg-primary-100 text-primary-700 rounded-full">
|
||||||
|
{tournament._count.inscriptions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tab.id === "bracket" && tournament.matches.length > 0 && (
|
||||||
|
<span className="ml-2 px-2 py-0.5 text-xs bg-primary-100 text-primary-700 rounded-full">
|
||||||
|
{tournament.matches.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div>
|
||||||
|
{/* Overview Tab */}
|
||||||
|
{activeTab === "overview" && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Tournament Info */}
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Informacion del Torneo</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-primary-500">Tipo</p>
|
||||||
|
<p className="font-medium text-primary-800">{typeLabel}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-primary-500">Categoria</p>
|
||||||
|
<p className="font-medium text-primary-800">
|
||||||
|
{tournament.settings?.category || "Sin categoria"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-primary-500">Fecha de Inicio</p>
|
||||||
|
<p className="font-medium text-primary-800">
|
||||||
|
{formatDate(tournament.startDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-primary-500">Fecha de Fin</p>
|
||||||
|
<p className="font-medium text-primary-800">
|
||||||
|
{tournament.endDate ? formatDate(tournament.endDate) : "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-primary-500">Precio Inscripcion</p>
|
||||||
|
<p className="font-medium text-primary-800">
|
||||||
|
{entryFee > 0 ? formatCurrency(entryFee) : "Gratis"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-primary-500">Maximo Equipos</p>
|
||||||
|
<p className="font-medium text-primary-800">
|
||||||
|
{tournament.maxPlayers || "Sin limite"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Site Info */}
|
||||||
|
<div className="pt-4 border-t border-primary-100">
|
||||||
|
<p className="text-sm text-primary-500 mb-2">Sede</p>
|
||||||
|
<div className="bg-primary-50 rounded-lg p-4">
|
||||||
|
<p className="font-medium text-primary-800">{tournament.site.name}</p>
|
||||||
|
{tournament.site.address && (
|
||||||
|
<p className="text-sm text-primary-600">{tournament.site.address}</p>
|
||||||
|
)}
|
||||||
|
{tournament.site.phone && (
|
||||||
|
<p className="text-sm text-primary-600">{tournament.site.phone}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-4xl font-bold text-primary-800">
|
||||||
|
{tournament._count.inscriptions}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-primary-500">
|
||||||
|
{tournament.maxPlayers
|
||||||
|
? `de ${tournament.maxPlayers} equipos`
|
||||||
|
: "equipos inscritos"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{tournament.maxPlayers && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="h-2 bg-primary-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(
|
||||||
|
(tournament._count.inscriptions / tournament.maxPlayers) * 100,
|
||||||
|
100
|
||||||
|
)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{tournament.matches.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-4xl font-bold text-primary-800">
|
||||||
|
{tournament.matches.filter((m) => m.status === "COMPLETED").length}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-primary-500">
|
||||||
|
de {tournament.matches.length} partidos jugados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inscriptions Tab */}
|
||||||
|
{activeTab === "inscriptions" && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<InscriptionsList
|
||||||
|
inscriptions={tournament.inscriptions}
|
||||||
|
entryFee={entryFee}
|
||||||
|
onRemove={canEdit ? handleRemoveInscription : undefined}
|
||||||
|
onTogglePaid={canEdit ? handleTogglePaid : undefined}
|
||||||
|
canEdit={canEdit}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bracket Tab */}
|
||||||
|
{activeTab === "bracket" && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<BracketView
|
||||||
|
matches={tournament.matches}
|
||||||
|
players={playersMap}
|
||||||
|
onMatchClick={
|
||||||
|
tournament.status === "IN_PROGRESS"
|
||||||
|
? (match) => setSelectedMatch(match)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
284
apps/web/app/(admin)/tournaments/page.tsx
Normal file
284
apps/web/app/(admin)/tournaments/page.tsx
Normal file
@@ -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<Tournament[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-primary-800">Torneos</h1>
|
||||||
|
<p className="mt-1 text-primary-600">
|
||||||
|
Gestiona torneos y competiciones de tu club
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowForm(true)}>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Nuevo Torneo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nombre..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2 sm:pb-0">
|
||||||
|
{statusFilters.map((filter) => (
|
||||||
|
<Button
|
||||||
|
key={filter.value}
|
||||||
|
variant={statusFilter === filter.value ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStatusFilter(filter.value)}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{filter.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Form Modal */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 overflow-y-auto">
|
||||||
|
<div className="my-8">
|
||||||
|
<TournamentForm
|
||||||
|
onSubmit={handleCreateTournament}
|
||||||
|
onCancel={() => setShowForm(false)}
|
||||||
|
isLoading={formLoading}
|
||||||
|
mode="create"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
|
||||||
|
<p className="text-primary-500">Cargando torneos...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center text-red-500">
|
||||||
|
<p>{error}</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
onClick={fetchTournaments}
|
||||||
|
>
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!loading && !error && filteredTournaments.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center text-primary-500">
|
||||||
|
<svg
|
||||||
|
className="w-12 h-12 mx-auto mb-3 text-primary-300"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="font-medium">No hay torneos</p>
|
||||||
|
<p className="text-sm mt-1">
|
||||||
|
{searchQuery || statusFilter
|
||||||
|
? "Intenta con otros filtros"
|
||||||
|
: "Crea tu primer torneo para comenzar"}
|
||||||
|
</p>
|
||||||
|
{!searchQuery && !statusFilter && (
|
||||||
|
<Button className="mt-4" onClick={() => setShowForm(true)}>
|
||||||
|
Crear Torneo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tournaments Grid */}
|
||||||
|
{!loading && !error && filteredTournaments.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{filteredTournaments.map((tournament) => (
|
||||||
|
<TournamentCard
|
||||||
|
key={tournament.id}
|
||||||
|
tournament={tournament}
|
||||||
|
onClick={() => handleTournamentClick(tournament.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results count */}
|
||||||
|
{!loading && !error && filteredTournaments.length > 0 && (
|
||||||
|
<div className="text-center text-sm text-primary-500">
|
||||||
|
Mostrando {filteredTournaments.length} de {tournaments.length} torneos
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
300
apps/web/components/tournaments/bracket-view.tsx
Normal file
300
apps/web/components/tournaments/bracket-view.tsx
Normal file
@@ -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<string, Player>;
|
||||||
|
onMatchClick?: (match: Match) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
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<string, Player>;
|
||||||
|
score: number[] | null;
|
||||||
|
isWinner: boolean;
|
||||||
|
isEmpty: boolean;
|
||||||
|
}) {
|
||||||
|
if (isEmpty || playerIds.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-gray-100 text-gray-400 rounded text-sm italic">
|
||||||
|
<span>TBD</span>
|
||||||
|
<span>-</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerNames = playerIds
|
||||||
|
.map((id) => {
|
||||||
|
const player = players.get(id);
|
||||||
|
if (!player) return "Desconocido";
|
||||||
|
return `${player.firstName} ${player.lastName.charAt(0)}.`;
|
||||||
|
})
|
||||||
|
.join(" / ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between px-3 py-2 rounded text-sm transition-all",
|
||||||
|
isWinner
|
||||||
|
? "bg-green-100 text-green-800 font-medium"
|
||||||
|
: "bg-white text-primary-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-[140px]" title={playerNames}>
|
||||||
|
{playerNames}
|
||||||
|
</span>
|
||||||
|
<span className={cn("font-mono", isWinner && "font-bold")}>
|
||||||
|
{formatScore(score)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchBox({
|
||||||
|
match,
|
||||||
|
players,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
match: Match;
|
||||||
|
players: Map<string, Player>;
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-[220px] rounded-lg border-2 overflow-hidden shadow-sm",
|
||||||
|
statusColors[match.status] || statusColors.SCHEDULED,
|
||||||
|
isClickable && "cursor-pointer hover:shadow-md hover:border-primary-400 transition-all"
|
||||||
|
)}
|
||||||
|
onClick={isClickable ? onClick : undefined}
|
||||||
|
>
|
||||||
|
{/* Match Info Header */}
|
||||||
|
<div className="px-2 py-1 bg-white/50 border-b text-xs text-primary-500 flex justify-between">
|
||||||
|
<span>Partido {match.position}</span>
|
||||||
|
{match.court && <span>{match.court.name}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Teams */}
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
<TeamSlot
|
||||||
|
playerIds={match.team1Players}
|
||||||
|
players={players}
|
||||||
|
score={match.team1Score}
|
||||||
|
isWinner={!!team1IsWinner}
|
||||||
|
isEmpty={match.team1Players.length === 0}
|
||||||
|
/>
|
||||||
|
<div className="text-center text-xs text-primary-400">vs</div>
|
||||||
|
<TeamSlot
|
||||||
|
playerIds={match.team2Players}
|
||||||
|
players={players}
|
||||||
|
score={match.team2Score}
|
||||||
|
isWinner={!!team2IsWinner}
|
||||||
|
isEmpty={match.team2Players.length === 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="px-2 py-1 text-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-0.5 rounded-full",
|
||||||
|
match.status === "SCHEDULED" && "bg-gray-200 text-gray-600",
|
||||||
|
match.status === "IN_PROGRESS" && "bg-blue-200 text-blue-700",
|
||||||
|
match.status === "COMPLETED" && "bg-green-200 text-green-700",
|
||||||
|
match.status === "WALKOVER" && "bg-yellow-200 text-yellow-700",
|
||||||
|
match.status === "CANCELLED" && "bg-red-200 text-red-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{match.status === "SCHEDULED" && "Programado"}
|
||||||
|
{match.status === "IN_PROGRESS" && "En Juego"}
|
||||||
|
{match.status === "COMPLETED" && "Finalizado"}
|
||||||
|
{match.status === "WALKOVER" && "Walkover"}
|
||||||
|
{match.status === "CANCELLED" && "Cancelado"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BracketView({ matches, players, onMatchClick }: BracketViewProps) {
|
||||||
|
// Group matches by round
|
||||||
|
const matchesByRound = useMemo(() => {
|
||||||
|
const grouped = new Map<number, Match[]>();
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center h-64 text-primary-500">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg
|
||||||
|
className="w-12 h-12 mx-auto mb-3 text-primary-300"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p>No hay bracket generado</p>
|
||||||
|
<p className="text-sm mt-1">Genera el bracket para comenzar el torneo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto pb-4">
|
||||||
|
<div className="flex gap-8 min-w-max p-4">
|
||||||
|
{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 (
|
||||||
|
<div key={round} className="flex flex-col">
|
||||||
|
{/* Round Header */}
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<h3 className="font-semibold text-primary-800">{roundName}</h3>
|
||||||
|
<p className="text-xs text-primary-500">
|
||||||
|
{roundMatches.length} {roundMatches.length === 1 ? "partido" : "partidos"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Matches */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col justify-around flex-1"
|
||||||
|
style={{
|
||||||
|
gap: `${(spacingMultiplier - 1) * 100 + 24}px`,
|
||||||
|
paddingTop: `${(spacingMultiplier - 1) * 50}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{roundMatches.map((match) => (
|
||||||
|
<MatchBox
|
||||||
|
key={match.id}
|
||||||
|
match={match}
|
||||||
|
players={players}
|
||||||
|
onClick={() => onMatchClick?.(match)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap gap-4 justify-center mt-6 pt-4 border-t border-primary-200">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="w-4 h-4 rounded bg-gray-200 border border-gray-300" />
|
||||||
|
<span>Programado</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="w-4 h-4 rounded bg-blue-200 border border-blue-400" />
|
||||||
|
<span>En Juego</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="w-4 h-4 rounded bg-green-200 border border-green-300" />
|
||||||
|
<span>Finalizado</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="w-4 h-4 rounded bg-yellow-200 border border-yellow-300" />
|
||||||
|
<span>Walkover</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
242
apps/web/components/tournaments/inscriptions-list.tsx
Normal file
242
apps/web/components/tournaments/inscriptions-list.tsx
Normal file
@@ -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<void>;
|
||||||
|
onTogglePaid?: (inscriptionId: string, isPaid: boolean) => Promise<void>;
|
||||||
|
canEdit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InscriptionsList({
|
||||||
|
inscriptions,
|
||||||
|
entryFee,
|
||||||
|
onRemove,
|
||||||
|
onTogglePaid,
|
||||||
|
canEdit = false,
|
||||||
|
}: InscriptionsListProps) {
|
||||||
|
const [loadingId, setLoadingId] = useState<string | null>(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 (
|
||||||
|
<div className="text-center py-12 text-primary-500">
|
||||||
|
<svg
|
||||||
|
className="w-12 h-12 mx-auto mb-3 text-primary-300"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p>No hay inscripciones todavia</p>
|
||||||
|
<p className="text-sm mt-1">Abre las inscripciones para recibir equipos</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="flex flex-wrap gap-4 p-4 bg-primary-50 rounded-lg">
|
||||||
|
<div className="flex-1 min-w-[150px]">
|
||||||
|
<p className="text-sm text-primary-500">Total Equipos</p>
|
||||||
|
<p className="text-2xl font-bold text-primary-800">
|
||||||
|
{inscriptions.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[150px]">
|
||||||
|
<p className="text-sm text-primary-500">Pagados</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">
|
||||||
|
{paidCount} / {inscriptions.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[150px]">
|
||||||
|
<p className="text-sm text-primary-500">Recaudado</p>
|
||||||
|
<p className="text-2xl font-bold text-accent-600">
|
||||||
|
{formatCurrency(totalCollected)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-primary-200">
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-primary-600">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-primary-600">
|
||||||
|
Equipo / Jugadores
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-medium text-primary-600">
|
||||||
|
Contacto
|
||||||
|
</th>
|
||||||
|
<th className="text-center py-3 px-4 text-sm font-medium text-primary-600">
|
||||||
|
Estado Pago
|
||||||
|
</th>
|
||||||
|
{canEdit && (
|
||||||
|
<th className="text-right py-3 px-4 text-sm font-medium text-primary-600">
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{inscriptions.map((inscription, index) => (
|
||||||
|
<tr
|
||||||
|
key={inscription.id}
|
||||||
|
className={cn(
|
||||||
|
"border-b border-primary-100 hover:bg-primary-50 transition-colors",
|
||||||
|
loadingId === inscription.id && "opacity-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="py-3 px-4 text-sm text-primary-500">
|
||||||
|
{inscription.seedNumber || index + 1}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-primary-800">
|
||||||
|
{getTeamName(inscription)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-primary-500">
|
||||||
|
{getPlayerNames(inscription)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="text-sm">
|
||||||
|
{inscription.client.email && (
|
||||||
|
<p className="text-primary-600">{inscription.client.email}</p>
|
||||||
|
)}
|
||||||
|
{inscription.client.phone && (
|
||||||
|
<p className="text-primary-500">{inscription.client.phone}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => canEdit && handleTogglePaid(inscription)}
|
||||||
|
disabled={!canEdit || loadingId === inscription.id}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1 text-xs font-medium rounded-full transition-colors",
|
||||||
|
inscription.isPaid
|
||||||
|
? "bg-green-100 text-green-700 hover:bg-green-200"
|
||||||
|
: "bg-yellow-100 text-yellow-700 hover:bg-yellow-200",
|
||||||
|
!canEdit && "cursor-default hover:bg-inherit"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{inscription.isPaid ? "Pagado" : "Pendiente"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
{canEdit && (
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemove(inscription.id)}
|
||||||
|
disabled={loadingId === inscription.id}
|
||||||
|
>
|
||||||
|
{loadingId === inscription.id ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
apps/web/components/tournaments/tournament-card.tsx
Normal file
175
apps/web/components/tournaments/tournament-card.tsx
Normal file
@@ -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<string, { label: string; className: string }> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer transition-all hover:shadow-lg hover:border-primary-400",
|
||||||
|
"relative overflow-hidden"
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<CardTitle className="text-lg line-clamp-2">{tournament.name}</CardTitle>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 text-xs font-medium rounded-full border shrink-0",
|
||||||
|
status.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{tournament.description && (
|
||||||
|
<p className="text-sm text-primary-500 line-clamp-2 mt-1">
|
||||||
|
{tournament.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{/* Type and Category */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<span className="px-2 py-1 text-xs bg-primary-100 text-primary-700 rounded">
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
|
{category && (
|
||||||
|
<span className="px-2 py-1 text-xs bg-accent-100 text-accent-700 rounded">
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-primary-600">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{formatDate(tournament.startDate)}</span>
|
||||||
|
{tournament.endDate && (
|
||||||
|
<>
|
||||||
|
<span>-</span>
|
||||||
|
<span>{formatDate(tournament.endDate)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inscriptions and Price */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-primary-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-primary-700">
|
||||||
|
{tournament._count.inscriptions}
|
||||||
|
{tournament.maxPlayers && ` / ${tournament.maxPlayers}`}
|
||||||
|
{" equipos"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-primary-800">
|
||||||
|
{entryFee > 0 ? formatCurrency(entryFee) : "Gratis"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Site name */}
|
||||||
|
<div className="pt-2 border-t border-primary-100">
|
||||||
|
<span className="text-xs text-primary-500">{tournament.site.name}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
342
apps/web/components/tournaments/tournament-form.tsx
Normal file
342
apps/web/components/tournaments/tournament-form.tsx
Normal file
@@ -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<TournamentFormData>;
|
||||||
|
onSubmit: (data: TournamentFormData) => Promise<void>;
|
||||||
|
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<Site[]>([]);
|
||||||
|
const [loadingSites, setLoadingSites] = useState(true);
|
||||||
|
const [formData, setFormData] = useState<TournamentFormData>({
|
||||||
|
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<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 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<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
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<string, string> = {};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{mode === "create" ? "Nuevo Torneo" : "Editar Torneo"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||||
|
Nombre del Torneo *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Ej: Torneo Navidad 2024"
|
||||||
|
className={errors.name ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||||
|
Descripcion
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Descripcion del torneo..."
|
||||||
|
rows={3}
|
||||||
|
className="flex w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-primary-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Site Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||||
|
Sede *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="siteId"
|
||||||
|
value={formData.siteId}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="flex h-10 w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||||
|
disabled={loadingSites}
|
||||||
|
>
|
||||||
|
<option value="">Selecciona una sede</option>
|
||||||
|
{sites.map((site) => (
|
||||||
|
<option key={site.id} value={site.id}>
|
||||||
|
{site.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.siteId && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.siteId}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||||
|
Fecha de Inicio *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
name="date"
|
||||||
|
value={formData.date}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={errors.date ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.date && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.date}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||||
|
Fecha de Fin
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
name="endDate"
|
||||||
|
value={formData.endDate}
|
||||||
|
onChange={handleChange}
|
||||||
|
className={errors.endDate ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.endDate && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.endDate}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type and Category */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||||
|
Tipo de Torneo
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="type"
|
||||||
|
value={formData.type}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="flex h-10 w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
{tournamentTypes.map((type) => (
|
||||||
|
<option key={type.value} value={type.value}>
|
||||||
|
{type.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||||
|
Categoria
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="category"
|
||||||
|
value={formData.category}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="flex h-10 w-full rounded-md border border-primary-200 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat.value} value={cat.value}>
|
||||||
|
{cat.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max Teams and Price */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||||
|
Maximo de Equipos *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="maxTeams"
|
||||||
|
value={formData.maxTeams}
|
||||||
|
onChange={handleChange}
|
||||||
|
min={2}
|
||||||
|
max={128}
|
||||||
|
className={errors.maxTeams ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.maxTeams && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.maxTeams}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||||
|
Precio de Inscripcion
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="price"
|
||||||
|
value={formData.price}
|
||||||
|
onChange={handleChange}
|
||||||
|
min={0}
|
||||||
|
step={50}
|
||||||
|
className={errors.price ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.price && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.price}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex justify-end gap-3 border-t border-primary-200 bg-primary-50 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{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>
|
||||||
|
) : mode === "create" ? (
|
||||||
|
"Crear Torneo"
|
||||||
|
) : (
|
||||||
|
"Guardar Cambios"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user