Fix user admin update, logo persistence, and improve error handling
This commit is contained in:
parent
d61729b92e
commit
45da8c1d46
BIN
almeira-web-deploy.zip
Normal file
BIN
almeira-web-deploy.zip
Normal file
Binary file not shown.
BIN
almeira-web-source.zip
Normal file
BIN
almeira-web-source.zip
Normal file
Binary file not shown.
32
docker-compose-production.yml
Normal file
32
docker-compose-production.yml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
almeira-web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
- NEXT_PUBLIC_APPWRITE_ENDPOINT=https://appwrite.almeira.com.co/v1
|
||||||
|
- NEXT_PUBLIC_APPWRITE_PROJECT_ID=69b84df5003bd8957c5c
|
||||||
|
- APPWRITE_API_KEY=standard_ee81a4ca6784c0fa6a23abab0a983dbba9cd0c2d8e211a00c759d9801f5d8e27b43a605bfa373e13106da87acfc7673608f0ffb0eb04e10cae3dae93d2404bfda8676f96e3fcfd6bd642f6e12d69367916b796c54e98e2e4c4bc0b7a27aa42ba5048582fcbd45b1365b51c5014cb939c1853923c418048ff0e39c2237065da3c
|
||||||
|
- APPWRITE_DATABASE_ID=almeira
|
||||||
|
image: almeira-web:production
|
||||||
|
container_name: almeira-web-prod
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3000
|
||||||
|
- HOSTNAME=0.0.0.0
|
||||||
|
- NEXT_PUBLIC_APPWRITE_ENDPOINT=https://appwrite.almeira.com.co/v1
|
||||||
|
- NEXT_PUBLIC_APPWRITE_PROJECT_ID=69b84df5003bd8957c5c
|
||||||
|
- APPWRITE_DATABASE_ID=almeira
|
||||||
|
- NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID=media_bucket
|
||||||
|
# Opcional: Define la clave API de Appwrite en ejecución si la necesitas
|
||||||
|
- APPWRITE_API_KEY=standard_ee81a4ca6784c0fa6a23abab0a983dbba9cd0c2d8e211a00c759d9801f5d8e27b43a605bfa373e13106da87acfc7673608f0ffb0eb04e10cae3dae93d2404bfda8676f96e3fcfd6bd642f6e12d69367916b796c54e98e2e4c4bc0b7a27aa42ba5048582fcbd45b1365b51c5014cb939c1853923c418048ff0e39c2237065da3c
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
28
ecosystem.config.js
Normal file
28
ecosystem.config.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'almeira-web',
|
||||||
|
script: 'server.js',
|
||||||
|
instances: 'max', // O un número específico de CPU cores como 1 o 2
|
||||||
|
exec_mode: 'cluster',
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: '1G',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3000,
|
||||||
|
HOSTNAME: '0.0.0.0'
|
||||||
|
},
|
||||||
|
env_production: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3000,
|
||||||
|
HOSTNAME: '0.0.0.0',
|
||||||
|
// Inyecta aquí las variables de entorno si no usas un archivo .env
|
||||||
|
NEXT_PUBLIC_APPWRITE_ENDPOINT: 'https://appwrite.almeira.com.co/v1',
|
||||||
|
NEXT_PUBLIC_APPWRITE_PROJECT_ID: '69b84df5003bd8957c5c',
|
||||||
|
APPWRITE_DATABASE_ID: 'almeira',
|
||||||
|
NEXT_PUBLIC_APPWRITE_MEDIA_BUCKET_ID: 'media_bucket'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
32
fix-schema.js
Normal file
32
fix-schema.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const { Client, Databases } = require('node-appwrite');
|
||||||
|
require('dotenv').config({ path: '.env.local' });
|
||||||
|
|
||||||
|
const client = new Client()
|
||||||
|
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT)
|
||||||
|
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID)
|
||||||
|
.setKey(process.env.APPWRITE_API_KEY);
|
||||||
|
|
||||||
|
const databases = new Databases(client);
|
||||||
|
const dbId = process.env.APPWRITE_DATABASE_ID;
|
||||||
|
const colId = 'settings';
|
||||||
|
|
||||||
|
async function fix() {
|
||||||
|
try {
|
||||||
|
console.log("Creando atributo ticker_count...");
|
||||||
|
await databases.createIntegerAttribute(dbId, colId, 'ticker_count', false, 1, 100, 10, false);
|
||||||
|
} catch(e) { console.log(e.message); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Creando atributo feed_news_count...");
|
||||||
|
await databases.createIntegerAttribute(dbId, colId, 'feed_news_count', false, 1, 100, 12, false);
|
||||||
|
} catch(e) { console.log(e.message); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Creando atributo rotation_seconds...");
|
||||||
|
await databases.createIntegerAttribute(dbId, colId, 'rotation_seconds', false, 1, 100, 5, false);
|
||||||
|
} catch(e) { console.log(e.message); }
|
||||||
|
|
||||||
|
console.log("Finished creating attributes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fix();
|
||||||
64
package-hostinger.sh
Executable file
64
package-hostinger.sh
Executable file
@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script para empaquetar la aplicación Next.js standalone para Hostinger Node.js App
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Iniciando empaquetado para Hostinger ==="
|
||||||
|
|
||||||
|
# 1. Asegurar que estamos en el directorio correcto
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# 2. Compilar el proyecto
|
||||||
|
echo "Compilando la aplicación Next.js..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 3. Validar la existencia del build standalone
|
||||||
|
if [ ! -d ".next/standalone" ]; then
|
||||||
|
echo "Error: No se generó el directorio .next/standalone."
|
||||||
|
echo "Asegúrate de que 'output: \"standalone\"' está configurado en next.config.ts"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Crear carpeta temporal de empaquetado
|
||||||
|
DEPLOY_DIR="deploy-hostinger"
|
||||||
|
echo "Creando directorio temporal: $DEPLOY_DIR..."
|
||||||
|
rm -rf "$DEPLOY_DIR"
|
||||||
|
mkdir -p "$DEPLOY_DIR"
|
||||||
|
|
||||||
|
# 5. Copiar archivos standalone
|
||||||
|
echo "Copiando archivos del servidor standalone..."
|
||||||
|
cp -r .next/standalone/. "$DEPLOY_DIR/"
|
||||||
|
|
||||||
|
# 6. Copiar archivos estáticos y carpeta public (requerido por Next.js standalone)
|
||||||
|
echo "Copiando archivos estáticos (.next/static)..."
|
||||||
|
mkdir -p "$DEPLOY_DIR/.next/static"
|
||||||
|
cp -r .next/static/. "$DEPLOY_DIR/.next/static/"
|
||||||
|
|
||||||
|
if [ -d "public" ]; then
|
||||||
|
echo "Copiando carpeta public..."
|
||||||
|
cp -r public "$DEPLOY_DIR/"
|
||||||
|
else
|
||||||
|
echo "Advertencia: No se encontró la carpeta 'public'."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7. Copiar archivo de entorno de ejemplo o variables
|
||||||
|
if [ -f ".env.local" ]; then
|
||||||
|
echo "Copiando variables locales como plantilla .env.production.example..."
|
||||||
|
cp .env.local "$DEPLOY_DIR/.env.production.example"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 8. Comprimir en un archivo zip
|
||||||
|
ZIP_FILE="almeira-web-deploy.zip"
|
||||||
|
echo "Comprimiendo aplicación en $ZIP_FILE..."
|
||||||
|
rm -f "$ZIP_FILE"
|
||||||
|
|
||||||
|
# Entrar al directorio temporal para que los archivos queden en la raíz del zip
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
zip -r "../$ZIP_FILE" . > /dev/null
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 9. Limpiar directorio temporal
|
||||||
|
echo "Limpiando directorio temporal..."
|
||||||
|
rm -rf "$DEPLOY_DIR"
|
||||||
|
|
||||||
|
echo "=== ¡Listo! Se ha generado el archivo $ZIP_FILE con éxito. ==="
|
||||||
|
echo "Sube este archivo a tu hosting Node.js en Hostinger y descomprímelo en la raíz de tu aplicación."
|
||||||
302
src/app/actions/user-admin.ts
Normal file
302
src/app/actions/user-admin.ts
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { Client, Users, ID, Query } from 'node-appwrite';
|
||||||
|
import { getLoggedInUser } from '@/lib/appwrite.server';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
|
||||||
|
// Helper de seguridad: Verifica si el usuario logueado es Administrador o bypass
|
||||||
|
async function checkAdminAccess(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const user = await getLoggedInUser() as any;
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
// 1. Bypass de emergencia
|
||||||
|
if (user.email === 'admin@almeira.com.co') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Rol admin en preferencias o en etiquetas de Appwrite
|
||||||
|
if (
|
||||||
|
user?.prefs?.role === 'admin' ||
|
||||||
|
(Array.isArray(user?.labels) && user.labels.includes('admin'))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verificando acceso administrador:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inicialización de servicios administrativos de Appwrite con la API Key del servidor
|
||||||
|
function getAdminServices() {
|
||||||
|
const client = new Client()
|
||||||
|
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://appwrite.almeira.com.co/v1')
|
||||||
|
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '69b84df5003bd8957c5c')
|
||||||
|
.setKey(process.env.APPWRITE_API_KEY || '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: new Users(client)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. LISTAR USUARIOS (con paginación y búsqueda opcional)
|
||||||
|
export async function listUsersAction(page: number = 1, search: string = '') {
|
||||||
|
const isAdmin = await checkAdminAccess();
|
||||||
|
if (!isAdmin) {
|
||||||
|
return { success: false, error: 'No autorizado. Se requieren permisos de administrador.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const admin = getAdminServices();
|
||||||
|
const limit = 10;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
Query.limit(limit),
|
||||||
|
Query.offset(offset)
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await admin.users.list({
|
||||||
|
queries,
|
||||||
|
search: search ? search.trim() : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retornamos los datos limpios evitando referencias circulares o no serializables en Server Actions
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
users: JSON.parse(JSON.stringify(response.users)),
|
||||||
|
total: response.total,
|
||||||
|
limit,
|
||||||
|
page
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error listando usuarios:', error);
|
||||||
|
return { success: false, error: error.message || 'Error al obtener la lista de usuarios.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. CREAR USUARIO
|
||||||
|
export async function createUserAction(name: string, email: string, password: string) {
|
||||||
|
const isAdmin = await checkAdminAccess();
|
||||||
|
if (!isAdmin) {
|
||||||
|
return { success: false, error: 'No autorizado. Se requieren permisos de administrador.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || !email || !password) {
|
||||||
|
return { success: false, error: 'Todos los campos son obligatorios.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return { success: false, error: 'La contraseña debe tener al menos 8 caracteres.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const admin = getAdminServices();
|
||||||
|
const response = await admin.users.create({
|
||||||
|
userId: ID.unique(),
|
||||||
|
email: email.trim().toLowerCase(),
|
||||||
|
password: password,
|
||||||
|
name: name.trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/dashboard/users');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: JSON.parse(JSON.stringify(response))
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error al crear usuario:', error);
|
||||||
|
return { success: false, error: error.message || 'Error al crear el usuario en Appwrite.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. ACTUALIZAR USUARIO — Nombre, Email y Labels en llamadas separadas con manejo de errores por operación
|
||||||
|
export async function updateUserAction(userId: string, name: string, email: string, labels: string[]) {
|
||||||
|
const isAdmin = await checkAdminAccess();
|
||||||
|
if (!isAdmin) {
|
||||||
|
return { success: false, error: 'No autorizado. Se requieren permisos de administrador.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId || !name.trim() || !email.trim()) {
|
||||||
|
return { success: false, error: 'El ID, Nombre y Email son campos obligatorios.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = getAdminServices();
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// 1. Actualizar Nombre (operación independiente)
|
||||||
|
try {
|
||||||
|
await admin.users.updateName({ userId, name: name.trim() });
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.message || e?.message || 'Error al actualizar el nombre.';
|
||||||
|
console.error(`[updateUserAction] updateName error para ${userId}:`, msg);
|
||||||
|
errors.push(`Nombre: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Actualizar Email (operación independiente)
|
||||||
|
try {
|
||||||
|
await admin.users.updateEmail({ userId, email: email.trim().toLowerCase() });
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.message || e?.message || 'Error al actualizar el correo.';
|
||||||
|
console.error(`[updateUserAction] updateEmail error para ${userId}:`, msg);
|
||||||
|
errors.push(`Email: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Actualizar Labels/Etiquetas (operación independiente)
|
||||||
|
try {
|
||||||
|
const sanitizedLabels = labels
|
||||||
|
.map(l => l.trim().toLowerCase())
|
||||||
|
.filter(l => /^[a-z0-9_-]+$/.test(l)); // solo alfanumérico, guiones y guiones bajos
|
||||||
|
await admin.users.updateLabels({ userId, labels: sanitizedLabels });
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.message || e?.message || 'Error al actualizar etiquetas/roles.';
|
||||||
|
console.error(`[updateUserAction] updateLabels error para ${userId}:`, msg);
|
||||||
|
errors.push(`Roles: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Se encontraron errores al actualizar: ${errors.join(' | ')}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/dashboard/users');
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. CAMBIAR CONTRASEÑA DE UN USUARIO (Restablecimiento administrativo)
|
||||||
|
export async function changeUserPasswordAction(userId: string, password: string) {
|
||||||
|
const isAdmin = await checkAdminAccess();
|
||||||
|
if (!isAdmin) {
|
||||||
|
return { success: false, error: 'No autorizado. Se requieren permisos de administrador.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId || !password) {
|
||||||
|
return { success: false, error: 'ID de usuario y contraseña son requeridos.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return { success: false, error: 'La contraseña debe tener al menos 8 caracteres.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const admin = getAdminServices();
|
||||||
|
await admin.users.updatePassword({
|
||||||
|
userId,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error cambiando contraseña del usuario ${userId}:`, error);
|
||||||
|
return { success: false, error: error.message || 'Error al restablecer la contraseña.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. BLOQUEAR / DESBLOQUEAR CUENTA DE USUARIO
|
||||||
|
export async function toggleUserStatusAction(userId: string, block: boolean) {
|
||||||
|
const isAdmin = await checkAdminAccess();
|
||||||
|
if (!isAdmin) {
|
||||||
|
return { success: false, error: 'No autorizado. Se requieren permisos de administrador.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return { success: false, error: 'ID de usuario es requerido.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const admin = getAdminServices();
|
||||||
|
|
||||||
|
// Appwrite updateStatus: status = true (activo), status = false (bloqueado)
|
||||||
|
// Si block es true, el status nuevo debe ser false (bloqueado).
|
||||||
|
// Si block es false, el status nuevo debe ser true (activo).
|
||||||
|
await admin.users.updateStatus({
|
||||||
|
userId,
|
||||||
|
status: !block
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/dashboard/users');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error cambiando estado del usuario ${userId}:`, error);
|
||||||
|
return { success: false, error: error.message || 'Error al modificar el estado de la cuenta.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. ELIMINAR CUENTA DE USUARIO PERMANENTEMENTE
|
||||||
|
export async function deleteUserAction(userId: string) {
|
||||||
|
const isAdmin = await checkAdminAccess();
|
||||||
|
if (!isAdmin) {
|
||||||
|
return { success: false, error: 'No autorizado. Se requieren permisos de administrador.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return { success: false, error: 'ID de usuario es requerido.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const admin = getAdminServices();
|
||||||
|
await admin.users.delete({
|
||||||
|
userId
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/dashboard/users');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error eliminando usuario ${userId}:`, error);
|
||||||
|
return { success: false, error: error.message || 'Error al eliminar la cuenta de usuario.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. OBTENER ESTADÍSTICAS RÁPIDAS DE USUARIOS
|
||||||
|
export async function getUserStatsAction() {
|
||||||
|
const isAdmin = await checkAdminAccess();
|
||||||
|
if (!isAdmin) {
|
||||||
|
return { success: false, error: 'No autorizado.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const admin = getAdminServices();
|
||||||
|
|
||||||
|
// Obtenemos los primeros 100 usuarios para calcular métricas (Appwrite no da un endpoint de conteo agrupado directo)
|
||||||
|
const response = await admin.users.list({
|
||||||
|
queries: [Query.limit(100)]
|
||||||
|
});
|
||||||
|
|
||||||
|
let total = response.total;
|
||||||
|
let active = 0;
|
||||||
|
let blocked = 0;
|
||||||
|
|
||||||
|
// Calculamos en base al listado obtenido
|
||||||
|
response.users.forEach(u => {
|
||||||
|
if (u.status) {
|
||||||
|
active++;
|
||||||
|
} else {
|
||||||
|
blocked++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Si hay más de 100 usuarios, el total de Appwrite es el real.
|
||||||
|
// Hacemos un ajuste proporcional o dejamos el cálculo del muestreo para activos/bloqueados
|
||||||
|
if (total > 100) {
|
||||||
|
// Hacemos una aproximación o podemos listar más si es necesario,
|
||||||
|
// pero para dashboards normales esto es eficiente y suficiente
|
||||||
|
const remaining = total - 100;
|
||||||
|
// Asumimos proporcionalmente o simplemente sumamos al total real
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
total,
|
||||||
|
active: response.users.filter(u => u.status).length + (total > 100 ? Math.round((total - 100) * 0.95) : 0), // estimación de activos si supera los 100
|
||||||
|
blocked: response.users.filter(u => !u.status).length + (total > 100 ? Math.round((total - 100) * 0.05) : 0) // estimación de bloqueados
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error calculando estadísticas de usuarios:', error);
|
||||||
|
return { success: false, error: error.message || 'Error al calcular métricas.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
319
src/app/dashboard/users/page.tsx
Normal file
319
src/app/dashboard/users/page.tsx
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { UserPlus, RefreshCw } from 'lucide-react';
|
||||||
|
import { UserStats } from '@/components/users/UserStats';
|
||||||
|
import { UserTable } from '@/components/users/UserTable';
|
||||||
|
import { UserModal } from '@/components/users/UserModal';
|
||||||
|
import { ConfirmActionModal } from '@/components/users/ConfirmActionModal';
|
||||||
|
import {
|
||||||
|
listUsersAction,
|
||||||
|
createUserAction,
|
||||||
|
updateUserAction,
|
||||||
|
changeUserPasswordAction,
|
||||||
|
toggleUserStatusAction,
|
||||||
|
deleteUserAction,
|
||||||
|
getUserStatsAction,
|
||||||
|
} from '@/app/actions/user-admin';
|
||||||
|
|
||||||
|
const USERS_PER_PAGE = 10;
|
||||||
|
|
||||||
|
type ModalMode = 'create' | 'edit' | 'password';
|
||||||
|
type ConfirmActionType = 'block' | 'unblock' | 'delete' | null;
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: number;
|
||||||
|
type: 'success' | 'error';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersAdminPage() {
|
||||||
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [loadingList, setLoadingList] = useState(true);
|
||||||
|
|
||||||
|
const [stats, setStats] = useState<{ total: number; active: number; blocked: number } | null>(null);
|
||||||
|
const [loadingStats, setLoadingStats] = useState(true);
|
||||||
|
|
||||||
|
// State de los modales
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [modalMode, setModalMode] = useState<ModalMode>('create');
|
||||||
|
const [modalUser, setModalUser] = useState<any | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmActionType>(null);
|
||||||
|
const [confirmUser, setConfirmUser] = useState<any | null>(null);
|
||||||
|
|
||||||
|
// Toast / Notificaciones
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
const toastCounter = useRef(0);
|
||||||
|
|
||||||
|
// Calcular total de páginas
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / USERS_PER_PAGE));
|
||||||
|
|
||||||
|
const showToast = (type: 'success' | 'error', message: string) => {
|
||||||
|
const id = ++toastCounter.current;
|
||||||
|
setToasts(prev => [...prev, { id, type, message }]);
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cargar usuarios
|
||||||
|
const loadUsers = useCallback(async (page = 1, search = '') => {
|
||||||
|
setLoadingList(true);
|
||||||
|
try {
|
||||||
|
const res = await listUsersAction(page, search);
|
||||||
|
if (res.success) {
|
||||||
|
setUsers(res.users || []);
|
||||||
|
setTotal(res.total || 0);
|
||||||
|
} else {
|
||||||
|
showToast('error', res.error || 'Error al cargar usuarios.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast('error', err?.message || 'Error de conexión al obtener usuarios.');
|
||||||
|
} finally {
|
||||||
|
setLoadingList(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cargar estadísticas
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
setLoadingStats(true);
|
||||||
|
try {
|
||||||
|
const res = await getUserStatsAction();
|
||||||
|
if (res.success) {
|
||||||
|
setStats({ total: res.total ?? 0, active: res.active ?? 0, blocked: res.blocked ?? 0 });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Estadísticas no críticas: silenciar error de UI
|
||||||
|
} finally {
|
||||||
|
setLoadingStats(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cargar datos iniciales
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers(currentPage, searchQuery);
|
||||||
|
loadStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Búsqueda con debounce
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
loadUsers(1, searchQuery);
|
||||||
|
}, 450);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Cambio de página
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
if (page < 1 || page > totalPages) return;
|
||||||
|
setCurrentPage(page);
|
||||||
|
loadUsers(page, searchQuery);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refrescar todo
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadUsers(currentPage, searchQuery);
|
||||||
|
loadStats();
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Handlers de apertura de modales
|
||||||
|
// -------------------------
|
||||||
|
const openCreateModal = () => {
|
||||||
|
setModalMode('create');
|
||||||
|
setModalUser(null);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (user: any) => {
|
||||||
|
setModalMode('edit');
|
||||||
|
setModalUser(user);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPasswordModal = (user: any) => {
|
||||||
|
setModalMode('password');
|
||||||
|
setModalUser(user);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openConfirmModal = (user: any, action: ConfirmActionType) => {
|
||||||
|
setConfirmUser(user);
|
||||||
|
setConfirmAction(action);
|
||||||
|
setConfirmOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = (user: any, currentlyActive: boolean) => {
|
||||||
|
// Si está activo, preguntar para bloquear; si está bloqueado, preguntar para desbloquear
|
||||||
|
openConfirmModal(user, currentlyActive ? 'block' : 'unblock');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (user: any) => {
|
||||||
|
openConfirmModal(user, 'delete');
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Submit del UserModal
|
||||||
|
// -------------------------
|
||||||
|
const handleModalSubmit = async (data: any) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
let res: any;
|
||||||
|
if (modalMode === 'create') {
|
||||||
|
res = await createUserAction(data.name, data.email, data.password);
|
||||||
|
} else if (modalMode === 'edit') {
|
||||||
|
res = await updateUserAction(modalUser.$id, data.name, data.email, data.labels);
|
||||||
|
} else if (modalMode === 'password') {
|
||||||
|
res = await changeUserPasswordAction(modalUser.$id, data.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res?.success) {
|
||||||
|
setModalOpen(false);
|
||||||
|
showToast('success', modalMode === 'create'
|
||||||
|
? 'Usuario creado exitosamente.'
|
||||||
|
: modalMode === 'edit'
|
||||||
|
? 'Usuario actualizado correctamente.'
|
||||||
|
: 'Contraseña restablecida con éxito.');
|
||||||
|
loadUsers(currentPage, searchQuery);
|
||||||
|
loadStats();
|
||||||
|
} else {
|
||||||
|
throw new Error(res?.error || 'Ocurrió un error inesperado.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast('error', err.message || 'Error al procesar la operación.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------
|
||||||
|
// Submit del ConfirmActionModal
|
||||||
|
// -------------------------
|
||||||
|
const handleConfirmAction = async () => {
|
||||||
|
if (!confirmAction || !confirmUser) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
let res: any;
|
||||||
|
if (confirmAction === 'delete') {
|
||||||
|
res = await deleteUserAction(confirmUser.$id);
|
||||||
|
} else if (confirmAction === 'block') {
|
||||||
|
res = await toggleUserStatusAction(confirmUser.$id, true);
|
||||||
|
} else if (confirmAction === 'unblock') {
|
||||||
|
res = await toggleUserStatusAction(confirmUser.$id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res?.success) {
|
||||||
|
setConfirmOpen(false);
|
||||||
|
const msg = confirmAction === 'delete'
|
||||||
|
? 'Usuario eliminado permanentemente.'
|
||||||
|
: confirmAction === 'block'
|
||||||
|
? 'Cuenta bloqueada correctamente.'
|
||||||
|
: 'Cuenta desbloqueada exitosamente.';
|
||||||
|
showToast('success', msg);
|
||||||
|
loadUsers(currentPage, searchQuery);
|
||||||
|
loadStats();
|
||||||
|
} else {
|
||||||
|
throw new Error(res?.error || 'Error al ejecutar la acción.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
showToast('error', err.message || 'Error al confirmar la acción.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header de la Página */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-black tracking-tight text-white uppercase">
|
||||||
|
Gestión de Usuarios
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm font-medium text-slate-400 mt-1">
|
||||||
|
Administra las cuentas, roles y accesos de los usuarios registrados en Appwrite.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={loadingList}
|
||||||
|
className="p-2.5 rounded-xl bg-slate-900 border border-slate-800 text-slate-400 hover:text-white hover:border-slate-700 transition-all disabled:opacity-50"
|
||||||
|
title="Recargar datos"
|
||||||
|
>
|
||||||
|
<RefreshCw size={17} className={loadingList ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={openCreateModal}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-cyan-500 hover:bg-cyan-400 text-slate-950 text-xs font-black uppercase tracking-widest rounded-xl transition-all shadow-lg hover:shadow-cyan-500/20"
|
||||||
|
>
|
||||||
|
<UserPlus size={16} />
|
||||||
|
Nuevo Usuario
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Estadísticas de Usuarios */}
|
||||||
|
<UserStats stats={stats} loading={loadingStats} />
|
||||||
|
|
||||||
|
{/* Tabla de Usuarios */}
|
||||||
|
<UserTable
|
||||||
|
users={users}
|
||||||
|
loading={loadingList}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onEdit={openEditModal}
|
||||||
|
onChangePassword={openPasswordModal}
|
||||||
|
onToggleStatus={handleToggleStatus}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal: Crear / Editar / Cambiar Contraseña */}
|
||||||
|
<UserModal
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={() => !submitting && setModalOpen(false)}
|
||||||
|
mode={modalMode}
|
||||||
|
user={modalUser}
|
||||||
|
onSubmit={handleModalSubmit}
|
||||||
|
submitting={submitting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal: Confirmación de Acción Crítica */}
|
||||||
|
<ConfirmActionModal
|
||||||
|
isOpen={confirmOpen}
|
||||||
|
onClose={() => !submitting && setConfirmOpen(false)}
|
||||||
|
actionType={confirmAction}
|
||||||
|
user={confirmUser}
|
||||||
|
onConfirm={handleConfirmAction}
|
||||||
|
submitting={submitting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Toast Notifications */}
|
||||||
|
<div className="fixed bottom-6 right-6 flex flex-col gap-3 z-50 pointer-events-none">
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<div
|
||||||
|
key={toast.id}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3.5 rounded-xl shadow-2xl border backdrop-blur-md text-xs font-bold max-w-xs animate-in slide-in-from-bottom-2 duration-300 pointer-events-auto ${
|
||||||
|
toast.type === 'success'
|
||||||
|
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-300'
|
||||||
|
: 'bg-rose-500/10 border-rose-500/30 text-rose-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full shrink-0 ${toast.type === 'success' ? 'bg-emerald-400' : 'bg-rose-400'}`} />
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { LayoutDashboard, Newspaper, Folders, Building2, MapPin, BadgeDollarSign, FileText, LogOut, ChevronDown, ChevronRight, Image as ImageIcon, Settings, Calendar, RadioTower } from 'lucide-react';
|
import { LayoutDashboard, Newspaper, Folders, Building2, MapPin, BadgeDollarSign, FileText, LogOut, ChevronDown, ChevronRight, Image as ImageIcon, Settings, Calendar, RadioTower, Users } from 'lucide-react';
|
||||||
import { logout } from '@/app/actions/auth';
|
import { logout } from '@/app/actions/auth';
|
||||||
|
|
||||||
export const Sidebar = ({ user }: { user: any }) => {
|
export const Sidebar = ({ user }: { user: any }) => {
|
||||||
@ -61,6 +61,13 @@ export const Sidebar = ({ user }: { user: any }) => {
|
|||||||
{ name: 'Gestor StreamingTV', href: '/dashboard/streaming', icon: RadioTower },
|
{ name: 'Gestor StreamingTV', href: '/dashboard/streaming', icon: RadioTower },
|
||||||
{ name: 'Configuración Global', href: '/dashboard/settings', icon: Settings },
|
{ name: 'Configuración Global', href: '/dashboard/settings', icon: Settings },
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'administracion',
|
||||||
|
title: 'Administración',
|
||||||
|
items: [
|
||||||
|
{ name: 'Gestión de Usuarios', href: '/dashboard/users', icon: Users },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export default function EntityLogoField({
|
|||||||
|
|
||||||
{/* Enviamos el string del ID o de la URL para que entity.ts la guarde.
|
{/* Enviamos el string del ID o de la URL para que entity.ts la guarde.
|
||||||
Adaptaremos entity.ts para que si 'logotipo' es texto puro, lo guarde tal cual. */}
|
Adaptaremos entity.ts para que si 'logotipo' es texto puro, lo guarde tal cual. */}
|
||||||
<input type="hidden" name="logotipo" value={finalUrl || ''} />
|
<input type="hidden" name="logotipo" value={mediaId || ''} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
146
src/components/users/ConfirmActionModal.tsx
Normal file
146
src/components/users/ConfirmActionModal.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { AlertTriangle, ShieldAlert, ShieldCheck, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ConfirmActionModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
actionType: 'block' | 'unblock' | 'delete' | null;
|
||||||
|
user: any | null;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
submitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConfirmActionModal: React.FC<ConfirmActionModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
actionType,
|
||||||
|
user,
|
||||||
|
onConfirm,
|
||||||
|
submitting,
|
||||||
|
}) => {
|
||||||
|
if (!isOpen || !actionType || !user) return null;
|
||||||
|
|
||||||
|
const getModalConfig = () => {
|
||||||
|
switch (actionType) {
|
||||||
|
case 'delete':
|
||||||
|
return {
|
||||||
|
title: '¿Eliminar Usuario de forma Permanente?',
|
||||||
|
description: `Estás a punto de borrar definitivamente la cuenta de ${user.name || 'este usuario'}.`,
|
||||||
|
warningText: 'Esta acción no se puede deshacer. Todos los datos de sesión y credenciales del usuario se eliminarán permanentemente de Appwrite.',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
iconColorClass: 'text-rose-400 bg-rose-500/10 border-rose-500/20',
|
||||||
|
confirmButtonClass: 'bg-rose-500 text-white hover:bg-rose-600 hover:shadow-rose-550/20',
|
||||||
|
confirmText: 'Sí, Eliminar Cuenta'
|
||||||
|
};
|
||||||
|
case 'block':
|
||||||
|
return {
|
||||||
|
title: '¿Bloquear Acceso del Usuario?',
|
||||||
|
description: `Estás a punto de suspender el acceso de ${user.name || 'este usuario'}.`,
|
||||||
|
warningText: 'El usuario no podrá iniciar sesión ni realizar ninguna acción en la plataforma. Su sesión actual será invalidada inmediatamente.',
|
||||||
|
icon: ShieldAlert,
|
||||||
|
iconColorClass: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
|
||||||
|
confirmButtonClass: 'bg-amber-500 text-slate-950 hover:bg-amber-400 hover:shadow-amber-550/20',
|
||||||
|
confirmText: 'Sí, Bloquear Usuario'
|
||||||
|
};
|
||||||
|
case 'unblock':
|
||||||
|
return {
|
||||||
|
title: '¿Desbloquear Acceso del Usuario?',
|
||||||
|
description: `Estás a punto de restaurar el acceso de ${user.name || 'este usuario'}.`,
|
||||||
|
warningText: 'El usuario podrá iniciar sesión de nuevo en la plataforma utilizando sus credenciales de acceso normales.',
|
||||||
|
icon: ShieldCheck,
|
||||||
|
iconColorClass: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20',
|
||||||
|
confirmButtonClass: 'bg-emerald-500 text-slate-950 hover:bg-emerald-400 hover:shadow-emerald-550/20',
|
||||||
|
confirmText: 'Sí, Desbloquear'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getModalConfig();
|
||||||
|
const IconComponent = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-slate-950/85 backdrop-blur-sm transition-opacity"
|
||||||
|
onClick={submitting ? undefined : onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Box */}
|
||||||
|
<div className="relative w-full max-w-md bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-2xl z-10 p-6">
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
className="absolute top-4 right-4 text-slate-500 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex flex-col items-center text-center mt-2">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={`p-4 rounded-full border mb-4 ${config.iconColorClass}`}>
|
||||||
|
<IconComponent size={28} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="text-lg font-black tracking-tight text-white uppercase mb-2">
|
||||||
|
{config.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Target User Info */}
|
||||||
|
<div className="bg-slate-950/80 border border-slate-800/80 rounded-xl px-4 py-2.5 mb-4 w-full">
|
||||||
|
<p className="text-xs font-bold text-white truncate">{user.name || 'Sin Nombre'}</p>
|
||||||
|
<p className="text-[11px] font-medium text-slate-400 truncate mt-0.5">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-xs font-medium text-slate-400 leading-relaxed mb-4">
|
||||||
|
{config.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Alert Box */}
|
||||||
|
<div className="p-3.5 bg-slate-950/40 border border-slate-850 rounded-xl mb-6 text-left">
|
||||||
|
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-rose-500"></span>
|
||||||
|
Advertencia Importante
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] font-semibold text-slate-400 leading-normal">
|
||||||
|
{config.warningText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Buttons */}
|
||||||
|
<div className="flex items-center justify-end gap-3 border-t border-slate-800/60 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 text-xs font-black uppercase tracking-widest text-slate-400 hover:text-white rounded-xl border border-slate-800 bg-slate-900/40 hover:bg-slate-800 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={submitting}
|
||||||
|
className={`px-5 py-2 text-xs font-black uppercase tracking-widest rounded-xl transition-all shadow-lg flex items-center gap-2 ${config.confirmButtonClass} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<span className="w-3.5 h-3.5 border-2 border-slate-950 border-t-transparent rounded-full animate-spin"></span>
|
||||||
|
Procesando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
config.confirmText
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
314
src/components/users/UserModal.tsx
Normal file
314
src/components/users/UserModal.tsx
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Lock, Mail, User, Shield, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
interface UserModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
mode: 'create' | 'edit' | 'password';
|
||||||
|
user: any | null;
|
||||||
|
onSubmit: (data: any) => Promise<void>;
|
||||||
|
submitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserModal: React.FC<UserModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
mode,
|
||||||
|
user,
|
||||||
|
onSubmit,
|
||||||
|
submitting,
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [labelsInput, setLabelsInput] = useState('');
|
||||||
|
const [isAdminChecked, setIsAdminChecked] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Rellenar datos cuando el modal se abre o cambia de usuario
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setError('');
|
||||||
|
if (mode === 'edit' && user) {
|
||||||
|
setName(user.name || '');
|
||||||
|
setEmail(user.email || '');
|
||||||
|
// Filtrar etiqueta 'admin' para el checkbox y el resto al input de texto
|
||||||
|
const labels: string[] = user.labels || [];
|
||||||
|
setIsAdminChecked(labels.includes('admin'));
|
||||||
|
const customLabels = labels.filter(l => l !== 'admin');
|
||||||
|
setLabelsInput(customLabels.join(', '));
|
||||||
|
} else {
|
||||||
|
// Modo creación o contraseña (limpiar campos)
|
||||||
|
setName('');
|
||||||
|
setEmail('');
|
||||||
|
setPassword('');
|
||||||
|
setIsAdminChecked(false);
|
||||||
|
setLabelsInput('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, mode, user]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Validaciones básicas
|
||||||
|
if (mode === 'create') {
|
||||||
|
if (!name.trim() || !email.trim() || !password.trim()) {
|
||||||
|
setError('Todos los campos son requeridos.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('La contraseña debe tener al menos 8 caracteres.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (mode === 'edit') {
|
||||||
|
if (!name.trim() || !email.trim()) {
|
||||||
|
setError('El nombre y el correo son obligatorios.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (mode === 'password') {
|
||||||
|
if (!password.trim()) {
|
||||||
|
setError('Debe especificar una nueva contraseña.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('La contraseña debe tener al menos 8 caracteres.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Formatear etiquetas/roles
|
||||||
|
let labels: string[] = [];
|
||||||
|
if (mode === 'edit') {
|
||||||
|
if (isAdminChecked) labels.push('admin');
|
||||||
|
if (labelsInput.trim()) {
|
||||||
|
const extra = labelsInput
|
||||||
|
.split(',')
|
||||||
|
.map(l => l.trim())
|
||||||
|
.filter(l => l.length > 0);
|
||||||
|
labels = [...labels, ...extra];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: any = {};
|
||||||
|
if (mode === 'create') {
|
||||||
|
payload.name = name.trim();
|
||||||
|
payload.email = email.trim();
|
||||||
|
payload.password = password;
|
||||||
|
} else if (mode === 'edit') {
|
||||||
|
payload.name = name.trim();
|
||||||
|
payload.email = email.trim();
|
||||||
|
payload.labels = labels;
|
||||||
|
} else if (mode === 'password') {
|
||||||
|
payload.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubmit(payload);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || 'Error al procesar el formulario.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTitle = () => {
|
||||||
|
if (mode === 'create') return 'Crear Nuevo Usuario';
|
||||||
|
if (mode === 'edit') return `Editar Usuario: ${user?.name || 'Perfil'}`;
|
||||||
|
return `Restablecer Contraseña`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm transition-opacity"
|
||||||
|
onClick={submitting ? undefined : onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Content */}
|
||||||
|
<div className="relative w-full max-w-lg bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-2xl z-10 max-h-[90vh] flex flex-col">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-5 border-b border-slate-800 flex items-center justify-between bg-slate-950/40">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-black tracking-tight text-white uppercase">{getTitle()}</h3>
|
||||||
|
{mode === 'password' && user && (
|
||||||
|
<p className="text-xs font-semibold text-slate-500 mt-0.5">Asignando nueva clave para: {user.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
className="text-slate-400 hover:text-white p-1.5 rounded-lg hover:bg-slate-800 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex-grow overflow-y-auto p-6 space-y-5">
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3.5 bg-rose-500/10 border border-rose-500/25 text-rose-400 rounded-xl text-xs font-bold leading-relaxed">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modo: CREATE o EDIT */}
|
||||||
|
{(mode === 'create' || mode === 'edit') && (
|
||||||
|
<>
|
||||||
|
{/* Campo: Nombre */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black tracking-widest text-slate-500 uppercase block">
|
||||||
|
Nombre Completo
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-500">
|
||||||
|
<User size={16} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
placeholder="Ej. Juan Pérez"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-10 pr-4 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all font-medium disabled:opacity-55"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Campo: Correo Electrónico */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black tracking-widest text-slate-500 uppercase block">
|
||||||
|
Correo Electrónico
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-500">
|
||||||
|
<Mail size={16} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
placeholder="correo@ejemplo.com"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-10 pr-4 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all font-medium disabled:opacity-55"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modo: CREATE o PASSWORD */}
|
||||||
|
{(mode === 'create' || mode === 'password') && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black tracking-widest text-slate-500 uppercase block">
|
||||||
|
Contraseña {mode === 'password' && '(Nueva)'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-500">
|
||||||
|
<Lock size={16} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
placeholder="Mínimo 8 caracteres"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl pl-10 pr-4 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all font-medium disabled:opacity-55"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modo: EDIT -> Gestión de Roles/Etiquetas */}
|
||||||
|
{mode === 'edit' && (
|
||||||
|
<div className="space-y-4 pt-2 border-t border-slate-800/60">
|
||||||
|
<h4 className="text-[10px] font-black tracking-widest text-slate-400 uppercase">Seguridad y Roles (Etiquetas)</h4>
|
||||||
|
|
||||||
|
{/* Checkbox Rol Admin */}
|
||||||
|
<label className={`flex items-center gap-3 p-3.5 rounded-xl border transition-all cursor-pointer select-none ${
|
||||||
|
isAdminChecked
|
||||||
|
? 'bg-amber-500/5 border-amber-500/30 text-amber-400'
|
||||||
|
: 'bg-slate-950 border-slate-850 hover:border-slate-800 text-slate-400'
|
||||||
|
}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isAdminChecked}
|
||||||
|
onChange={(e) => setIsAdminChecked(e.target.checked)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="rounded border-slate-800 bg-slate-950 text-cyan-500 focus:ring-0 focus:ring-offset-0 h-4 w-4 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-sm font-bold flex items-center gap-1.5">
|
||||||
|
<Shield size={14} />
|
||||||
|
Rol Administrador (`admin`)
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-medium opacity-70 mt-0.5">Concede acceso total al panel administrativo del CMS y a este módulo de usuarios.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Campo etiquetas personalizadas */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black tracking-widest text-slate-500 uppercase block">
|
||||||
|
Otras Etiquetas (Separadas por comas)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={labelsInput}
|
||||||
|
onChange={(e) => setLabelsInput(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
placeholder="editor, moderador, soporte"
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-sm text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all font-medium disabled:opacity-55 placeholder-slate-700"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-semibold text-slate-500 block leading-normal">
|
||||||
|
Las etiquetas se utilizan en Appwrite para otorgar permisos a colecciones de base de datos.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="px-6 py-4 border-t border-slate-800 bg-slate-950/40 flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 text-xs font-black uppercase tracking-widest text-slate-400 hover:text-white rounded-xl border border-slate-800 bg-slate-900/40 hover:bg-slate-800 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
className={`px-5 py-2 text-xs font-black uppercase tracking-widest rounded-xl transition-all shadow-lg flex items-center gap-2 ${
|
||||||
|
mode === 'password'
|
||||||
|
? 'bg-amber-500 text-slate-950 hover:bg-amber-400 hover:shadow-amber-550/20'
|
||||||
|
: 'bg-cyan-500 text-slate-950 hover:bg-cyan-400 hover:shadow-cyan-500/20'
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<span className="w-3.5 h-3.5 border-2 border-slate-950 border-t-transparent rounded-full animate-spin"></span>
|
||||||
|
Guardando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check size={14} />
|
||||||
|
{mode === 'create' ? 'Crear Cuenta' : mode === 'edit' ? 'Guardar Cambios' : 'Restablecer'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
96
src/components/users/UserStats.tsx
Normal file
96
src/components/users/UserStats.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Users, UserCheck, UserX } from 'lucide-react';
|
||||||
|
|
||||||
|
interface UserStatsProps {
|
||||||
|
stats: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
blocked: number;
|
||||||
|
} | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserStats: React.FC<UserStatsProps> = ({ stats, loading }) => {
|
||||||
|
if (loading || !stats) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-slate-900 border border-slate-800/80 rounded-2xl p-6 animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="h-4 bg-slate-800 rounded w-24"></div>
|
||||||
|
<div className="h-10 w-10 bg-slate-800 rounded-xl"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-8 bg-slate-800 rounded w-16 mb-2"></div>
|
||||||
|
<div className="h-3 bg-slate-800 rounded w-32"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
title: 'Total Usuarios',
|
||||||
|
value: stats.total,
|
||||||
|
description: 'Usuarios registrados en Appwrite',
|
||||||
|
icon: Users,
|
||||||
|
colorClass: 'from-blue-500/20 to-cyan-500/10 border-cyan-500/30',
|
||||||
|
iconColor: 'text-cyan-400',
|
||||||
|
bgGrad: 'bg-gradient-to-r from-blue-400 to-cyan-400'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Usuarios Activos',
|
||||||
|
value: stats.active,
|
||||||
|
description: 'Cuentas con acceso habilitado',
|
||||||
|
icon: UserCheck,
|
||||||
|
colorClass: 'from-emerald-500/20 to-teal-500/10 border-emerald-500/30',
|
||||||
|
iconColor: 'text-emerald-400',
|
||||||
|
bgGrad: 'bg-gradient-to-r from-emerald-400 to-teal-400'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Usuarios Bloqueados',
|
||||||
|
value: stats.blocked,
|
||||||
|
description: 'Cuentas con acceso restringido',
|
||||||
|
icon: UserX,
|
||||||
|
colorClass: 'from-rose-500/20 to-red-500/10 border-rose-500/30',
|
||||||
|
iconColor: 'text-rose-400',
|
||||||
|
bgGrad: 'bg-gradient-to-r from-rose-400 to-red-400'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
{cards.map((card, idx) => {
|
||||||
|
const IconComponent = card.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`bg-slate-900/50 backdrop-blur-md border rounded-2xl p-6 flex flex-col justify-between transition-all duration-300 hover:scale-[1.02] hover:shadow-xl hover:shadow-slate-950/50 bg-gradient-to-br ${card.colorClass}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest text-slate-400">
|
||||||
|
{card.title}
|
||||||
|
</span>
|
||||||
|
<div className={`p-2.5 rounded-xl bg-slate-950/60 border border-slate-800 ${card.iconColor}`}>
|
||||||
|
<IconComponent size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className={`text-4xl font-black tracking-tight ${card.bgGrad} bg-clip-text text-transparent mb-1`}>
|
||||||
|
{card.value.toLocaleString()}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs font-semibold text-slate-500">
|
||||||
|
{card.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
253
src/components/users/UserTable.tsx
Normal file
253
src/components/users/UserTable.tsx
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Edit2, Key, ShieldAlert, ShieldCheck, Trash2, Search, ChevronLeft, ChevronRight, User } from 'lucide-react';
|
||||||
|
|
||||||
|
interface UserTableProps {
|
||||||
|
users: any[];
|
||||||
|
loading: boolean;
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onEdit: (user: any) => void;
|
||||||
|
onChangePassword: (user: any) => void;
|
||||||
|
onToggleStatus: (user: any, block: boolean) => void;
|
||||||
|
onDelete: (user: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserTable: React.FC<UserTableProps> = ({
|
||||||
|
users,
|
||||||
|
loading,
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
onEdit,
|
||||||
|
onChangePassword,
|
||||||
|
onToggleStatus,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
// Formateador de fecha
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('es-ES', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900/40 backdrop-blur-md border border-slate-800/80 rounded-2xl overflow-hidden shadow-2xl">
|
||||||
|
{/* Header / Filtro de búsqueda */}
|
||||||
|
<div className="p-6 border-b border-slate-800 flex flex-col md:flex-row md:items-center justify-between gap-4 bg-slate-900/60">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-black tracking-tight text-white uppercase">Lista de Usuarios</h3>
|
||||||
|
<p className="text-xs font-semibold text-slate-500 mt-0.5">Administración y control de cuentas activas en Appwrite</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full md:w-80">
|
||||||
|
<span className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-500">
|
||||||
|
<Search size={18} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nombre, email o ID..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="w-full bg-slate-950/80 border border-slate-800 rounded-xl pl-10 pr-4 py-2.5 text-sm text-white focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all placeholder-slate-600 font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabla */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-800 text-[10px] font-black tracking-widest text-slate-500 uppercase bg-slate-950/20">
|
||||||
|
<th className="py-4 px-6">Usuario</th>
|
||||||
|
<th className="py-4 px-6">ID Cuenta</th>
|
||||||
|
<th className="py-4 px-6">Roles / Labels</th>
|
||||||
|
<th className="py-4 px-6">Registro</th>
|
||||||
|
<th className="py-4 px-6">Estado</th>
|
||||||
|
<th className="py-4 px-6 text-right">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
|
{loading ? (
|
||||||
|
// Skeleton rows
|
||||||
|
[...Array(5)].map((_, i) => (
|
||||||
|
<tr key={i} className="animate-pulse">
|
||||||
|
<td className="py-5 px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 bg-slate-800 rounded-xl"></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 bg-slate-800 rounded w-28"></div>
|
||||||
|
<div className="h-3 bg-slate-800 rounded w-36"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-5 px-6"><div className="h-4 bg-slate-800 rounded w-20"></div></td>
|
||||||
|
<td className="py-5 px-6"><div className="h-5 bg-slate-800 rounded-full w-16"></div></td>
|
||||||
|
<td className="py-5 px-6"><div className="h-4 bg-slate-800 rounded w-24"></div></td>
|
||||||
|
<td className="py-5 px-6"><div className="h-6 bg-slate-800 rounded-full w-16"></div></td>
|
||||||
|
<td className="py-5 px-6 text-right"><div className="h-8 bg-slate-800 rounded w-24 ml-auto"></div></td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
// Empty State
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-16 text-center">
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<div className="p-4 rounded-full bg-slate-950 border border-slate-800 text-slate-600 mb-4">
|
||||||
|
<User size={32} />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-sm font-black text-white uppercase tracking-wider">No se encontraron usuarios</h4>
|
||||||
|
<p className="text-xs font-semibold text-slate-500 mt-1 max-w-xs">
|
||||||
|
{searchQuery
|
||||||
|
? 'Ningún usuario coincide con los términos de búsqueda provistos.'
|
||||||
|
: 'No existen registros de usuarios creados en este proyecto.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
// Rows
|
||||||
|
users.map((user) => (
|
||||||
|
<tr key={user.$id} className="hover:bg-slate-900/30 transition-colors">
|
||||||
|
<td className="py-4 px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-xl bg-slate-950 border border-slate-800 flex items-center justify-center text-slate-400 font-bold shrink-0">
|
||||||
|
{user.name ? user.name.charAt(0).toUpperCase() : <User size={18} />}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-sm font-bold text-white truncate max-w-[180px]">{user.name || 'Sin Nombre'}</span>
|
||||||
|
<span className="text-xs font-medium text-slate-400 truncate max-w-[200px]" title={user.email}>{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6">
|
||||||
|
<span className="text-xs font-mono font-bold text-slate-500 bg-slate-950 px-2.5 py-1 rounded-lg border border-slate-800/60" title={user.$id}>
|
||||||
|
{user.$id}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{user.labels && user.labels.length > 0 ? (
|
||||||
|
user.labels.map((label: string) => {
|
||||||
|
const isAdmin = label === 'admin';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={label}
|
||||||
|
className={`text-[9px] font-black uppercase tracking-wider px-2 py-0.5 rounded ${
|
||||||
|
isAdmin
|
||||||
|
? 'bg-amber-500/10 text-amber-400 border border-amber-500/25'
|
||||||
|
: 'bg-cyan-500/10 text-cyan-400 border border-cyan-500/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] font-semibold text-slate-600 italic">Ninguno</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6">
|
||||||
|
<span className="text-xs font-bold text-slate-400">
|
||||||
|
{formatDate(user.registration)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6">
|
||||||
|
{user.status ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[9px] font-black uppercase tracking-widest px-2.5 py-1 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/25">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||||
|
Activo
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[9px] font-black uppercase tracking-widest px-2.5 py-1 rounded-full bg-rose-500/10 text-rose-400 border border-rose-500/25">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-rose-400"></span>
|
||||||
|
Bloqueado
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(user)}
|
||||||
|
title="Editar perfil y etiquetas"
|
||||||
|
className="p-2 text-slate-400 hover:text-cyan-400 hover:bg-slate-800 rounded-xl transition-all border border-transparent hover:border-slate-700/60"
|
||||||
|
>
|
||||||
|
<Edit2 size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onChangePassword(user)}
|
||||||
|
title="Cambiar contraseña"
|
||||||
|
className="p-2 text-slate-400 hover:text-amber-400 hover:bg-slate-800 rounded-xl transition-all border border-transparent hover:border-slate-700/60"
|
||||||
|
>
|
||||||
|
<Key size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleStatus(user, user.status)}
|
||||||
|
title={user.status ? 'Bloquear cuenta' : 'Desbloquear cuenta'}
|
||||||
|
className={`p-2 rounded-xl transition-all border border-transparent hover:border-slate-700/60 ${
|
||||||
|
user.status
|
||||||
|
? 'text-slate-400 hover:text-rose-400 hover:bg-slate-800'
|
||||||
|
: 'text-rose-400 hover:text-emerald-400 hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.status ? <ShieldAlert size={15} /> : <ShieldCheck size={15} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(user)}
|
||||||
|
title="Eliminar permanentemente"
|
||||||
|
className="p-2 text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 rounded-xl transition-all border border-transparent hover:border-rose-500/20"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paginación */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="p-6 border-t border-slate-800 bg-slate-900/60 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-slate-500">
|
||||||
|
Página <span className="text-slate-300">{currentPage}</span> de <span className="text-slate-300">{totalPages}</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1 || loading}
|
||||||
|
className="p-2 rounded-xl border border-slate-800 bg-slate-950/60 hover:bg-slate-900 text-slate-400 hover:text-white disabled:opacity-40 disabled:hover:bg-slate-950/60 disabled:hover:text-slate-400 transition-all"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages || loading}
|
||||||
|
className="p-2 rounded-xl border border-slate-800 bg-slate-950/60 hover:bg-slate-900 text-slate-400 hover:text-white disabled:opacity-40 disabled:hover:bg-slate-950/60 disabled:hover:text-slate-400 transition-all"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user