full project: admin panel, backend modules, docs

This commit is contained in:
Lizandro Guarnizo
2026-06-03 22:11:01 -05:00
parent 1635723035
commit afc096d552
94 changed files with 15994 additions and 240 deletions
+208
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}