Add Excel user import feature

This commit is contained in:
2026-06-11 16:55:48 -03:00
parent 4ba290004c
commit 0614dbd599
4 changed files with 241 additions and 2 deletions
+2 -1
View File
@@ -24,7 +24,8 @@
"jose": "^5.6.3",
"next": "^14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
+189
View File
@@ -0,0 +1,189 @@
'use client'
import { useState, useRef } from 'react'
import { useRouter } from 'next/navigation'
interface UserRow { name: string; email: string }
interface ImportResult { name: string; email: string; status: 'created' | 'exists' | 'error'; password?: string }
export default function ImportPage() {
const [preview, setPreview] = useState<UserRow[]>([])
const [results, setResults] = useState<ImportResult[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const fileRef = useRef<HTMLInputElement>(null)
const router = useRouter()
const handleFile = async (file: File) => {
setError('')
setPreview([])
setResults([])
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<Record<string, unknown>>(ws, { defval: '' })
const parsed: UserRow[] = rows.map(row => {
const keys = Object.keys(row).map(k => k.toLowerCase())
const nameKey = Object.keys(row).find(k => ['nombre', 'name', 'nombre completo', 'full name'].includes(k.toLowerCase()))
const emailKey = Object.keys(row).find(k => k.toLowerCase() === 'email' || k.toLowerCase() === 'correo')
return {
name: nameKey ? String(row[nameKey]).trim() : '',
email: emailKey ? String(row[emailKey]).trim() : '',
}
}).filter(r => r.name && r.email)
if (parsed.length === 0) {
setError('No se encontraron filas válidas. El archivo debe tener columnas "nombre" y "email".')
return
}
setPreview(parsed)
} catch {
setError('No se pudo leer el archivo. Asegurate de que sea .xlsx o .xls')
}
}
const handleImport = async () => {
setLoading(true)
setError('')
try {
const res = await fetch('/api/admin/import-users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ users: preview }),
})
const data = await res.json()
if (!res.ok) { setError(data.error); return }
setResults(data.results)
setPreview([])
} catch {
setError('Error al importar')
} finally {
setLoading(false)
}
}
const copyAll = () => {
const created = results.filter(r => r.status === 'created')
const text = created.map(r => `${r.name}\t${r.email}\t${r.password}`).join('\n')
navigator.clipboard.writeText(text)
}
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>
<div className="px-4 pt-6 space-y-4">
{/* Upload */}
{preview.length === 0 && results.length === 0 && (
<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">Seleccioná el archivo Excel</p>
<p className="text-sm text-gray-400 mt-1">Columnas requeridas: <strong>nombre</strong> y <strong>email</strong></p>
<input
ref={fileRef}
type="file"
accept=".xlsx,.xls,.csv"
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} usuarios para importar</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-4 py-2">Nombre</th>
<th className="text-left px-4 py-2">Email</th>
</tr>
</thead>
<tbody>
{preview.map((u, i) => (
<tr key={i} className="border-t border-gray-50">
<td className="px-4 py-2 font-medium">{u.name}</td>
<td className="px-4 py-2 text-gray-500">{u.email}</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...' : `Crear ${preview.length} usuarios`}
</button>
</div>
)}
{/* Results */}
{results.length > 0 && (
<div>
<div className="flex items-center justify-between mb-3">
<p className="font-semibold text-gray-700">
{results.filter(r => r.status === 'created').length} creados ·{' '}
{results.filter(r => r.status === 'exists').length} ya existían
</p>
<button onClick={copyAll} className="text-sm text-blue-600 font-semibold">Copiar todo</button>
</div>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-500 text-xs">
<tr>
<th className="text-left px-4 py-2">Nombre</th>
<th className="text-left px-4 py-2">Email</th>
<th className="text-left px-4 py-2">Contraseña</th>
</tr>
</thead>
<tbody>
{results.map((r, i) => (
<tr key={i} className={`border-t border-gray-50 ${r.status === 'exists' ? 'opacity-50' : ''}`}>
<td className="px-4 py-2 font-medium">{r.name}</td>
<td className="px-4 py-2 text-gray-500 text-xs">{r.email}</td>
<td className="px-4 py-2">
{r.status === 'created' && (
<code className="bg-gray-100 px-2 py-0.5 rounded text-xs font-mono">{r.password}</code>
)}
{r.status === 'exists' && <span className="text-gray-400 text-xs">ya existe</span>}
{r.status === 'error' && <span className="text-red-500 text-xs">error</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={() => { setResults([]); if (fileRef.current) fileRef.current.value = '' }}
className="w-full mt-4 border border-gray-300 text-gray-700 font-bold py-3 rounded-2xl"
>
Importar otro archivo
</button>
</div>
)}
</div>
</div>
)
}
+6 -1
View File
@@ -52,7 +52,12 @@ export default function AdminPage() {
<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">
<div className="flex items-center justify-between mb-3">
<h1 className="text-xl font-black">⚙️ Admin</h1>
<div className="flex items-center gap-3">
<h1 className="text-xl font-black">⚙️ Admin</h1>
<a href="/admin/import" className="bg-gray-700 text-white text-xs font-bold px-3 py-1.5 rounded-full">
📥 Importar
</a>
</div>
<button
onClick={async () => {
setSyncing(true)
@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getSession } from '@/lib/auth'
import bcrypt from 'bcryptjs'
import { randomBytes } from 'crypto'
export async function POST(req: Request) {
const session = await getSession()
if (!session || session.role !== 'ADMIN') {
return NextResponse.json({ error: 'No autorizado' }, { status: 403 })
}
const { users } = await req.json() as { users: { name: string; email: string }[] }
if (!Array.isArray(users) || users.length === 0) {
return NextResponse.json({ error: 'Lista vacía' }, { status: 400 })
}
const results: { name: string; email: string; status: 'created' | 'exists' | 'error'; password?: string }[] = []
for (const u of users) {
const email = String(u.email ?? '').trim().toLowerCase()
const name = String(u.name ?? '').trim()
if (!email || !name) continue
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) {
results.push({ name, email, status: 'exists' })
continue
}
try {
const password = randomBytes(4).toString('hex') // ej: "a3f9c012"
await prisma.user.create({
data: { email, name, password: await bcrypt.hash(password, 10), role: 'PLAYER' },
})
results.push({ name, email, status: 'created', password })
} catch {
results.push({ name, email, status: 'error' })
}
}
return NextResponse.json({ results })
}