Componentes Admin
Sección titulada «Componentes Admin»Componentes para el panel de administración, ubicados en app/src/admin/.
Acceso al Admin
Sección titulada «Acceso al Admin»Crear Usuario Admin
Sección titulada «Crear Usuario Admin»Para crear un usuario administrador en desarrollo, ejecuta:
wasp db seed seedAdminUserCredenciales Admin
Sección titulada «Credenciales Admin»| Campo | Valor |
|---|---|
admin@talentbricks.ai | |
| Password | Admin123! |
| URL Login | /login |
| URL Admin | /admin |
El admin usa un layout consistente con sidebar colapsable y header.
DefaultLayout
Sección titulada «DefaultLayout»Layout principal optimizado para pantallas modernas (hasta 1920px de ancho).
import { FC, ReactNode, useEffect, useState } from "react";import { Navigate } from "react-router-dom";import { type AuthUser } from "wasp/auth";import Header from "./Header";import Sidebar from "./Sidebar";
interface Props { user: AuthUser; children?: ReactNode;}
const DefaultLayout: FC<Props> = ({ children, user }) => { const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState( localStorage.getItem("admin-sidebar-collapsed") === "true" );
if (!user.isAdmin) { return <Navigate to="/" replace />; }
return ( <div className="bg-background text-foreground"> <div className="flex h-screen overflow-hidden"> <Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} sidebarCollapsed={sidebarCollapsed} /> <div className="relative flex flex-1 flex-col overflow-y-auto overflow-x-hidden transition-all duration-300 ease-linear"> <Header sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} sidebarCollapsed={sidebarCollapsed} setSidebarCollapsed={setSidebarCollapsed} user={user} /> <main> {/* Optimizado: max-w-[1920px] en lugar de max-w-screen-2xl (1536px) */} {/* Padding: 2xl:p-8 (32px) en lugar de 2xl:p-10 (40px) */} <div className="mx-auto max-w-[1920px] p-4 md:p-6 2xl:p-8">{children}</div> </main> </div> </div> </div> );};Características del Layout:
- ✅ Max-width optimizado: 1920px (ganancia de hasta 384px vs. anterior 1536px)
- ✅ Sidebar colapsable: 290px expandido → 80px colapsado (estado persistido en localStorage)
- ✅ Padding responsive: 16px (mobile) → 24px (md) → 32px (2xl+)
- ✅ Transición suave: 300ms ease-linear al colapsar/expandir sidebar
Espaciado en Páginas:
Las páginas hijas deben usar spacing consistente:
// ✅ Recomendado: gap-6 o space-y-6 (24px)<div className="space-y-6"> <Section1 /> <Section2 /></div>
// ❌ Evitar: gap-10, gap-4, o spacing inconsistenteEstrategia de Ancho por Tipo de Contenido:
// Formularios: Restricción de ancho + centrado<div className="space-y-6 max-w-4xl mx-auto"> <CourseEditForm /></div>
// Tablas/Listas: Full-width (aprovecha espacio completo)<div className="space-y-6"> <UsersTable /></div>Sidebar
Sección titulada «Sidebar»import { Link, useLocation } from "wasp/client/router";import { LayoutDashboard, Sheet, Settings, Tag, Calendar, LayoutTemplate } from "lucide-react";import { cn } from "../../shared/utils";
const navigation = [ { name: "Dashboard", href: "/admin", icon: LayoutDashboard }, { name: "Usuarios", href: "/admin/users", icon: Sheet }, { name: "configuración", href: "/admin/settings", icon: Settings }, { name: "Promociones", href: "/admin/promotions", icon: Tag }, // Extra Components section { name: "Calendario", href: "/admin/calendar", icon: Calendar }, { name: "UI Elements", href: "/admin/ui", icon: LayoutTemplate }, { name: "Analytics", href: "/admin/analytics", icon: BarChart },];
export function Sidebar() { const location = useLocation();
return ( <aside className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col"> <div className="flex flex-col flex-grow border-r bg-card"> {/* Logo */} <div className="flex items-center h-16 px-6 border-b"> <Link to="/admin" className="text-xl font-bold"> TalentBricksAI </Link> </div>
{/* Navigation */} <nav className="flex-1 p-4 space-y-1"> {navigation.map(item => { const isActive = location.pathname === item.href; return ( <Link key={item.name} to={item.href} className={cn( "flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors", isActive ? "bg-primary text-primary-foreground" : "hover:bg-accent" )} > <item.icon className="w-5 h-5" /> {item.name} </Link> ); })} </nav> </div> </aside> );}Tablas de Datos
Sección titulada «Tablas de Datos»CoursesTable
Sección titulada «CoursesTable»import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "../../../client/components/ui/table";import { Button } from "../../../client/components/ui/button";import { Badge } from "../../../client/components/ui/badge";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,} from "../../../client/components/ui/dropdown-menu";import { MoreHorizontal, Edit, Trash, Eye } from "lucide-react";import type { Course } from "wasp/entities";
interface CoursesTableProps { courses: Course[]; onEdit: (course: Course) => void; onDelete: (course: Course) => void; onView: (course: Course) => void;}
export function CoursesTable({ courses, onEdit, onDelete, onView }: CoursesTableProps) { return ( <Table> <TableHeader> <TableRow> <TableHead>Curso</TableHead> <TableHead>Categoría</TableHead> <TableHead>Precio</TableHead> <TableHead>Estado</TableHead> <TableHead>Inscritos</TableHead> <TableHead className="w-[70px]"></TableHead> </TableRow> </TableHeader> <TableBody> {courses.map(course => ( <TableRow key={course.id}> <TableCell> <div className="flex items-center gap-3"> {course.thumbnail && ( <img src={course.thumbnail} alt="" className="w-12 h-8 object-cover rounded" /> )} <div> <p className="font-medium">{course.title}</p> <p className="text-sm text-muted-foreground"> {course.lessons?.length || 0} lecciones </p> </div> </div> </TableCell> <TableCell>{course.category}</TableCell> <TableCell>${(course.price / 100).toFixed(2)}</TableCell> <TableCell> <Badge variant={course.isPublished ? "default" : "secondary"}> {course.isPublished ? "Publicado" : "Borrador"} </Badge> </TableCell> <TableCell>{course._count?.enrollments || 0}</TableCell> <TableCell> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon"> <MoreHorizontal className="w-4 h-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onClick={() => onView(course)}> <Eye className="w-4 h-4 mr-2" /> Ver </DropdownMenuItem> <DropdownMenuItem onClick={() => onEdit(course)}> <Edit className="w-4 h-4 mr-2" /> Editar </DropdownMenuItem> <DropdownMenuItem onClick={() => onDelete(course)} className="text-destructive"> <Trash className="w-4 h-4 mr-2" /> Eliminar </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </TableCell> </TableRow> ))} </TableBody> </Table> );}EnrollmentsTable
Sección titulada «EnrollmentsTable»import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "../../../client/components/ui/table";import { Avatar, AvatarFallback } from "../../../client/components/ui/avatar";import { Badge } from "../../../client/components/ui/badge";import { ProgressRing } from "../../../courses/components/ProgressRing";import type { Enrollment, User, Course } from "wasp/entities";
interface EnrollmentsTableProps { enrollments: (Enrollment & { user: User; course: Course; progress: number; })[];}
export function EnrollmentsTable({ enrollments }: EnrollmentsTableProps) { return ( <Table> <TableHeader> <TableRow> <TableHead>Estudiante</TableHead> <TableHead>Curso</TableHead> <TableHead>Inscripcion</TableHead> <TableHead>Progreso</TableHead> <TableHead>Estado</TableHead> </TableRow> </TableHeader> <TableBody> {enrollments.map(enrollment => ( <TableRow key={enrollment.id}> <TableCell> <div className="flex items-center gap-3"> <Avatar> <AvatarFallback>{enrollment.user.email?.[0].toUpperCase()}</AvatarFallback> </Avatar> <span>{enrollment.user.email}</span> </div> </TableCell> <TableCell>{enrollment.course.title}</TableCell> <TableCell>{new Date(enrollment.createdAt).toLocaleDateString("es-ES")}</TableCell> <TableCell> <ProgressRing progress={enrollment.progress} size={40} /> </TableCell> <TableCell> <Badge variant={enrollment.completedAt ? "default" : "secondary"}> {enrollment.completedAt ? "Completado" : "En progreso"} </Badge> </TableCell> </TableRow> ))} </TableBody> </Table> );}Formularios
Sección titulada «Formularios»CourseForm
Sección titulada «CourseForm»import { useForm } from "react-hook-form";import { zodResolver } from "@hookform/resolvers/zod";import * as z from "zod";import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription,} from "../../../client/components/ui/form";import { Input } from "../../../client/components/ui/input";import { Textarea } from "../../../client/components/ui/textarea";import { Button } from "../../../client/components/ui/button";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "../../../client/components/ui/select";import { Switch } from "../../../client/components/ui/switch";import type { Course } from "wasp/entities";
const courseSchema = z.object({ title: z.string().min(3, "Minimo 3 caracteres"), slug: z .string() .min(3) .regex(/^[a-z0-9-]+$/, "Solo letras, numeros y guiones"), description: z.string().min(10, "Minimo 10 caracteres"), category: z.string().min(1, "Selecciona una categoría"), difficulty: z.enum(["BEGINNER", "INTERMEDIATE", "ADVANCED"]), price: z.number().min(0), thumbnail: z.string().url().optional().or(z.literal("")), isPublished: z.boolean(),});
type CourseFormData = z.infer<typeof courseSchema>;
interface CourseFormProps { course?: Course; onSubmit: (data: CourseFormData) => Promise<void>; isLoading?: boolean;}
export function CourseForm({ course, onSubmit, isLoading }: CourseFormProps) { const form = useForm<CourseFormData>({ resolver: zodResolver(courseSchema), defaultValues: { title: course?.title || "", slug: course?.slug || "", description: course?.description || "", category: course?.category || "", difficulty: course?.difficulty || "BEGINNER", price: course?.price ? course.price / 100 : 0, thumbnail: course?.thumbnail || "", isPublished: course?.isPublished || false, }, });
// Auto-generate slug from title const title = form.watch("title"); useEffect(() => { if (!course) { const slug = title .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); form.setValue("slug", slug); } }, [title, course, form]);
const handleSubmit = async (data: CourseFormData) => { await onSubmit({ ...data, price: Math.round(data.price * 100), // Convert to cents }); };
return ( <Form {...form}> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6"> {/* Title */} <FormField control={form.control} name="title" render={({ field }) => ( <FormItem> <FormLabel>Título</FormLabel> <FormControl> <Input placeholder="Fundamentos de Data Engineering" {...field} /> </FormControl> <FormMessage /> </FormItem> )} />
{/* Slug */} <FormField control={form.control} name="slug" render={({ field }) => ( <FormItem> <FormLabel>Slug (URL)</FormLabel> <FormControl> <Input placeholder="fundamentos-data-engineering" {...field} /> </FormControl> <FormDescription>URL: /courses/{field.value || "slug"}</FormDescription> <FormMessage /> </FormItem> )} />
{/* Description */} <FormField control={form.control} name="description" render={({ field }) => ( <FormItem> <FormLabel>descripción</FormLabel> <FormControl> <Textarea placeholder="Describe el curso..." className="min-h-[120px]" {...field} /> </FormControl> <FormMessage /> </FormItem> )} />
<div className="grid grid-cols-2 gap-4"> {/* Category */} <FormField control={form.control} name="category" render={({ field }) => ( <FormItem> <FormLabel>Categoría</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value}> <FormControl> <SelectTrigger> <SelectValue placeholder="Seleccionar" /> </SelectTrigger> </FormControl> <SelectContent> <SelectItem value="Data Engineering">Data Engineering</SelectItem> <SelectItem value="Machine Learning">Machine Learning</SelectItem> <SelectItem value="Deep Learning">Deep Learning</SelectItem> <SelectItem value="MLOps">MLOps</SelectItem> <SelectItem value="Python">Python</SelectItem> <SelectItem value="SQL">SQL</SelectItem> </SelectContent> </Select> <FormMessage /> </FormItem> )} />
{/* Difficulty */} <FormField control={form.control} name="difficulty" render={({ field }) => ( <FormItem> <FormLabel>Dificultad</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value}> <FormControl> <SelectTrigger> <SelectValue /> </SelectTrigger> </FormControl> <SelectContent> <SelectItem value="BEGINNER">Principiante</SelectItem> <SelectItem value="INTERMEDIATE">Intermedio</SelectItem> <SelectItem value="ADVANCED">Avanzado</SelectItem> </SelectContent> </Select> <FormMessage /> </FormItem> )} /> </div>
<div className="grid grid-cols-2 gap-4"> {/* Price */} <FormField control={form.control} name="price" render={({ field }) => ( <FormItem> <FormLabel>Precio (USD)</FormLabel> <FormControl> <Input type="number" min="0" step="0.01" {...field} onChange={e => field.onChange(parseFloat(e.target.value))} /> </FormControl> <FormMessage /> </FormItem> )} />
{/* Thumbnail */} <FormField control={form.control} name="thumbnail" render={({ field }) => ( <FormItem> <FormLabel>Thumbnail URL</FormLabel> <FormControl> <Input placeholder="https://..." {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </div>
{/* Published */} <FormField control={form.control} name="isPublished" render={({ field }) => ( <FormItem className="flex items-center justify-between rounded-lg border p-4"> <div> <FormLabel>Publicado</FormLabel> <FormDescription>Los cursos publicados son visibles en el catalogo</FormDescription> </div> <FormControl> <Switch checked={field.value} onCheckedChange={field.onChange} /> </FormControl> </FormItem> )} />
<div className="flex justify-end gap-4"> <Button type="button" variant="outline"> Cancelar </Button> <Button type="submit" disabled={isLoading}> {isLoading ? "Guardando..." : course ? "Actualizar" : "Crear"} </Button> </div> </form> </Form> );}Stats Cards
Sección titulada «Stats Cards»import { Card, CardContent } from "../../client/components/ui/card";import { cn } from "../../shared/utils";
interface StatsCardProps { title: string; value: string | number; description?: string; icon?: React.ReactNode; trend?: { value: number; isPositive: boolean; };}
export function StatsCard({ title, value, description, icon, trend }: StatsCardProps) { return ( <Card> <CardContent className="p-6"> <div className="flex items-center justify-between"> <p className="text-sm font-medium text-muted-foreground">{title}</p> {icon && <div className="text-muted-foreground">{icon}</div>} </div> <div className="mt-2"> <p className="text-3xl font-bold">{value}</p> {trend && ( <p className={cn("text-sm mt-1", trend.isPositive ? "text-green-600" : "text-red-600")}> {trend.isPositive ? "+" : ""} {trend.value}% vs mes anterior </p> )} {description && <p className="text-sm text-muted-foreground mt-1">{description}</p>} </div> </CardContent> </Card> );}Admin Promotions
Sección titulada «Admin Promotions»El panel de promociones (/admin/promotions) gestiona códigos promocionales, referidos y
recompensas.
AdminPromotionsPage
Sección titulada «AdminPromotionsPage»Página con interfaz de tabs para gestionar todo el sistema de promociones.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../client/components/ui/tabs";import { Tag, Users, Gift } from "lucide-react";
export function AdminPromotionsPage() { return ( <DefaultLayout> <div className="space-y-6"> <h1 className="text-2xl font-bold">Promociones</h1>
<Tabs defaultValue="promo-codes"> <TabsList> <TabsTrigger value="promo-codes"> <Tag className="w-4 h-4 mr-2" /> Codigos Promo </TabsTrigger> <TabsTrigger value="referrals"> <Users className="w-4 h-4 mr-2" /> Referidos </TabsTrigger> <TabsTrigger value="rewards"> <Gift className="w-4 h-4 mr-2" /> Recompensas </TabsTrigger> </TabsList>
<TabsContent value="promo-codes"> <PromoCodesTab /> </TabsContent> <TabsContent value="referrals"> <ReferralsTab /> </TabsContent> <TabsContent value="rewards"> <RewardsTab /> </TabsContent> </Tabs> </div> </DefaultLayout> );}PromoCodesTab
Sección titulada «PromoCodesTab»Gestión de códigos promocionales con tabla y formulario de creación.
// Tabla de codigos promocionales<Table> <TableHeader> <TableRow> <TableHead>código</TableHead> <TableHead>Descuento</TableHead> <TableHead>Usos</TableHead> <TableHead>Expiracion</TableHead> <TableHead>Estado</TableHead> <TableHead></TableHead> </TableRow> </TableHeader> <TableBody> {promoCodes?.map(code => ( <TableRow key={code.id}> <TableCell> <code className="font-mono bg-muted px-2 py-1 rounded">{code.code}</code> </TableCell> <TableCell> {code.discountType === "PERCENTAGE" ? `${code.discountValue}%` : `$${(code.discountValue / 100).toFixed(2)}`} </TableCell> <TableCell> {code.currentUses} / {code.maxUses || "∞"} </TableCell> <TableCell> {code.expiresAt ? new Date(code.expiresAt).toLocaleDateString() : "Sin expiracion"} </TableCell> <TableCell> <Badge variant={code.isActive ? "default" : "secondary"}> {code.isActive ? "Activo" : "Inactivo"} </Badge> </TableCell> <TableCell> <Button variant="ghost" size="icon" onClick={() => handleDelete(code.id)}> <Trash className="w-4 h-4" /> </Button> </TableCell> </TableRow> ))} </TableBody></Table>ReferralsTab
Sección titulada «ReferralsTab»Analíticas de referidos con Estadísticas y top referidos.
// Estadísticas de referidos<div className="grid grid-cols-4 gap-4"> <StatsCard title="Total Referidores" value={stats.totalReferrers} /> <StatsCard title="Signups" value={stats.totalSignups} /> <StatsCard title="Conversiones" value={stats.totalConversions} /> <StatsCard title="Tasa de Conversion" value={`${stats.conversionRate}%`} /></div>
// Top referidores<Table> <TableHeader> <TableRow> <TableHead>Usuario</TableHead> <TableHead>código</TableHead> <TableHead>Clicks</TableHead> <TableHead>Signups</TableHead> <TableHead>Conversiones</TableHead> </TableRow> </TableHeader> <TableBody> {stats.topReferrers.map((referrer) => ( <TableRow key={referrer.id}> <TableCell>{referrer.userEmail}</TableCell> <TableCell> <code className="font-mono">{referrer.code}</code> </TableCell> <TableCell>{referrer.clicks}</TableCell> <TableCell>{referrer.signups}</TableCell> <TableCell>{referrer.conversions}</TableCell> </TableRow> ))} </TableBody></Table>RewardsTab
Sección titulada «RewardsTab»Gestión de recompensas de usuarios con acciones de aprobación/rechazo.
// Tabla de recompensas<Table> <TableHeader> <TableRow> <TableHead>Usuario</TableHead> <TableHead>Tipo</TableHead> <TableHead>Valor</TableHead> <TableHead>descripción</TableHead> <TableHead>Expira</TableHead> <TableHead>Estado</TableHead> <TableHead></TableHead> </TableRow> </TableHeader> <TableBody> {rewards?.map(reward => ( <TableRow key={reward.id}> <TableCell>{reward.userEmail}</TableCell> <TableCell> <Badge variant="outline">{reward.rewardType === "DISCOUNT_PERCENTAGE" ? "%" : "$"}</Badge> </TableCell> <TableCell> {reward.rewardType === "DISCOUNT_PERCENTAGE" ? `${reward.value}%` : `$${(reward.value / 100).toFixed(2)}`} </TableCell> <TableCell>{reward.description}</TableCell> <TableCell>{new Date(reward.expiresAt).toLocaleDateString()}</TableCell> <TableCell> <Badge variant={reward.isRedeemed ? "secondary" : "default"}> {reward.isRedeemed ? "Usado" : "Disponible"} </Badge> </TableCell> <TableCell> {!reward.isRedeemed && ( <Button variant="ghost" size="icon" onClick={() => handleReject(reward.id)}> <X className="w-4 h-4" /> </Button> )} </TableCell> </TableRow> ))} </TableBody></Table>Sidebar Navigation (Actualizado)
Sección titulada «Sidebar Navigation (Actualizado)»El sidebar ahora incluye el enlace a Promociones:
const navigation = [ { name: "Dashboard", href: "/admin", icon: LayoutDashboard }, { name: "Usuarios", href: "/admin/users", icon: Sheet }, { name: "configuración", href: "/admin/settings", icon: Settings }, { name: "Promociones", href: "/admin/promotions", icon: Tag }, // ... otros items];Uso en Paginas
Sección titulada «Uso en Paginas»import { useQuery, getCourses } from "wasp/client/operations";import { DefaultLayout } from "../../layout/DefaultLayout";import { CoursesTable } from "./CoursesTable";import { StatsCard } from "../../components/StatsCard";import { Button } from "../../../client/components/ui/button";import { BookOpen, Users, DollarSign } from "lucide-react";
export function AdminCoursesPage() { const { data: courses, isLoading } = useQuery(getCourses);
const totalCourses = courses?.length || 0; const publishedCourses = courses?.filter(c => c.isPublished).length || 0;
return ( <DefaultLayout> <div className="space-y-6"> {/* Header */} <div className="flex items-center justify-between"> <h1 className="text-2xl font-bold">Cursos</h1> <Button asChild> <Link to="/admin/courses/new">Nuevo Curso</Link> </Button> </div>
{/* Stats */} <div className="grid grid-cols-3 gap-4"> <StatsCard title="Total Cursos" value={totalCourses} icon={<BookOpen className="w-5 h-5" />} /> <StatsCard title="Publicados" value={publishedCourses} description={`${totalCourses - publishedCourses} en borrador`} /> <StatsCard title="Inscripciones Totales" value="--" icon={<Users className="w-5 h-5" />} /> </div>
{/* Table */} {isLoading ? ( <div>Cargando...</div> ) : ( <CoursesTable courses={courses || []} onEdit={handleEdit} onDelete={handleDelete} onView={handleView} /> )} </div> </DefaultLayout> );}