full project: admin panel, backend modules, docs
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } 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 { ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface Professional {
|
||||
id: string;
|
||||
profession?: string;
|
||||
rate?: number;
|
||||
identification?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
interface Reputation {
|
||||
total: number;
|
||||
average: number;
|
||||
total_pro: number;
|
||||
average_pro: number;
|
||||
}
|
||||
|
||||
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;
|
||||
professionals?: Professional | null;
|
||||
reputations?: Reputation | null;
|
||||
}
|
||||
|
||||
const PRO_STATE_LABELS = ['Usuario', 'Solicitó', 'Profesional', 'Rechazado'];
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [user, setUser] = useState<UserDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.get<UserDetail>(`/users/${id}`)
|
||||
.then(setUser)
|
||||
.catch(() => setError('Error al cargar el usuario'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<p className="text-muted-foreground">Cargando...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={load} className="mt-2">
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/users">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">{user.name}</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Información general</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Email</span>
|
||||
<p className="flex items-center gap-2">
|
||||
{user.email || '—'}
|
||||
{user.is_email_verified && (
|
||||
<Badge variant="default" className="bg-green-600">Email verificado</Badge>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Teléfono</span>
|
||||
<p className="flex items-center gap-2">
|
||||
{user.phone || '—'}
|
||||
{user.is_phone_verified && (
|
||||
<Badge variant="default" className="bg-green-600">Teléfono verificado</Badge>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Ciudad</span>
|
||||
<p>{user.city || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Género</span>
|
||||
<p>{user.gender || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Cumpleaños</span>
|
||||
<p>{user.birthday ? new Date(user.birthday).toLocaleDateString() : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Registro</span>
|
||||
<p>{new Date(user.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Estado</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Estado profesional</span>
|
||||
<p>
|
||||
<Badge>{PRO_STATE_LABELS[user.pro_state ?? 0] || '—'}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{user.professionals && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profesional</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{user.professionals.profession || '—'}</span>
|
||||
<Link href={`/professionals/${user.professionals.id}`}>
|
||||
<Button variant="outline" size="sm">Ver detalle</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Tarifa</span>
|
||||
<p>{user.professionals.rate != null ? `$${user.professionals.rate}` : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Identificación</span>
|
||||
<p>{user.professionals.identification || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{user.reputations && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Reputación</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Total</span>
|
||||
<p className="text-2xl font-bold">{user.reputations.total}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Promedio</span>
|
||||
<p className="text-2xl font-bold">{user.reputations.average.toFixed(1)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Total Pro</span>
|
||||
<p className="text-2xl font-bold">{user.reputations.total_pro}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">Promedio Pro</span>
|
||||
<p className="text-2xl font-bold">{user.reputations.average_pro.toFixed(1)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
city?: string;
|
||||
pro_state?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.get<{ data: User[]; meta: any }>('/users')
|
||||
.then((res) => setUsers(res.data))
|
||||
.catch(() => setError('Error al cargar los usuarios'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const filtered = users.filter(
|
||||
(u) =>
|
||||
u.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.phone?.includes(search),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Usuarios</h1>
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-destructive">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={load} className="mt-2">
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre, email o teléfono..."
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Total: {filtered.length}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nombre</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Teléfono</TableHead>
|
||||
<TableHead>Ciudad</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
<TableHead>Registro</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center">Cargando...</TableCell></TableRow>
|
||||
) : filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center">Sin resultados</TableCell></TableRow>
|
||||
) : (
|
||||
filtered.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">{u.name}</TableCell>
|
||||
<TableCell>{u.email || '—'}</TableCell>
|
||||
<TableCell>{u.phone || '—'}</TableCell>
|
||||
<TableCell>{u.city || '—'}</TableCell>
|
||||
<TableCell>{['Usuario', 'Solicitó', 'Profesional', 'Rechazado'][u.pro_state ?? 0] || '—'}</TableCell>
|
||||
<TableCell>{new Date(u.created_at).toLocaleDateString()}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user