Add Excel user import feature
This commit is contained in:
+2
-1
@@ -24,7 +24,8 @@
|
|||||||
"jose": "^5.6.3",
|
"jose": "^5.6.3",
|
||||||
"next": "^14.2.5",
|
"next": "^14.2.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -52,7 +52,12 @@ export default function AdminPage() {
|
|||||||
<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">
|
<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">
|
<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
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setSyncing(true)
|
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 })
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user