feat(users): add block/unblock and delete from admin panel
- Migration 0004: adds is_active column to users table - Backend: DELETE /users/:id endpoint + is_active field in UpdateUserDto - Admin UI: block/unblock toggle and delete with confirmation on user detail - Users list: shows "Bloqueado" badge for inactive users Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
04f1801b89
commit
a8842a2728
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ArrowLeft, Pencil, X, Check } from 'lucide-react';
|
||||
import { ArrowLeft, Pencil, X, Check, ShieldOff, Shield, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Professional { id: string; profession?: string; rate?: number; identification?: string; }
|
||||
@@ -16,7 +16,7 @@ interface Reputation { total: number; average: number; total_pro: number; averag
|
||||
interface UserDetail {
|
||||
id: string; name: string; email?: string; phone?: string; city?: string;
|
||||
gender?: string; birthday?: string; is_email_verified?: boolean;
|
||||
is_phone_verified?: boolean; pro_state?: number; created_at: string;
|
||||
is_phone_verified?: boolean; is_active?: boolean; pro_state?: number; created_at: string;
|
||||
professionals?: Professional | null; reputations?: Reputation | null;
|
||||
}
|
||||
|
||||
@@ -29,12 +29,16 @@ interface Form { name: string; city: string; phone: string; gender: string; }
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const id = params?.id;
|
||||
const [user, setUser] = useState<UserDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [form, setForm] = useState<Form>({ name: '', city: '', phone: '', gender: '' });
|
||||
|
||||
const load = useCallback(() => {
|
||||
@@ -71,6 +75,34 @@ export default function UserDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBlock = async () => {
|
||||
if (!user) return;
|
||||
setToggling(true);
|
||||
try {
|
||||
const updated = await api.patch<UserDetail>(`/users/${id}`, { is_active: !user.is_active });
|
||||
setUser(updated);
|
||||
toast.success(updated.is_active ? 'Usuario desbloqueado' : 'Usuario bloqueado');
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || 'Error al cambiar estado');
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.delete(`/users/${id}`);
|
||||
toast.success('Usuario eliminado');
|
||||
router.push('/users');
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || 'No se puede eliminar: el usuario tiene datos asociados');
|
||||
setConfirmDelete(false);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>;
|
||||
if (error) return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-destructive gap-2">
|
||||
@@ -81,32 +113,72 @@ export default function UserDetailPage() {
|
||||
if (!user) return null;
|
||||
|
||||
const proState = user.pro_state ?? 0;
|
||||
const isActive = user.is_active !== false;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/users"><Button variant="ghost" size="icon"><ArrowLeft className="h-5 w-5" /></Button></Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{user.name}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold">{user.name}</h1>
|
||||
{!isActive && <Badge variant="destructive">Bloqueado</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">ID: {user.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!editing ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||
<Pencil className="mr-1 h-4 w-4" /> Editar
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Bloquear / Desbloquear */}
|
||||
<Button
|
||||
variant={isActive ? 'outline' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={toggleBlock}
|
||||
disabled={toggling}
|
||||
className={isActive ? 'border-orange-300 text-orange-600 hover:bg-orange-50' : ''}
|
||||
>
|
||||
{isActive
|
||||
? <><ShieldOff className="mr-1 h-4 w-4" />{toggling ? 'Bloqueando...' : 'Bloquear'}</>
|
||||
: <><Shield className="mr-1 h-4 w-4" />{toggling ? 'Desbloqueando...' : 'Desbloquear'}</>
|
||||
}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => { setEditing(false); setForm({ name: user.name, city: user.city || '', phone: user.phone || '', gender: user.gender || '' }); }} disabled={saving}>
|
||||
<X className="mr-1 h-4 w-4" /> Cancelar
|
||||
|
||||
{/* Eliminar */}
|
||||
{!confirmDelete ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmDelete(true)}
|
||||
className="border-red-300 text-red-600 hover:bg-red-50">
|
||||
<Trash2 className="mr-1 h-4 w-4" /> Eliminar
|
||||
</Button>
|
||||
<Button size="sm" onClick={save} disabled={saving}>
|
||||
<Check className="mr-1 h-4 w-4" /> {saving ? 'Guardando...' : 'Guardar'}
|
||||
) : (
|
||||
<div className="flex items-center gap-1 rounded-md border border-red-300 bg-red-50 px-3 py-1.5">
|
||||
<span className="text-xs text-red-600 font-medium mr-1">¿Confirmar?</span>
|
||||
<Button variant="destructive" size="sm" onClick={deleteUser} disabled={deleting} className="h-7 px-2 text-xs">
|
||||
{deleting ? 'Eliminando...' : 'Sí, eliminar'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setConfirmDelete(false)} className="h-7 px-2 text-xs">
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editar */}
|
||||
{!editing ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||
<Pencil className="mr-1 h-4 w-4" /> Editar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => { setEditing(false); setForm({ name: user.name, city: user.city || '', phone: user.phone || '', gender: user.gender || '' }); }} disabled={saving}>
|
||||
<X className="mr-1 h-4 w-4" /> Cancelar
|
||||
</Button>
|
||||
<Button size="sm" onClick={save} disabled={saving}>
|
||||
<Check className="mr-1 h-4 w-4" /> {saving ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
@@ -170,6 +242,14 @@ export default function UserDetailPage() {
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Estado</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Cuenta</span>
|
||||
<div className="mt-2">
|
||||
<Badge variant={isActive ? 'default' : 'destructive'}>
|
||||
{isActive ? 'Activo' : 'Bloqueado'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Estado profesional</span>
|
||||
<div className="mt-2">
|
||||
@@ -201,7 +281,7 @@ export default function UserDetailPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Perfil profesional (solo lectura, con link) */}
|
||||
{/* Perfil profesional */}
|
||||
{user.professionals && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search, Eye } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface User {
|
||||
@@ -15,6 +16,7 @@ interface User {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
city?: string;
|
||||
is_active?: boolean;
|
||||
pro_state?: number;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -91,7 +93,12 @@ export default function UsersPage() {
|
||||
<TableCell>{u.email || '—'}</TableCell>
|
||||
<TableCell>{u.phone || '—'}</TableCell>
|
||||
<TableCell>{u.city || '—'}</TableCell>
|
||||
<TableCell>{['Usuario', 'Solicitó', 'Profesional', 'Rechazado'][u.pro_state ?? 0] || '—'}</TableCell>
|
||||
<TableCell>
|
||||
{u.is_active === false
|
||||
? <Badge variant="destructive">Bloqueado</Badge>
|
||||
: <span>{['Usuario', 'Solicitó', 'Profesional', 'Rechazado'][u.pro_state ?? 0] || '—'}</span>
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>{new Date(u.created_at).toLocaleDateString()}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/users/${u.id}`}>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "is_active" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -227,6 +227,7 @@ model users {
|
||||
fcm_token String?
|
||||
is_phone_verified Boolean? @default(false)
|
||||
is_email_verified Boolean? @default(false)
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
chats_chats_professional_idTousers chats[] @relation("chats_professional_idTousers")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsEmail, IsString, MinLength, IsOptional, IsPhoneNumber, Matches } from 'class-validator';
|
||||
import { IsEmail, IsString, MinLength, IsOptional, IsPhoneNumber, Matches, IsBoolean } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
@@ -54,6 +54,10 @@ export class UpdateUserDto {
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'birthday must be YYYY-MM-DD' })
|
||||
@IsOptional()
|
||||
birthday?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export class FcmTokenDto {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Patch, Param, Body, UseGuards, Req } from '@nestjs/common';
|
||||
import { Controller, Get, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
@@ -48,4 +48,11 @@ export class UsersController {
|
||||
findById(@Param('id') id: string) {
|
||||
return this.users.findById(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
deleteById(@Param('id') id: string) {
|
||||
return this.users.deleteById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,4 +31,8 @@ export class UsersService {
|
||||
updateFcmToken(id: string, fcm_token: string) {
|
||||
return this.prisma.users.update({ where: { id }, data: { fcm_token } });
|
||||
}
|
||||
|
||||
deleteById(id: string) {
|
||||
return this.prisma.users.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user