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:
Lizandro Guarnizo
2026-07-02 14:55:16 -05:00
co-authored by Claude Sonnet 4.6
parent 04f1801b89
commit a8842a2728
7 changed files with 124 additions and 20 deletions
+97 -17
View File
@@ -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>
+8 -1
View File
@@ -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;
+1
View File
@@ -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")
+5 -1
View File
@@ -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 {
+8 -1
View File
@@ -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);
}
}
+4
View File
@@ -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 } });
}
}