Componentes Admin
Section titled “Componentes Admin”Componentes para el panel de administracion, ubicados en app/src/admin/.
Layout
Section titled “Layout”El admin usa un layout consistente con sidebar y header.
DefaultLayout
Section titled “DefaultLayout”import { Sidebar } from './Sidebar';import { Header } from './Header';
interface DefaultLayoutProps { children: React.ReactNode;}
export function DefaultLayout({ children }: DefaultLayoutProps) { return ( <div className="min-h-screen bg-background"> <Sidebar /> <div className="lg:pl-64"> <Header /> <main className="p-6"> {children} </main> </div> </div> );}Sidebar
Section titled “Sidebar”import { Link, useLocation } from 'wasp/client/router';import { LayoutDashboard, Users, BookOpen, CreditCard, Settings, BarChart} from 'lucide-react';import { cn } from '../../shared/utils';
const navigation = [ { name: 'Dashboard', href: '/admin', icon: LayoutDashboard }, { name: 'Usuarios', href: '/admin/users', icon: Users }, { name: 'Cursos', href: '/admin/courses', icon: BookOpen }, { name: 'Inscripciones', href: '/admin/enrollments', icon: CreditCard }, { name: 'Analytics', href: '/admin/analytics', icon: BarChart }, { name: 'Configuracion', href: '/admin/settings', icon: Settings },];
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
Section titled “Tablas de Datos”CoursesTable
Section titled “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>Categoria</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
Section titled “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
Section titled “Formularios”CourseForm
Section titled “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 categoria'), 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>Titulo</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: /curso/{field.value || 'slug'} </FormDescription> <FormMessage /> </FormItem> )} />
{/* Description */} <FormField control={form.control} name="description" render={({ field }) => ( <FormItem> <FormLabel>Descripcion</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>Categoria</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
Section titled “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> );}Uso en Paginas
Section titled “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> );}