Skip to content

Componentes para el panel de administracion, ubicados en app/src/admin/.

El admin usa un layout consistente con sidebar y header.

admin/layout/DefaultLayout.tsx
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>
);
}
admin/layout/Sidebar.tsx
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>
);
}
admin/dashboards/courses/CoursesTable.tsx
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>
);
}
admin/dashboards/enrollments/EnrollmentsTable.tsx
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>
);
}
admin/dashboards/courses/CourseForm.tsx
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>
);
}
admin/components/StatsCard.tsx
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/dashboards/courses/AdminCoursesPage.tsx
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>
);
}