Add predictions import from Excel with Spanish team name mapping
This commit is contained in:
@@ -73,9 +73,15 @@ export default function ImportPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg mx-auto">
|
<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">
|
<div className="bg-gray-900 text-white px-4 pt-12 pb-4 sticky top-0 z-10">
|
||||||
<button onClick={() => router.back()} className="text-gray-400 text-lg">←</button>
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<h1 className="text-xl font-black">Importar usuarios</h1>
|
<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>
|
||||||
|
|
||||||
<div className="px-4 pt-6 space-y-4">
|
<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 })
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user