diff --git a/app/src/app/admin/import/page.tsx b/app/src/app/admin/import/page.tsx index a9f3d3e..3483a75 100644 --- a/app/src/app/admin/import/page.tsx +++ b/app/src/app/admin/import/page.tsx @@ -73,9 +73,15 @@ export default function ImportPage() { return (
-
- -

Importar usuarios

+
+
+ +

Importar

+
+
+ 👥 Usuarios + ⚽ Pronósticos +
diff --git a/app/src/app/admin/import/predictions/page.tsx b/app/src/app/admin/import/predictions/page.tsx new file mode 100644 index 0000000..b621675 --- /dev/null +++ b/app/src/app/admin/import/predictions/page.tsx @@ -0,0 +1,208 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import { useRouter } from 'next/navigation' + +interface User { id: string; name: string; email: string } +interface PredictionRow { homeTeamName: string; homeScore: number; awayScore: number; awayTeamName: string } +interface ImportResult { ok: boolean; imported: number; skipped: number; errors: string[] } + +export default function ImportPredictionsPage() { + const [users, setUsers] = useState([]) + const [userId, setUserId] = useState('') + const [preview, setPreview] = useState([]) + const [result, setResult] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const fileRef = useRef(null) + const router = useRouter() + + useEffect(() => { + fetch('/api/admin/users').then(r => r.json()).then(setUsers) + }, []) + + const handleFile = async (file: File) => { + setError('') + setPreview([]) + setResult(null) + try { + const XLSX = await import('xlsx') + const buf = await file.arrayBuffer() + const wb = XLSX.read(buf, { type: 'array' }) + const ws = wb.Sheets[wb.SheetNames[0]] + const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }) as string[][] + + const parsed: PredictionRow[] = [] + for (const row of rows) { + const homeTeamName = String(row[0] ?? '').trim() + const homeScore = Number(row[1]) + const awayScore = Number(row[3]) + const awayTeamName = String(row[4] ?? '').trim() + + // Filtra filas que no son partidos + if (!homeTeamName || !awayTeamName) continue + if (isNaN(homeScore) || isNaN(awayScore)) continue + if (homeTeamName.toLowerCase().includes('equipo') || homeTeamName.toLowerCase().includes('fixture')) continue + + parsed.push({ homeTeamName, homeScore, awayScore, awayTeamName }) + } + + if (parsed.length === 0) { + setError('No se encontraron pronósticos en el archivo.') + return + } + setPreview(parsed) + } catch { + setError('No se pudo leer el archivo.') + } + } + + const handleImport = async () => { + if (!userId) { setError('Seleccioná un usuario'); return } + setLoading(true) + setError('') + try { + const res = await fetch('/api/admin/import-predictions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId, predictions: preview }), + }) + const data = await res.json() + if (!res.ok) { setError(data.error); return } + setResult(data) + setPreview([]) + } catch { + setError('Error al importar') + } finally { + setLoading(false) + } + } + + const selectedUser = users.find(u => u.id === userId) + + return ( +
+
+ +

Importar pronósticos

+
+ +
+ + {/* Selector de usuario */} +
+ + +
+ + {/* Upload */} + {userId && preview.length === 0 && !result && ( +
fileRef.current?.click()} + className="border-2 border-dashed border-gray-300 rounded-2xl p-10 text-center cursor-pointer hover:border-gray-400 transition-colors" + > +
📂
+

Subí el Excel de pronósticos

+

Formato: Equipo | Goles | vs | Goles | Equipo

+ e.target.files?.[0] && handleFile(e.target.files[0])} + /> +
+ )} + + {error && ( +
{error}
+ )} + + {/* Preview */} + {preview.length > 0 && ( +
+
+

+ {preview.length} pronósticos para {selectedUser?.name} +

+ +
+
+ + + + + + + + + + {preview.map((r, i) => { + const result = r.homeScore > r.awayScore ? 'L' : r.homeScore < r.awayScore ? 'V' : 'E' + return ( + + + + + + ) + })} + +
LocalResultadoVisitante
{r.homeTeamName} + + {r.homeScore} - {r.awayScore} + + {r.awayTeamName}
+
+ +
+ )} + + {/* Resultado */} + {result && ( +
+
+ ✅ +
+

{result.imported} pronósticos importados

+ {result.skipped > 0 &&

{result.skipped} omitidos

} +
+
+ {result.errors.length > 0 && ( +
+

Equipos no reconocidos:

+ {result.errors.map((e, i) =>

{e}

)} +
+ )} + +
+ )} + +
+
+ ) +} diff --git a/app/src/app/api/admin/import-predictions/route.ts b/app/src/app/api/admin/import-predictions/route.ts new file mode 100644 index 0000000..9e54edd --- /dev/null +++ b/app/src/app/api/admin/import-predictions/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { resolveTeam } from '@/lib/teamMapping' + +interface PredictionRow { + homeTeamName: string + homeScore: number + awayScore: number + awayTeamName: string +} + +export async function POST(req: Request) { + const session = await getSession() + if (!session || session.role !== 'ADMIN') { + return NextResponse.json({ error: 'No autorizado' }, { status: 403 }) + } + + const { userId, predictions } = await req.json() as { userId: string; predictions: PredictionRow[] } + + if (!userId || !Array.isArray(predictions) || predictions.length === 0) { + return NextResponse.json({ error: 'Datos incompletos' }, { status: 400 }) + } + + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return NextResponse.json({ error: 'Usuario no encontrado' }, { status: 404 }) + + // Precarga todos los equipos + const teams = await prisma.team.findMany() + const teamById = Object.fromEntries(teams.map(t => [t.id, t])) + + let imported = 0 + let skipped = 0 + const errors: string[] = [] + + for (const row of predictions) { + const homeTla = resolveTeam(row.homeTeamName) + const awayTla = resolveTeam(row.awayTeamName) + + if (!homeTla || !awayTla) { + errors.push(`No se reconoció: "${row.homeTeamName}" o "${row.awayTeamName}"`) + skipped++ + continue + } + + const homeTeam = teams.find(t => t.shortName === homeTla) + const awayTeam = teams.find(t => t.shortName === awayTla) + if (!homeTeam || !awayTeam) { skipped++; continue } + + // Busca el partido con cualquier combinación home/away + const match = await prisma.match.findFirst({ + where: { + OR: [ + { homeTeamId: homeTeam.id, awayTeamId: awayTeam.id }, + { homeTeamId: awayTeam.id, awayTeamId: homeTeam.id }, + ], + }, + }) + + if (!match) { + errors.push(`Partido no encontrado: ${homeTla} vs ${awayTla}`) + skipped++ + continue + } + + // Detecta si los equipos están invertidos respecto a la DB + const isSwapped = match.homeTeamId === awayTeam.id + const dbHomeScore = isSwapped ? row.awayScore : row.homeScore + const dbAwayScore = isSwapped ? row.homeScore : row.awayScore + + const result = + dbHomeScore > dbAwayScore ? 'HOME' : + dbHomeScore < dbAwayScore ? 'AWAY' : 'DRAW' + + await prisma.prediction.upsert({ + where: { userId_matchId: { userId, matchId: match.id } }, + update: { result }, + create: { userId, matchId: match.id, result }, + }) + + imported++ + } + + return NextResponse.json({ ok: true, imported, skipped, errors }) +} diff --git a/app/src/app/api/admin/users/route.ts b/app/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..bc3b8e9 --- /dev/null +++ b/app/src/app/api/admin/users/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' + +export async function GET() { + const session = await getSession() + if (!session || session.role !== 'ADMIN') { + return NextResponse.json({ error: 'No autorizado' }, { status: 403 }) + } + + const users = await prisma.user.findMany({ + select: { id: true, name: true, email: true, role: true }, + orderBy: { name: 'asc' }, + }) + + return NextResponse.json(users) +} diff --git a/app/src/lib/teamMapping.ts b/app/src/lib/teamMapping.ts new file mode 100644 index 0000000..115e481 --- /dev/null +++ b/app/src/lib/teamMapping.ts @@ -0,0 +1,67 @@ +// Mapeo de nombres de equipos en español → shortName de la DB +export const TEAM_NAME_MAP: Record = { + // Grupo A + 'mexico': 'MEX', 'méxico': 'MEX', + 'sud africa': 'RSA', 'sudáfrica': 'RSA', 'sudafrica': 'RSA', 'south africa': 'RSA', + 'corea': 'KOR', 'corea del sur': 'KOR', 'south korea': 'KOR', + 'china': 'CZE', 'checos': 'CZE', 'república checa': 'CZE', 'republica checa': 'CZE', 'czech republic': 'CZE', + // Grupo B + 'canada': 'CAN', 'canadá': 'CAN', + 'bosnia': 'BIH', 'bosnia y herzegovina': 'BIH', 'bosnia and herzegovina': 'BIH', + 'qatar': 'QAT', 'catar': 'QAT', + 'suiza': 'SUI', 'switzerland': 'SUI', + // Grupo C + 'brasil': 'BRA', 'brazil': 'BRA', + 'marruecos': 'MAR', 'morocco': 'MAR', + 'haiti': 'HAI', 'haití': 'HAI', + 'escocia': 'SCO', 'scotland': 'SCO', + // Grupo D + 'estados unidos': 'USA', 'eeuu': 'USA', 'usa': 'USA', 'united states': 'USA', + 'paraguay': 'PAR', + 'australia': 'AUS', + 'turquia': 'TUR', 'turquía': 'TUR', 'turkey': 'TUR', + // Grupo E + 'alemania': 'GER', 'germany': 'GER', + 'curazao': 'CUW', 'curaçao': 'CUW', 'curacao': 'CUW', + 'costa de marfil': 'CIV', "costa de marfil": 'CIV', 'ivory coast': 'CIV', + 'ecuador': 'ECU', + // Grupo F + 'paises bajos': 'NED', 'países bajos': 'NED', 'holanda': 'NED', 'netherlands': 'NED', + 'japon': 'JPN', 'japón': 'JPN', 'japan': 'JPN', + 'suecia': 'SWE', 'sweden': 'SWE', + 'tunes': 'TUN', 'túnez': 'TUN', 'tunez': 'TUN', 'tunisia': 'TUN', + // Grupo G + 'belgica': 'BEL', 'bélgica': 'BEL', 'belgium': 'BEL', + 'egipto': 'EGY', 'egypt': 'EGY', + 'iran': 'IRN', 'irán': 'IRN', + 'nueva zelanda': 'NZL', 'new zealand': 'NZL', + // Grupo H + 'españa': 'ESP', 'espana': 'ESP', 'spain': 'ESP', + 'cabo verde': 'CPV', 'cape verde': 'CPV', + 'arabia saudita': 'KSA', 'arabia saudi': 'KSA', 'saudi arabia': 'KSA', + 'uruguay': 'URU', + // Grupo I + 'francia': 'FRA', 'france': 'FRA', + 'senegal': 'SEN', + 'irak': 'IRQ', 'iraq': 'IRQ', + 'noruega': 'NOR', 'norway': 'NOR', + // Grupo J + 'argentina': 'ARG', + 'argelia': 'ALG', 'algeria': 'ALG', + 'austria': 'AUT', + 'jordania': 'JOR', 'jordan': 'JOR', + // Grupo K + 'portugal': 'POR', + 'congo': 'COD', 'dr congo': 'COD', 'república democrática del congo': 'COD', + 'uzbekistan': 'UZB', 'uzbekistán': 'UZB', + 'colombia': 'COL', + // Grupo L + 'inglaterra': 'ENG', 'england': 'ENG', + 'croacia': 'CRO', 'croatia': 'CRO', + 'ghana': 'GHA', + 'panama': 'PAN', 'panamá': 'PAN', +} + +export function resolveTeam(name: string): string | null { + return TEAM_NAME_MAP[name.trim().toLowerCase()] ?? null +}