Add admin portal
This commit is contained in:
68
apps/frontend/src/features/admin/admin-layout.tsx
Normal file
68
apps/frontend/src/features/admin/admin-layout.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { BarChart3, Building2, Home, LayoutDashboard, Shield, Users } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type NavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: 'Dashboard', href: '/admin', icon: <LayoutDashboard className="size-4" /> },
|
||||
{ label: 'Complejos', href: '/admin/complexes', icon: <Building2 className="size-4" /> },
|
||||
{ label: 'Planes', href: '/admin/plans', icon: <BarChart3 className="size-4" /> },
|
||||
{ label: 'Usuarios', href: '/admin/users', icon: <Users className="size-4" /> },
|
||||
];
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: ReactNode;
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
export function AdminLayout({ children, currentPath }: AdminLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-[calc(100dvh-4rem)] gap-0">
|
||||
<aside className="hidden w-56 shrink-0 border-r border-border/60 md:block">
|
||||
<nav className="sticky top-20 flex flex-col gap-1 p-4">
|
||||
<div className="mb-4 flex items-center gap-2 px-3">
|
||||
<Shield className="size-4 text-emerald-600" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{navItems.map((item) => {
|
||||
const isActive = currentPath === item.href;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={item.href}
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
className="justify-start gap-3 rounded-xl"
|
||||
asChild
|
||||
>
|
||||
<Link to={item.href}>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="mt-6 border-t border-border/60 pt-4">
|
||||
<Button variant="ghost" className="w-full justify-start gap-3 rounded-xl" asChild>
|
||||
<Link to="/">
|
||||
<Home className="size-4" />
|
||||
Volver a Playzer
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-4 sm:p-6 lg:p-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
apps/frontend/src/features/admin/admin-page.tsx
Normal file
130
apps/frontend/src/features/admin/admin-page.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useLocation } from '@tanstack/react-router';
|
||||
import { Building2, CalendarDays, Crosshair, Users } from 'lucide-react';
|
||||
import { AdminLayout } from './admin-layout';
|
||||
|
||||
export function AdminPage() {
|
||||
const location = useLocation();
|
||||
|
||||
const complexesQuery = useQuery({
|
||||
queryKey: ['admin-complexes'],
|
||||
queryFn: () => apiClient.admin.listComplexes(),
|
||||
});
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['admin-users'],
|
||||
queryFn: () => apiClient.admin.listUsers(),
|
||||
});
|
||||
|
||||
const stats = {
|
||||
totalComplexes: complexesQuery.data?.length ?? 0,
|
||||
totalUsers: usersQuery.data?.length ?? 0,
|
||||
totalCourts: complexesQuery.data?.reduce((sum, c) => sum + c.courtCount, 0) ?? 0,
|
||||
totalBookings:
|
||||
complexesQuery.data?.reduce((sum, c) => sum + Math.round(c.avgBookingsPerDay * 30), 0) ?? 0,
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: 'Complejos',
|
||||
value: stats.totalComplexes,
|
||||
icon: Building2,
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
},
|
||||
{
|
||||
label: 'Usuarios',
|
||||
value: stats.totalUsers,
|
||||
icon: Users,
|
||||
color: 'text-emerald-600',
|
||||
bg: 'bg-emerald-100 dark:bg-emerald-900/30',
|
||||
},
|
||||
{
|
||||
label: 'Canchas',
|
||||
value: stats.totalCourts,
|
||||
icon: Crosshair,
|
||||
color: 'text-purple-600',
|
||||
bg: 'bg-purple-100 dark:bg-purple-900/30',
|
||||
},
|
||||
{
|
||||
label: 'Reservas (30d)',
|
||||
value: stats.totalBookings.toLocaleString(),
|
||||
icon: CalendarDays,
|
||||
color: 'text-amber-600',
|
||||
bg: 'bg-amber-100 dark:bg-amber-900/30',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AdminLayout currentPath={location.pathname}>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Panel de Administración</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Gestioná complejos, planes y usuarios de Playzer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{cards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.label}
|
||||
className="rounded-2xl border border-border/60 bg-card p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`rounded-xl p-2.5 ${card.bg}`}>
|
||||
<Icon className={`size-5 ${card.color}`} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{card.label}
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{card.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="mb-3 text-lg font-semibold">Acceso rápido</h2>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Link
|
||||
to="/admin/complexes"
|
||||
className="rounded-2xl border border-border/60 bg-card p-4 shadow-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<Building2 className="mb-2 size-5 text-blue-600" />
|
||||
<p className="font-medium">Complejos</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.totalComplexes} complejos registrados
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/admin/plans"
|
||||
className="rounded-2xl border border-border/60 bg-card p-4 shadow-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<CalendarDays className="mb-2 size-5 text-emerald-600" />
|
||||
<p className="font-medium">Planes</p>
|
||||
<p className="text-xs text-muted-foreground">Administrar suscripciones</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/admin/users"
|
||||
className="rounded-2xl border border-border/60 bg-card p-4 shadow-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<Users className="mb-2 size-5 text-purple-600" />
|
||||
<p className="font-medium">Usuarios</p>
|
||||
<p className="text-xs text-muted-foreground">{stats.totalUsers} usuarios registrados</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
167
apps/frontend/src/features/admin/complexes-page.tsx
Normal file
167
apps/frontend/src/features/admin/complexes-page.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useLocation } from '@tanstack/react-router';
|
||||
import { Building2, ChevronDown, ChevronUp, MapPin, RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { AdminLayout } from './admin-layout';
|
||||
|
||||
function PaymentBadge({ status }: { status: string }) {
|
||||
if (status === 'active') {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
>
|
||||
Activo
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'no_plan') {
|
||||
return (
|
||||
<Badge variant="outline" className="border-muted-foreground/30 text-muted-foreground">
|
||||
Sin plan
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950 dark:text-red-400"
|
||||
>
|
||||
Expirado
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComplexesPage() {
|
||||
const location = useLocation();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['admin-complexes'],
|
||||
queryFn: () => apiClient.admin.listComplexes(),
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminLayout currentPath={location.pathname}>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Complejos</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{data?.length ?? 0} complejos registrados en Playzer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="icon" className="rounded-xl" onClick={() => refetch()}>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<p className="text-sm text-muted-foreground">Cargando complejos...</p>
|
||||
</div>
|
||||
) : data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Building2 className="mb-3 size-10 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No hay complejos registrados.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data?.map((complex) => {
|
||||
const isExpanded = expandedId === complex.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={complex.id}
|
||||
className="rounded-2xl border border-border/60 bg-card shadow-sm"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedId(isExpanded ? null : complex.id)}
|
||||
className="flex w-full items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="rounded-xl bg-emerald-100 p-2.5 dark:bg-emerald-900/30">
|
||||
<Building2 className="size-5 text-emerald-600" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{complex.complexName}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{complex.complexSlug}</span>
|
||||
{complex.city && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<MapPin className="size-3" />
|
||||
<span>{complex.city}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaymentBadge status={complex.paymentStatus} />
|
||||
|
||||
<div className="hidden items-center gap-4 text-xs text-muted-foreground sm:flex">
|
||||
<span className="text-center">
|
||||
<span className="block text-sm font-semibold text-foreground">
|
||||
{complex.courtCount}
|
||||
</span>
|
||||
Canchas
|
||||
</span>
|
||||
<span className="text-center">
|
||||
<span className="block text-sm font-semibold text-foreground">
|
||||
{complex.userCount}
|
||||
</span>
|
||||
Usuarios
|
||||
</span>
|
||||
<span className="text-center">
|
||||
<span className="block text-sm font-semibold text-foreground">
|
||||
{complex.avgBookingsPerDay.toFixed(1)}
|
||||
</span>
|
||||
Res/día
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="size-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border/40 px-5 py-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Plan</p>
|
||||
<p className="font-medium">{complex.planName ?? 'Sin plan'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Canchas</p>
|
||||
<p className="font-medium">{complex.courtCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Usuarios</p>
|
||||
<p className="font-medium">{complex.userCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Prom. reservas/día</p>
|
||||
<p className="font-medium">{complex.avgBookingsPerDay.toFixed(1)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
337
apps/frontend/src/features/admin/plans-page.tsx
Normal file
337
apps/frontend/src/features/admin/plans-page.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AdminLayout } from '@/features/admin/admin-layout';
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useLocation } from '@tanstack/react-router';
|
||||
import { BarChart3, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
type Plan = {
|
||||
code: string;
|
||||
name: string;
|
||||
price: number;
|
||||
rules: unknown;
|
||||
lastUpdatedAt: string;
|
||||
complexCount: number;
|
||||
};
|
||||
|
||||
function PlanFormDialog({
|
||||
mode,
|
||||
plan,
|
||||
onClose,
|
||||
}: {
|
||||
mode: 'create' | 'edit';
|
||||
plan?: Plan;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [code, setCode] = useState(plan?.code ?? '');
|
||||
const [name, setName] = useState(plan?.name ?? '');
|
||||
const [price, setPrice] = useState(String(plan?.price ?? ''));
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: { code: string; name: string; price: number; rules: object }) =>
|
||||
apiClient.admin.createPlan(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: { name?: string; price?: number }) =>
|
||||
apiClient.admin.updatePlan(plan!.code, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
const error = createMutation.error ?? updateMutation.error;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (mode === 'create') {
|
||||
createMutation.mutate({
|
||||
code,
|
||||
name,
|
||||
price: Number.parseFloat(price),
|
||||
rules: {},
|
||||
});
|
||||
} else {
|
||||
updateMutation.mutate({
|
||||
name,
|
||||
price: Number.parseFloat(price),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="rounded-2xl sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === 'create' ? 'Crear plan' : 'Editar plan'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'create'
|
||||
? 'Agregá un nuevo plan de suscripción.'
|
||||
: 'Actualizá los datos del plan.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{mode === 'create' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">Código</Label>
|
||||
<Input
|
||||
id="code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="pro"
|
||||
maxLength={10}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Nombre</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Pro"
|
||||
maxLength={30}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="price">Precio</Label>
|
||||
<Input
|
||||
id="price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
placeholder="29.99"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">
|
||||
{error instanceof ApiClientError
|
||||
? String(error.details ?? error.message)
|
||||
: error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" className="rounded-xl" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" className="rounded-xl" disabled={isPending}>
|
||||
{isPending ? 'Guardando...' : mode === 'create' ? 'Crear' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteConfirmDialog({
|
||||
plan,
|
||||
onClose,
|
||||
}: {
|
||||
plan: Plan;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => apiClient.admin.deletePlan(plan.code),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="rounded-2xl sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Eliminar plan</DialogTitle>
|
||||
<DialogDescription>
|
||||
¿Estás seguro de eliminar el plan <strong>{plan.name}</strong>?
|
||||
{plan.complexCount > 0 && (
|
||||
<span className="mt-2 block text-destructive">
|
||||
No se puede eliminar porque tiene {plan.complexCount} complejo
|
||||
{plan.complexCount > 1 ? 's' : ''} asignado{plan.complexCount > 1 ? 's' : ''}.
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{deleteMutation.error && (
|
||||
<p className="text-sm text-destructive">
|
||||
{deleteMutation.error instanceof ApiClientError
|
||||
? String(deleteMutation.error.details ?? deleteMutation.error.message)
|
||||
: deleteMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" className="rounded-xl" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="rounded-xl"
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
disabled={deleteMutation.isPending || plan.complexCount > 0}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Eliminando...' : 'Eliminar'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlansPage() {
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingPlan, setEditingPlan] = useState<Plan | null>(null);
|
||||
const [deletingPlan, setDeletingPlan] = useState<Plan | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-plans'],
|
||||
queryFn: () => apiClient.admin.listPlans(),
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminLayout currentPath={location.pathname}>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Planes</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{data?.length ?? 0} planes de suscripción.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-xl"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['admin-plans'] })}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
<Button className="rounded-xl" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
Nuevo plan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createOpen && <PlanFormDialog mode="create" onClose={() => setCreateOpen(false)} />}
|
||||
|
||||
{editingPlan && (
|
||||
<PlanFormDialog mode="edit" plan={editingPlan} onClose={() => setEditingPlan(null)} />
|
||||
)}
|
||||
|
||||
{deletingPlan && (
|
||||
<DeleteConfirmDialog plan={deletingPlan} onClose={() => setDeletingPlan(null)} />
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<p className="text-sm text-muted-foreground">Cargando planes...</p>
|
||||
</div>
|
||||
) : data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<BarChart3 className="mb-3 size-10 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No hay planes creados.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-border/60">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/50">
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Código
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Nombre
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Precio
|
||||
</th>
|
||||
<th className="hidden px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground sm:table-cell">
|
||||
Complejos
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{data?.map((plan) => (
|
||||
<tr key={plan.code} className="transition-colors hover:bg-accent/30">
|
||||
<td className="px-5 py-4">
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
{plan.code}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-4 font-medium">{plan.name}</td>
|
||||
<td className="px-5 py-4 text-right font-medium">
|
||||
${Number(plan.price).toFixed(2)}
|
||||
</td>
|
||||
<td className="hidden px-5 py-4 text-center text-sm text-muted-foreground sm:table-cell">
|
||||
{plan.complexCount}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-lg"
|
||||
onClick={() => setEditingPlan(plan)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-lg text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setDeletingPlan(plan)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
437
apps/frontend/src/features/admin/users-page.tsx
Normal file
437
apps/frontend/src/features/admin/users-page.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AdminLayout } from '@/features/admin/admin-layout';
|
||||
import { ApiClientError, apiClient } from '@/lib/api-client';
|
||||
import type { AdminUser, AdminUserSession } from '@repo/api-contract';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useLocation } from '@tanstack/react-router';
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
LogIn,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
function SessionsDialog({
|
||||
user,
|
||||
onClose,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-user-sessions', user.id],
|
||||
queryFn: () => apiClient.admin.getUserSessions(user.id),
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: () => apiClient.admin.revokeAllSessions(user.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-user-sessions', user.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="rounded-2xl sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sesiones de {user.name}</DialogTitle>
|
||||
<DialogDescription>{data?.length ?? 0} sesiones activas</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-80 space-y-3 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Cargando sesiones...</p>
|
||||
) : data?.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Sin sesiones activas.</p>
|
||||
) : (
|
||||
data?.map((session) => <SessionRow key={session.id} session={session} />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{revokeMutation.error && (
|
||||
<p className="flex-1 text-sm text-destructive">
|
||||
{revokeMutation.error instanceof ApiClientError
|
||||
? String(revokeMutation.error.details ?? revokeMutation.error.message)
|
||||
: revokeMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" className="rounded-xl" onClick={onClose}>
|
||||
Cerrar
|
||||
</Button>
|
||||
{data && data.length > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="rounded-xl"
|
||||
onClick={() => revokeMutation.mutate()}
|
||||
disabled={revokeMutation.isPending}
|
||||
>
|
||||
{revokeMutation.isPending ? 'Cerrando...' : 'Cerrar todas las sesiones'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRow({ session }: { session: AdminUserSession }) {
|
||||
const isExpired = new Date(session.expiresAt) < new Date();
|
||||
const userAgent = session.userAgent ?? 'Desconocido';
|
||||
const ip = session.ipAddress ?? '—';
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Monitor className="size-4 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-sm font-medium">{userAgent}</span>
|
||||
{isExpired ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-gray-300 bg-gray-50 text-gray-600 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-400"
|
||||
>
|
||||
Expirada
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
>
|
||||
Activa
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Globe className="size-3" />
|
||||
{ip}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<LogIn className="size-3" />
|
||||
{new Date(session.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockDialog({
|
||||
user,
|
||||
onClose,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (banReason?: string) => apiClient.admin.blockUser(user.id, { banReason }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="rounded-2xl sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bloquear usuario</DialogTitle>
|
||||
<DialogDescription>
|
||||
¿Estás seguro de bloquear a <strong>{user.name}</strong> ({user.email})? No podrá
|
||||
iniciar sesión en Playzer.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reason">Motivo (opcional)</Label>
|
||||
<Input
|
||||
id="reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Violación de términos..."
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mutation.error && (
|
||||
<p className="text-sm text-destructive">
|
||||
{mutation.error instanceof ApiClientError
|
||||
? String(mutation.error.details ?? mutation.error.message)
|
||||
: mutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" className="rounded-xl" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="rounded-xl"
|
||||
onClick={() => mutation.mutate(reason || undefined)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'Bloqueando...' : 'Bloquear'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function UnblockDialog({
|
||||
user,
|
||||
onClose,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => apiClient.admin.unblockUser(user.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="rounded-2xl sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Desbloquear usuario</DialogTitle>
|
||||
<DialogDescription>
|
||||
¿Estás seguro de desbloquear a <strong>{user.name}</strong>? Podrá volver a iniciar
|
||||
sesión en Playzer.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{mutation.error && (
|
||||
<p className="text-sm text-destructive">
|
||||
{mutation.error instanceof ApiClientError
|
||||
? String(mutation.error.details ?? mutation.error.message)
|
||||
: mutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" className="rounded-xl" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
className="rounded-xl"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? 'Desbloqueando...' : 'Desbloquear'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleBadge({ role }: { role: string }) {
|
||||
if (role === 'super_admin') {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-300 bg-purple-50 text-purple-700 dark:border-purple-700 dark:bg-purple-950 dark:text-purple-400"
|
||||
>
|
||||
<ShieldAlert className="mr-1 size-3" />
|
||||
Super Admin
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{role}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const [blockingUser, setBlockingUser] = useState<AdminUser | null>(null);
|
||||
const [unblockingUser, setUnblockingUser] = useState<AdminUser | null>(null);
|
||||
const [sessionsUser, setSessionsUser] = useState<AdminUser | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-users'],
|
||||
queryFn: () => apiClient.admin.listUsers(),
|
||||
});
|
||||
|
||||
return (
|
||||
<AdminLayout currentPath={location.pathname}>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Usuarios</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{data?.length ?? 0} usuarios registrados en Playzer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-xl"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['admin-users'] })}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{blockingUser && <BlockDialog user={blockingUser} onClose={() => setBlockingUser(null)} />}
|
||||
|
||||
{unblockingUser && (
|
||||
<UnblockDialog user={unblockingUser} onClose={() => setUnblockingUser(null)} />
|
||||
)}
|
||||
|
||||
{sessionsUser && <SessionsDialog user={sessionsUser} onClose={() => setSessionsUser(null)} />}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<p className="text-sm text-muted-foreground">Cargando usuarios...</p>
|
||||
</div>
|
||||
) : data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Users className="mb-3 size-10 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No hay usuarios registrados.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-border/60">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/50">
|
||||
<th className="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Usuario
|
||||
</th>
|
||||
<th className="hidden px-5 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground md:table-cell">
|
||||
Rol
|
||||
</th>
|
||||
<th className="hidden px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground sm:table-cell">
|
||||
Complejos
|
||||
</th>
|
||||
<th className="hidden px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground lg:table-cell">
|
||||
Sesiones
|
||||
</th>
|
||||
<th className="hidden px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground lg:table-cell">
|
||||
Registro
|
||||
</th>
|
||||
<th className="px-5 py-3 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Acción
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{data?.map((user) => (
|
||||
<tr key={user.id} className="transition-colors hover:bg-accent/30">
|
||||
<td className="px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden px-5 py-4 md:table-cell">
|
||||
<RoleBadge role={user.role} />
|
||||
</td>
|
||||
<td className="hidden px-5 py-4 text-center text-sm text-muted-foreground sm:table-cell">
|
||||
{user.complexCount}
|
||||
</td>
|
||||
<td className="hidden px-5 py-4 text-center lg:table-cell">
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-sm font-medium text-emerald-600 underline-offset-2 hover:underline"
|
||||
onClick={() => setSessionsUser(user)}
|
||||
>
|
||||
{user.activeSessions}
|
||||
</button>
|
||||
</td>
|
||||
<td className="hidden px-5 py-4 text-sm text-muted-foreground lg:table-cell">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-center">
|
||||
{user.banned ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-red-300 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-950 dark:text-red-400"
|
||||
>
|
||||
Bloqueado
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
>
|
||||
Activo
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{user.banned ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-xl text-xs"
|
||||
disabled={user.role === 'super_admin'}
|
||||
onClick={() => setUnblockingUser(user)}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 size-3.5 text-emerald-600" />
|
||||
Desbloquear
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="rounded-xl text-xs text-destructive hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
|
||||
disabled={user.role === 'super_admin'}
|
||||
onClick={() => setBlockingUser(user)}
|
||||
>
|
||||
<Ban className="mr-1.5 size-3.5" />
|
||||
Bloquear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user