Add admin portal
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
Menu,
|
||||
Moon,
|
||||
Settings,
|
||||
Shield,
|
||||
Sun,
|
||||
User,
|
||||
X,
|
||||
@@ -241,6 +242,18 @@ export function Header() {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{user?.role === 'super_admin' && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer rounded-xl"
|
||||
onSelect={() => {
|
||||
void navigate({ to: '/admin' });
|
||||
}}
|
||||
>
|
||||
<Shield className="mr-2 size-4 text-emerald-600" />
|
||||
Admin
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -405,6 +418,20 @@ export function Header() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{user?.role === 'super_admin' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start rounded-2xl"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
void navigate({ to: '/admin' });
|
||||
}}
|
||||
>
|
||||
<Shield className="mr-2 size-4 text-emerald-600" />
|
||||
Admin
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isAuthenticated && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
31
apps/frontend/src/components/ui/badge.tsx
Normal file
31
apps/frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow-sm',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow-sm',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -5,24 +5,40 @@ import * as api from './api';
|
||||
|
||||
const apiBaseUrl = api.apiBaseUrl;
|
||||
|
||||
const plugins = [
|
||||
sentinelClient(),
|
||||
inferAdditionalFields({
|
||||
user: {
|
||||
phone: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
const infraPlugins = import.meta.env.VITE_BETTER_AUTH_INFRA === 'true' ? [sentinelClient()] : [];
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: apiBaseUrl,
|
||||
fetchOptions: {
|
||||
credentials: 'include',
|
||||
},
|
||||
plugins: import.meta.env.VITE_BETTER_AUTH_INFRA === 'true' ? plugins : [],
|
||||
plugins: [
|
||||
...infraPlugins,
|
||||
inferAdditionalFields({
|
||||
user: {
|
||||
phone: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
banned: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
},
|
||||
bannedAt: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
banReason: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
@@ -48,6 +64,7 @@ export const apiClient = {
|
||||
complexes: api.complexes,
|
||||
sports: api.sports,
|
||||
courts: api.courts,
|
||||
admin: api.admin,
|
||||
publicBookings: {
|
||||
getAvailability: api.getAvailability,
|
||||
create: api.createPublic,
|
||||
|
||||
@@ -17,6 +17,7 @@ export class ApiClientError extends Error {
|
||||
|
||||
export type ErrorHandlers = {
|
||||
onForbidden?: (error: ApiClientError) => void | Promise<void>;
|
||||
onUnauthorized?: (error: ApiClientError) => void | Promise<void>;
|
||||
onError?: (error: ApiClientError) => void | Promise<void>;
|
||||
};
|
||||
|
||||
@@ -28,6 +29,7 @@ export const apiBaseUrl =
|
||||
|
||||
export function configureApiClient(nextHandlers: ErrorHandlers) {
|
||||
handlers.onForbidden = nextHandlers.onForbidden;
|
||||
handlers.onUnauthorized = nextHandlers.onUnauthorized;
|
||||
handlers.onError = nextHandlers.onError;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ http.interceptors.response.use(
|
||||
async (error: AxiosError) => {
|
||||
const normalized = normalizeError(error);
|
||||
|
||||
if (normalized.status === 401 && handlers.onUnauthorized) {
|
||||
await handlers.onUnauthorized(normalized);
|
||||
}
|
||||
|
||||
if (normalized.status === 403 && handlers.onForbidden) {
|
||||
await handlers.onForbidden(normalized);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export * as courts from './resources/courts';
|
||||
export * as user from './resources/user';
|
||||
export * as sports from './resources/sports';
|
||||
export * as plans from './resources/plans';
|
||||
export * as admin from './resources/admin';
|
||||
|
||||
export {
|
||||
cancelPublic,
|
||||
|
||||
88
apps/frontend/src/lib/api/resources/admin.ts
Normal file
88
apps/frontend/src/lib/api/resources/admin.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type {
|
||||
AdminBlockUserInput,
|
||||
AdminComplexListItem,
|
||||
AdminCreatePlanInput,
|
||||
AdminGlobalStats,
|
||||
AdminUpdatePlanInput,
|
||||
AdminUser,
|
||||
AdminUserSession,
|
||||
} from '@repo/api-contract';
|
||||
import { http } from '../http';
|
||||
|
||||
type AdminPlan = {
|
||||
code: string;
|
||||
name: string;
|
||||
price: number;
|
||||
rules: unknown;
|
||||
lastUpdatedAt: string;
|
||||
complexCount: number;
|
||||
};
|
||||
|
||||
export async function listComplexes() {
|
||||
const response = await http.get<AdminComplexListItem[]>('/api/admin/complexes');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listPlans() {
|
||||
const response = await http.get<AdminPlan[]>('/api/admin/plans');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createPlan(data: AdminCreatePlanInput) {
|
||||
const response = await http.post<{ code: string; name: string; price: number }>(
|
||||
'/api/admin/plans',
|
||||
JSON.stringify(data),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updatePlan(code: string, data: AdminUpdatePlanInput) {
|
||||
const response = await http.patch<{ code: string; name: string; price: number }>(
|
||||
`/api/admin/plans/${code}`,
|
||||
JSON.stringify(data),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deletePlan(code: string) {
|
||||
const response = await http.delete<{ message: string }>(`/api/admin/plans/${code}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listUsers() {
|
||||
const response = await http.get<AdminUser[]>('/api/admin/users');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function blockUser(userId: string, data?: AdminBlockUserInput) {
|
||||
const response = await http.post<{ message: string }>(
|
||||
`/api/admin/users/${userId}/block`,
|
||||
JSON.stringify(data ?? {}),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function unblockUser(userId: string) {
|
||||
const response = await http.post<{ message: string }>(`/api/admin/users/${userId}/unblock`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getGlobalStats() {
|
||||
const response = await http.get<AdminGlobalStats>('/api/admin/stats');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getUserSessions(userId: string) {
|
||||
const response = await http.get<AdminUserSession[]>(`/api/admin/users/${userId}/sessions`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function revokeAllSessions(userId: string) {
|
||||
const response = await http.post<{ message: string }>(
|
||||
`/api/admin/users/${userId}/sessions/revoke-all`
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -27,6 +27,14 @@ function AppRouter() {
|
||||
|
||||
useEffect(() => {
|
||||
configureApiClient({
|
||||
onUnauthorized: async () => {
|
||||
if (!auth.isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
await auth.signOut();
|
||||
await router.navigate({ to: '/login' });
|
||||
},
|
||||
onForbidden: async (error) => {
|
||||
if (error.message?.includes('verificar tu email')) {
|
||||
return;
|
||||
|
||||
@@ -23,6 +23,11 @@ import { Route as ComplexSlugBookingRouteImport } from './routes/$complexSlug/bo
|
||||
import { Route as AppAuthenticatedRouteRouteImport } from './routes/_app/_authenticated/route'
|
||||
import { Route as AppAuthenticatedIndexRouteImport } from './routes/_app/_authenticated/index'
|
||||
import { Route as ComplexSlugBookingIndexRouteImport } from './routes/$complexSlug/booking/index'
|
||||
import { Route as AppAuthenticatedAdminRouteRouteImport } from './routes/_app/_authenticated/admin/route'
|
||||
import { Route as AppAuthenticatedAdminIndexRouteImport } from './routes/_app/_authenticated/admin/index'
|
||||
import { Route as AppAuthenticatedAdminUsersRouteImport } from './routes/_app/_authenticated/admin/users'
|
||||
import { Route as AppAuthenticatedAdminPlansRouteImport } from './routes/_app/_authenticated/admin/plans'
|
||||
import { Route as AppAuthenticatedAdminComplexesRouteImport } from './routes/_app/_authenticated/admin/complexes'
|
||||
import { Route as ComplexSlugBookingConfirmedBookingCodeRouteImport } from './routes/$complexSlug/booking/confirmed/$bookingCode'
|
||||
import { Route as AppAuthenticatedComplexSlugEditRouteImport } from './routes/_app/_authenticated/complex/$slug/edit'
|
||||
|
||||
@@ -94,6 +99,36 @@ const ComplexSlugBookingIndexRoute = ComplexSlugBookingIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => ComplexSlugBookingRoute,
|
||||
} as any)
|
||||
const AppAuthenticatedAdminRouteRoute =
|
||||
AppAuthenticatedAdminRouteRouteImport.update({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
getParentRoute: () => AppAuthenticatedRouteRoute,
|
||||
} as any)
|
||||
const AppAuthenticatedAdminIndexRoute =
|
||||
AppAuthenticatedAdminIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AppAuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
const AppAuthenticatedAdminUsersRoute =
|
||||
AppAuthenticatedAdminUsersRouteImport.update({
|
||||
id: '/users',
|
||||
path: '/users',
|
||||
getParentRoute: () => AppAuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
const AppAuthenticatedAdminPlansRoute =
|
||||
AppAuthenticatedAdminPlansRouteImport.update({
|
||||
id: '/plans',
|
||||
path: '/plans',
|
||||
getParentRoute: () => AppAuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
const AppAuthenticatedAdminComplexesRoute =
|
||||
AppAuthenticatedAdminComplexesRouteImport.update({
|
||||
id: '/complexes',
|
||||
path: '/complexes',
|
||||
getParentRoute: () => AppAuthenticatedAdminRouteRoute,
|
||||
} as any)
|
||||
const ComplexSlugBookingConfirmedBookingCodeRoute =
|
||||
ComplexSlugBookingConfirmedBookingCodeRouteImport.update({
|
||||
id: '/confirmed/$bookingCode',
|
||||
@@ -119,8 +154,13 @@ export interface FileRoutesByFullPath {
|
||||
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
||||
'/profile': typeof AppProfileRoute
|
||||
'/onboard/setup': typeof OnboardSetupRoute
|
||||
'/admin': typeof AppAuthenticatedAdminRouteRouteWithChildren
|
||||
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||
'/admin/complexes': typeof AppAuthenticatedAdminComplexesRoute
|
||||
'/admin/plans': typeof AppAuthenticatedAdminPlansRoute
|
||||
'/admin/users': typeof AppAuthenticatedAdminUsersRoute
|
||||
'/admin/': typeof AppAuthenticatedAdminIndexRoute
|
||||
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
@@ -136,6 +176,10 @@ export interface FileRoutesByTo {
|
||||
'/onboard/setup': typeof OnboardSetupRoute
|
||||
'/$complexSlug/booking': typeof ComplexSlugBookingIndexRoute
|
||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||
'/admin/complexes': typeof AppAuthenticatedAdminComplexesRoute
|
||||
'/admin/plans': typeof AppAuthenticatedAdminPlansRoute
|
||||
'/admin/users': typeof AppAuthenticatedAdminUsersRoute
|
||||
'/admin': typeof AppAuthenticatedAdminIndexRoute
|
||||
'/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
@@ -152,9 +196,14 @@ export interface FileRoutesById {
|
||||
'/$complexSlug/booking': typeof ComplexSlugBookingRouteWithChildren
|
||||
'/_app/profile': typeof AppProfileRoute
|
||||
'/onboard/setup': typeof OnboardSetupRoute
|
||||
'/_app/_authenticated/admin': typeof AppAuthenticatedAdminRouteRouteWithChildren
|
||||
'/$complexSlug/booking/': typeof ComplexSlugBookingIndexRoute
|
||||
'/_app/_authenticated/': typeof AppAuthenticatedIndexRoute
|
||||
'/$complexSlug/booking/confirmed/$bookingCode': typeof ComplexSlugBookingConfirmedBookingCodeRoute
|
||||
'/_app/_authenticated/admin/complexes': typeof AppAuthenticatedAdminComplexesRoute
|
||||
'/_app/_authenticated/admin/plans': typeof AppAuthenticatedAdminPlansRoute
|
||||
'/_app/_authenticated/admin/users': typeof AppAuthenticatedAdminUsersRoute
|
||||
'/_app/_authenticated/admin/': typeof AppAuthenticatedAdminIndexRoute
|
||||
'/_app/_authenticated/complex/$slug/edit': typeof AppAuthenticatedComplexSlugEditRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
@@ -171,8 +220,13 @@ export interface FileRouteTypes {
|
||||
| '/$complexSlug/booking'
|
||||
| '/profile'
|
||||
| '/onboard/setup'
|
||||
| '/admin'
|
||||
| '/$complexSlug/booking/'
|
||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||
| '/admin/complexes'
|
||||
| '/admin/plans'
|
||||
| '/admin/users'
|
||||
| '/admin/'
|
||||
| '/complex/$slug/edit'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
@@ -188,6 +242,10 @@ export interface FileRouteTypes {
|
||||
| '/onboard/setup'
|
||||
| '/$complexSlug/booking'
|
||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||
| '/admin/complexes'
|
||||
| '/admin/plans'
|
||||
| '/admin/users'
|
||||
| '/admin'
|
||||
| '/complex/$slug/edit'
|
||||
id:
|
||||
| '__root__'
|
||||
@@ -203,9 +261,14 @@ export interface FileRouteTypes {
|
||||
| '/$complexSlug/booking'
|
||||
| '/_app/profile'
|
||||
| '/onboard/setup'
|
||||
| '/_app/_authenticated/admin'
|
||||
| '/$complexSlug/booking/'
|
||||
| '/_app/_authenticated/'
|
||||
| '/$complexSlug/booking/confirmed/$bookingCode'
|
||||
| '/_app/_authenticated/admin/complexes'
|
||||
| '/_app/_authenticated/admin/plans'
|
||||
| '/_app/_authenticated/admin/users'
|
||||
| '/_app/_authenticated/admin/'
|
||||
| '/_app/_authenticated/complex/$slug/edit'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
@@ -321,6 +384,41 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ComplexSlugBookingIndexRouteImport
|
||||
parentRoute: typeof ComplexSlugBookingRoute
|
||||
}
|
||||
'/_app/_authenticated/admin': {
|
||||
id: '/_app/_authenticated/admin'
|
||||
path: '/admin'
|
||||
fullPath: '/admin'
|
||||
preLoaderRoute: typeof AppAuthenticatedAdminRouteRouteImport
|
||||
parentRoute: typeof AppAuthenticatedRouteRoute
|
||||
}
|
||||
'/_app/_authenticated/admin/': {
|
||||
id: '/_app/_authenticated/admin/'
|
||||
path: '/'
|
||||
fullPath: '/admin/'
|
||||
preLoaderRoute: typeof AppAuthenticatedAdminIndexRouteImport
|
||||
parentRoute: typeof AppAuthenticatedAdminRouteRoute
|
||||
}
|
||||
'/_app/_authenticated/admin/users': {
|
||||
id: '/_app/_authenticated/admin/users'
|
||||
path: '/users'
|
||||
fullPath: '/admin/users'
|
||||
preLoaderRoute: typeof AppAuthenticatedAdminUsersRouteImport
|
||||
parentRoute: typeof AppAuthenticatedAdminRouteRoute
|
||||
}
|
||||
'/_app/_authenticated/admin/plans': {
|
||||
id: '/_app/_authenticated/admin/plans'
|
||||
path: '/plans'
|
||||
fullPath: '/admin/plans'
|
||||
preLoaderRoute: typeof AppAuthenticatedAdminPlansRouteImport
|
||||
parentRoute: typeof AppAuthenticatedAdminRouteRoute
|
||||
}
|
||||
'/_app/_authenticated/admin/complexes': {
|
||||
id: '/_app/_authenticated/admin/complexes'
|
||||
path: '/complexes'
|
||||
fullPath: '/admin/complexes'
|
||||
preLoaderRoute: typeof AppAuthenticatedAdminComplexesRouteImport
|
||||
parentRoute: typeof AppAuthenticatedAdminRouteRoute
|
||||
}
|
||||
'/$complexSlug/booking/confirmed/$bookingCode': {
|
||||
id: '/$complexSlug/booking/confirmed/$bookingCode'
|
||||
path: '/confirmed/$bookingCode'
|
||||
@@ -338,12 +436,34 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
}
|
||||
|
||||
interface AppAuthenticatedAdminRouteRouteChildren {
|
||||
AppAuthenticatedAdminComplexesRoute: typeof AppAuthenticatedAdminComplexesRoute
|
||||
AppAuthenticatedAdminPlansRoute: typeof AppAuthenticatedAdminPlansRoute
|
||||
AppAuthenticatedAdminUsersRoute: typeof AppAuthenticatedAdminUsersRoute
|
||||
AppAuthenticatedAdminIndexRoute: typeof AppAuthenticatedAdminIndexRoute
|
||||
}
|
||||
|
||||
const AppAuthenticatedAdminRouteRouteChildren: AppAuthenticatedAdminRouteRouteChildren =
|
||||
{
|
||||
AppAuthenticatedAdminComplexesRoute: AppAuthenticatedAdminComplexesRoute,
|
||||
AppAuthenticatedAdminPlansRoute: AppAuthenticatedAdminPlansRoute,
|
||||
AppAuthenticatedAdminUsersRoute: AppAuthenticatedAdminUsersRoute,
|
||||
AppAuthenticatedAdminIndexRoute: AppAuthenticatedAdminIndexRoute,
|
||||
}
|
||||
|
||||
const AppAuthenticatedAdminRouteRouteWithChildren =
|
||||
AppAuthenticatedAdminRouteRoute._addFileChildren(
|
||||
AppAuthenticatedAdminRouteRouteChildren,
|
||||
)
|
||||
|
||||
interface AppAuthenticatedRouteRouteChildren {
|
||||
AppAuthenticatedAdminRouteRoute: typeof AppAuthenticatedAdminRouteRouteWithChildren
|
||||
AppAuthenticatedIndexRoute: typeof AppAuthenticatedIndexRoute
|
||||
AppAuthenticatedComplexSlugEditRoute: typeof AppAuthenticatedComplexSlugEditRoute
|
||||
}
|
||||
|
||||
const AppAuthenticatedRouteRouteChildren: AppAuthenticatedRouteRouteChildren = {
|
||||
AppAuthenticatedAdminRouteRoute: AppAuthenticatedAdminRouteRouteWithChildren,
|
||||
AppAuthenticatedIndexRoute: AppAuthenticatedIndexRoute,
|
||||
AppAuthenticatedComplexSlugEditRoute: AppAuthenticatedComplexSlugEditRoute,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ComplexesPage } from '@/features/admin/complexes-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/_authenticated/admin/complexes')({
|
||||
component: ComplexesPage,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { AdminPage } from '@/features/admin/admin-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/_authenticated/admin/')({
|
||||
component: AdminPage,
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PlansPage } from '@/features/admin/plans-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/_authenticated/admin/plans')({
|
||||
component: PlansPage,
|
||||
});
|
||||
16
apps/frontend/src/routes/_app/_authenticated/admin/route.tsx
Normal file
16
apps/frontend/src/routes/_app/_authenticated/admin/route.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/_authenticated/admin')({
|
||||
beforeLoad: ({ context, location }) => {
|
||||
if (!context.auth.isAuthenticated) {
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
search: { redirect: location.href },
|
||||
});
|
||||
}
|
||||
|
||||
if (context.auth.user?.role !== 'super_admin') {
|
||||
throw redirect({ to: '/' });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { UsersPage } from '@/features/admin/users-page';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/_app/_authenticated/admin/users')({
|
||||
component: UsersPage,
|
||||
});
|
||||
Reference in New Issue
Block a user