Add predictions import from Excel with Spanish team name mapping

This commit is contained in:
2026-06-11 21:52:34 -03:00
parent 0614dbd599
commit 99f5d4ebd5
5 changed files with 386 additions and 3 deletions
+9 -3
View File
@@ -73,9 +73,15 @@ export default function ImportPage() {
return (
<div className="max-w-lg mx-auto">
<div className="bg-gray-900 text-white px-4 pt-12 pb-4 sticky top-0 z-10 flex items-center gap-3">
<button onClick={() => router.back()} className="text-gray-400 text-lg">←</button>
<h1 className="text-xl font-black">Importar usuarios</h1>
<div className="bg-gray-900 text-white px-4 pt-12 pb-4 sticky top-0 z-10">
<div className="flex items-center gap-3 mb-3">
<button onClick={() => router.back()} className="text-gray-400 text-lg">←</button>
<h1 className="text-xl font-black">Importar</h1>
</div>
<div className="flex gap-2">
<a href="/admin/import" className="bg-white text-gray-900 text-xs font-bold px-3 py-1.5 rounded-full">👥 Usuarios</a>
<a href="/admin/import/predictions" className="bg-gray-700 text-white text-xs font-bold px-3 py-1.5 rounded-full">⚽ Pronósticos</a>
</div>
</div>
<div className="px-4 pt-6 space-y-4">
@@ -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<User[]>([])
const [userId, setUserId] = useState('')
const [preview, setPreview] = useState<PredictionRow[]>([])
const [result, setResult] = useState<ImportResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const fileRef = useRef<HTMLInputElement>(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<string[]>(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 (
<div className="max-w-lg mx-auto">
<div className="bg-gray-900 text-white px-4 pt-12 pb-4 sticky top-0 z-10 flex items-center gap-3">
<button onClick={() => router.back()} className="text-gray-400 text-lg">←</button>
<h1 className="text-xl font-black">Importar pronósticos</h1>
</div>
<div className="px-4 pt-6 space-y-4">
{/* Selector de usuario */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<label className="text-sm font-semibold text-gray-700 block mb-2">Usuario</label>
<select
value={userId}
onChange={e => setUserId(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
>
<option value="">— Seleccioná un usuario —</option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name} ({u.email})</option>
))}
</select>
</div>
{/* Upload */}
{userId && preview.length === 0 && !result && (
<div
onClick={() => 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"
>
<div className="text-4xl mb-3">📂</div>
<p className="font-semibold text-gray-700">Subí el Excel de pronósticos</p>
<p className="text-sm text-gray-400 mt-1">Formato: Equipo | Goles | vs | Goles | Equipo</p>
<input
ref={fileRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={e => e.target.files?.[0] && handleFile(e.target.files[0])}
/>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-xl px-4 py-3">{error}</div>
)}
{/* Preview */}
{preview.length > 0 && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-semibold text-gray-700">
{preview.length} pronósticos para <span className="text-blue-600">{selectedUser?.name}</span>
</p>
<button onClick={() => { setPreview([]); if (fileRef.current) fileRef.current.value = '' }} className="text-sm text-gray-400">Cancelar</button>
</div>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden mb-4">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-xs">
<tr>
<th className="text-left px-3 py-2">Local</th>
<th className="text-center px-2 py-2">Resultado</th>
<th className="text-right px-3 py-2">Visitante</th>
</tr>
</thead>
<tbody>
{preview.map((r, i) => {
const result = r.homeScore > r.awayScore ? 'L' : r.homeScore < r.awayScore ? 'V' : 'E'
return (
<tr key={i} className="border-t border-gray-50">
<td className="px-3 py-1.5 font-medium text-xs">{r.homeTeamName}</td>
<td className="text-center px-2 py-1.5">
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${
result === 'L' ? 'bg-blue-100 text-blue-700' :
result === 'V' ? 'bg-orange-100 text-orange-700' :
'bg-gray-100 text-gray-600'
}`}>
{r.homeScore} - {r.awayScore}
</span>
</td>
<td className="px-3 py-1.5 font-medium text-xs text-right">{r.awayTeamName}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<button
onClick={handleImport}
disabled={loading}
className="w-full bg-gray-900 text-white font-bold py-3 rounded-2xl disabled:opacity-50"
>
{loading ? 'Importando...' : `Guardar ${preview.length} pronósticos`}
</button>
</div>
)}
{/* Resultado */}
{result && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 space-y-3">
<div className="flex items-center gap-2">
<span className="text-2xl">✅</span>
<div>
<p className="font-bold text-gray-900">{result.imported} pronósticos importados</p>
{result.skipped > 0 && <p className="text-sm text-gray-500">{result.skipped} omitidos</p>}
</div>
</div>
{result.errors.length > 0 && (
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-3">
<p className="text-xs font-semibold text-yellow-700 mb-1">Equipos no reconocidos:</p>
{result.errors.map((e, i) => <p key={i} className="text-xs text-yellow-600">{e}</p>)}
</div>
)}
<button
onClick={() => { setResult(null); setPreview([]); if (fileRef.current) fileRef.current.value = '' }}
className="w-full border border-gray-300 text-gray-700 font-bold py-3 rounded-2xl text-sm"
>
Importar otro
</button>
</div>
)}
</div>
</div>
)
}
@@ -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 })
}
+17
View File
@@ -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)
}
+67
View File
@@ -0,0 +1,67 @@
// Mapeo de nombres de equipos en español → shortName de la DB
export const TEAM_NAME_MAP: Record<string, string> = {
// 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
}