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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
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';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface Professional { id: string; profession?: string; rate?: number; identification?: string; }
|
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 {
|
interface UserDetail {
|
||||||
id: string; name: string; email?: string; phone?: string; city?: string;
|
id: string; name: string; email?: string; phone?: string; city?: string;
|
||||||
gender?: string; birthday?: string; is_email_verified?: boolean;
|
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;
|
professionals?: Professional | null; reputations?: Reputation | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,12 +29,16 @@ interface Form { name: string; city: string; phone: string; gender: string; }
|
|||||||
|
|
||||||
export default function UserDetailPage() {
|
export default function UserDetailPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
const id = params?.id;
|
const id = params?.id;
|
||||||
const [user, setUser] = useState<UserDetail | null>(null);
|
const [user, setUser] = useState<UserDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [saving, setSaving] = 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 [form, setForm] = useState<Form>({ name: '', city: '', phone: '', gender: '' });
|
||||||
|
|
||||||
const load = useCallback(() => {
|
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 (loading) return <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>;
|
||||||
if (error) return (
|
if (error) return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-destructive gap-2">
|
<div className="flex flex-col items-center justify-center py-16 text-destructive gap-2">
|
||||||
@@ -81,18 +113,57 @@ export default function UserDetailPage() {
|
|||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const proState = user.pro_state ?? 0;
|
const proState = user.pro_state ?? 0;
|
||||||
|
const isActive = user.is_active !== false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* 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">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/users"><Button variant="ghost" size="icon"><ArrowLeft className="h-5 w-5" /></Button></Link>
|
<Link href="/users"><Button variant="ghost" size="icon"><ArrowLeft className="h-5 w-5" /></Button></Link>
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-2xl font-bold">{user.name}</h1>
|
<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>
|
<p className="text-sm text-muted-foreground">ID: {user.id}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
) : (
|
||||||
|
<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 ? (
|
{!editing ? (
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||||
<Pencil className="mr-1 h-4 w-4" /> Editar
|
<Pencil className="mr-1 h-4 w-4" /> Editar
|
||||||
@@ -108,6 +179,7 @@ export default function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
{/* Info general */}
|
{/* Info general */}
|
||||||
@@ -170,6 +242,14 @@ export default function UserDetailPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>Estado</CardTitle></CardHeader>
|
<CardHeader><CardTitle>Estado</CardTitle></CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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>
|
<div>
|
||||||
<span className="text-sm text-muted-foreground">Estado profesional</span>
|
<span className="text-sm text-muted-foreground">Estado profesional</span>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@@ -201,7 +281,7 @@ export default function UserDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Perfil profesional (solo lectura, con link) */}
|
{/* Perfil profesional */}
|
||||||
{user.professionals && (
|
{user.professionals && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Search, Eye } from 'lucide-react';
|
import { Search, Eye } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -15,6 +16,7 @@ interface User {
|
|||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
|
is_active?: boolean;
|
||||||
pro_state?: number;
|
pro_state?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
@@ -91,7 +93,12 @@ export default function UsersPage() {
|
|||||||
<TableCell>{u.email || '—'}</TableCell>
|
<TableCell>{u.email || '—'}</TableCell>
|
||||||
<TableCell>{u.phone || '—'}</TableCell>
|
<TableCell>{u.phone || '—'}</TableCell>
|
||||||
<TableCell>{u.city || '—'}</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>{new Date(u.created_at).toLocaleDateString()}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link href={`/users/${u.id}`}>
|
<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?
|
fcm_token String?
|
||||||
is_phone_verified Boolean? @default(false)
|
is_phone_verified Boolean? @default(false)
|
||||||
is_email_verified Boolean? @default(false)
|
is_email_verified Boolean? @default(false)
|
||||||
|
is_active Boolean @default(true)
|
||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
chats_chats_professional_idTousers chats[] @relation("chats_professional_idTousers")
|
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 {
|
export class RegisterDto {
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
@@ -54,6 +54,10 @@ export class UpdateUserDto {
|
|||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'birthday must be YYYY-MM-DD' })
|
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'birthday must be YYYY-MM-DD' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
birthday?: string;
|
birthday?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
is_active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FcmTokenDto {
|
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 { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
@@ -48,4 +48,11 @@ export class UsersController {
|
|||||||
findById(@Param('id') id: string) {
|
findById(@Param('id') id: string) {
|
||||||
return this.users.findById(id);
|
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) {
|
updateFcmToken(id: string, fcm_token: string) {
|
||||||
return this.prisma.users.update({ where: { id }, data: { fcm_token } });
|
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