Fix login blank screen and progress persistence
- Fix authStore to persist user data, not just isAuthenticated - Fix progressStore handling of undefined API responses - Remove minimax.md documentation file - All progress now properly saves to PostgreSQL - Login flow working correctly
This commit is contained in:
@@ -1,82 +1,109 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useProgressStore } from '../stores/progressStore';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { progresoService } from '../services/api';
|
||||
import type { ModuloProgreso } from '../types';
|
||||
import { BookOpen, TrendingUp, User, LogOut, LayoutGrid } from 'lucide-react';
|
||||
import { ProgressBar } from '../components/progress/ProgressBar';
|
||||
import { ScoreDisplay } from '../components/progress/ScoreDisplay';
|
||||
import { BadgesSection } from '../components/progress/Badges';
|
||||
import { Loader } from '../components/ui/Loader';
|
||||
import { BookOpen, User, LogOut, LayoutGrid, Award, Star, Target, CheckCircle, FileText } from 'lucide-react';
|
||||
|
||||
const MODULOS_DEFAULT = [
|
||||
{ numero: 1, titulo: 'Fundamentos de Economía', descripcion: 'Introducción a los conceptos básicos' },
|
||||
{ numero: 2, titulo: 'Oferta, Demanda y Equilibrio', descripcion: 'Curvas de mercado' },
|
||||
{ numero: 3, titulo: 'Utilidad y Elasticidad', descripcion: 'Teoría del consumidor' },
|
||||
{ numero: 4, titulo: 'Teoría del Productor', descripcion: 'Costos y producción' },
|
||||
const MODULOS_CONFIG = [
|
||||
{ id: 'modulo1', numero: 1, titulo: 'Fundamentos de Economía', descripcion: 'Introducción a los conceptos básicos', totalEjercicios: 3 },
|
||||
{ id: 'modulo2', numero: 2, titulo: 'Oferta, Demanda y Equilibrio', descripcion: 'Curvas de mercado', totalEjercicios: 3 },
|
||||
{ id: 'modulo3', numero: 3, titulo: 'Utilidad y Elasticidad', descripcion: 'Teoría del consumidor', totalEjercicios: 3 },
|
||||
{ id: 'modulo4', numero: 4, titulo: 'Teoría del Productor', descripcion: 'Costos y producción', totalEjercicios: 3 },
|
||||
];
|
||||
|
||||
export function Dashboard() {
|
||||
const { usuario, logout } = useAuthStore();
|
||||
const [modulosProgreso, setModulosProgreso] = useState<ModuloProgreso[]>([]);
|
||||
const {
|
||||
puntuacionTotal,
|
||||
nivel,
|
||||
calcularPorcentajeModulo,
|
||||
getBadgesDesbloqueados,
|
||||
getBadgesBloqueados,
|
||||
modulos,
|
||||
loadProgreso,
|
||||
isLoading,
|
||||
error,
|
||||
} = useProgressStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadProgreso();
|
||||
}, []);
|
||||
|
||||
const loadProgreso = async () => {
|
||||
try {
|
||||
const progresos = await progresoService.getProgreso();
|
||||
const modulos = MODULOS_DEFAULT.map((mod) => {
|
||||
const modProgresos = progresos.filter((p) => p.modulo_numero === mod.numero);
|
||||
const completados = modProgresos.filter((p) => p.completado).length;
|
||||
const total = 5; // Asumiendo 5 ejercicios por módulo
|
||||
return {
|
||||
numero: mod.numero,
|
||||
titulo: mod.titulo,
|
||||
porcentaje: Math.round((completados / total) * 100),
|
||||
ejerciciosCompletados: completados,
|
||||
totalEjercicios: total,
|
||||
};
|
||||
});
|
||||
setModulosProgreso(modulos);
|
||||
} catch {
|
||||
// Si hay error, mostrar progreso vacío
|
||||
setModulosProgreso(
|
||||
MODULOS_DEFAULT.map((mod) => ({
|
||||
numero: mod.numero,
|
||||
titulo: mod.titulo,
|
||||
porcentaje: 0,
|
||||
ejerciciosCompletados: 0,
|
||||
totalEjercicios: 5,
|
||||
}))
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [loadProgreso]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
if (isLoading && Object.keys(modulos).length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader size="lg" className="mx-auto mb-4" />
|
||||
<p className="text-gray-600">Cargando tu progreso...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center max-w-md mx-auto px-4">
|
||||
<div className="text-red-500 mb-4">
|
||||
<svg className="w-16 h-16 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Error al cargar el progreso</h2>
|
||||
<p className="text-gray-600 mb-4">{error}</p>
|
||||
<Button onClick={loadProgreso}>Reintentar</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calcular progreso total
|
||||
const totalProgreso = Math.round(
|
||||
modulosProgreso.reduce((acc, mod) => acc + mod.porcentaje, 0) / modulosProgreso.length
|
||||
MODULOS_CONFIG.reduce((acc, mod) => {
|
||||
return acc + calcularPorcentajeModulo(mod.id, mod.totalEjercicios);
|
||||
}, 0) / MODULOS_CONFIG.length
|
||||
);
|
||||
|
||||
const badgesDesbloqueados = getBadgesDesbloqueados();
|
||||
const badgesBloqueados = getBadgesBloqueados();
|
||||
|
||||
// Calcular ejercicios completados por módulo
|
||||
const getEjerciciosCompletados = (moduloId: string) => {
|
||||
const modulo = modulos[moduloId];
|
||||
if (!modulo) return 0;
|
||||
return Object.values(modulo.ejercicios).filter(ej => ej.completado).length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm">
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center">
|
||||
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<BookOpen className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900">Economía</h1>
|
||||
<h1 className="text-xl font-bold text-gray-900">Economía Interactiva</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<User className="w-5 h-5" />
|
||||
<span className="font-medium">{usuario?.nombre}</span>
|
||||
<span className="font-medium">{usuario?.nombre || 'Usuario'}</span>
|
||||
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded-full font-semibold">
|
||||
{nivel}
|
||||
</span>
|
||||
{usuario?.rol === 'admin' && (
|
||||
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full">
|
||||
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full font-semibold">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
@@ -91,86 +118,154 @@ export function Dashboard() {
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Tu progreso</h2>
|
||||
<p className="text-gray-600">Continúa donde lo dejaste</p>
|
||||
<p className="text-gray-600">Continúa donde lo dejaste y desbloquea nuevos logros</p>
|
||||
</div>
|
||||
|
||||
<Card className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Progreso total</h3>
|
||||
<p className="text-sm text-gray-500">{totalProgreso}% completado</p>
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<Card className="bg-gradient-to-br from-blue-500 to-blue-600 text-white border-none">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-blue-100 text-sm">Progreso total</p>
|
||||
<p className="text-3xl font-bold mt-1">{totalProgreso}%</p>
|
||||
</div>
|
||||
<Target className="w-12 h-12 text-blue-100 opacity-80" />
|
||||
</div>
|
||||
<div className="mt-4 w-full bg-white/20 rounded-full h-2">
|
||||
<div
|
||||
className="bg-white h-2 rounded-full transition-all duration-500"
|
||||
style={{ width: `${totalProgreso}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-blue-100">
|
||||
{totalProgreso === 100 ? '¡Has completado todos los módulos!' : 'Sigue así, vas por buen camino'}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-amber-500 to-orange-500 text-white border-none">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-orange-100 text-sm">Puntuación total</p>
|
||||
<p className="text-3xl font-bold mt-1">{puntuacionTotal.toLocaleString()}</p>
|
||||
</div>
|
||||
<Star className="w-12 h-12 text-orange-100 opacity-80" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-orange-100">
|
||||
Acumula puntos completando ejercicios para subir de nivel
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-purple-500 to-pink-500 text-white border-none">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-purple-100 text-sm">Logros</p>
|
||||
<p className="text-3xl font-bold mt-1">
|
||||
{badgesDesbloqueados.length}/{badgesDesbloqueados.length + badgesBloqueados.length}
|
||||
</p>
|
||||
</div>
|
||||
<Award className="w-12 h-12 text-purple-100 opacity-80" />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-purple-100">
|
||||
{badgesBloqueados.length === 0
|
||||
? '¡Todos los logros desbloqueados!'
|
||||
: `${badgesBloqueados.length} logros por desbloquear`}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Columna izquierda - Módulos */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Puntuación y Nivel */}
|
||||
<ScoreDisplay
|
||||
puntos={puntuacionTotal}
|
||||
animar={false}
|
||||
showNivel={true}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Módulos</h2>
|
||||
{usuario?.rol === 'admin' && (
|
||||
<Link to="/admin">
|
||||
<Button variant="outline" size="sm">
|
||||
Panel de Admin
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{MODULOS_CONFIG.map((modulo) => {
|
||||
const porcentaje = calcularPorcentajeModulo(modulo.id, modulo.totalEjercicios);
|
||||
const completados = getEjerciciosCompletados(modulo.id);
|
||||
|
||||
return (
|
||||
<Link key={modulo.id} to={`/modulo/${modulo.numero}`}>
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-blue-600 font-bold text-lg">{modulo.numero}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{modulo.titulo}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{completados}/{modulo.totalEjercicios} ejercicios
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
porcentaje={porcentaje}
|
||||
moduloNumero={modulo.numero}
|
||||
showLabel={false}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between text-sm mt-3">
|
||||
<span className="text-gray-500">{porcentaje}% completado</span>
|
||||
{porcentaje === 100 && (
|
||||
<span className="text-green-600 flex items-center gap-1 font-medium">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Completado
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-center gap-4">
|
||||
<Link to="/modulos">
|
||||
<Button variant="outline" size="lg">
|
||||
<LayoutGrid className="w-5 h-5 mr-2" />
|
||||
Ver todos los módulos
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/recursos">
|
||||
<Button variant="outline" size="lg">
|
||||
<FileText className="w-5 h-5 mr-2" />
|
||||
Material PDF
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-primary">{totalProgreso}%</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-primary h-3 rounded-full transition-all duration-500"
|
||||
style={{ width: `${totalProgreso}%` }}
|
||||
|
||||
{/* Columna derecha - Logros */}
|
||||
<div>
|
||||
<BadgesSection
|
||||
badgesDesbloqueados={badgesDesbloqueados}
|
||||
badgesBloqueados={badgesBloqueados}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Módulos</h2>
|
||||
{usuario?.rol === 'admin' && (
|
||||
<Link to="/admin">
|
||||
<Button variant="outline" size="sm">
|
||||
Panel de Admin
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{modulosProgreso.map((modulo) => (
|
||||
<Link key={modulo.numero} to={`/modulo/${modulo.numero}`}>
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-primary font-bold">{modulo.numero}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{modulo.titulo}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{modulo.ejerciciosCompletados}/{modulo.totalEjercicios} ejercicios
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
modulo.porcentaje === 100 ? 'bg-success' : 'bg-primary'
|
||||
}`}
|
||||
style={{ width: `${modulo.porcentaje}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">{modulo.porcentaje}% completado</span>
|
||||
{modulo.porcentaje === 100 && (
|
||||
<span className="text-success flex items-center gap-1">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Completado
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<Link to="/modulos">
|
||||
<Button variant="outline" size="lg">
|
||||
<LayoutGrid className="w-5 h-5 mr-2" />
|
||||
Ver todos los módulos
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
@@ -1,63 +1,213 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { progresoService } from '../services/api';
|
||||
import type { Progreso } from '../types';
|
||||
import { ArrowLeft, CheckCircle, Play } from 'lucide-react';
|
||||
import { Loader } from '../components/ui/Loader';
|
||||
import { useProgressStore } from '../stores/progressStore';
|
||||
import { ScoreDisplay } from '../components/progress/ScoreDisplay';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Play,
|
||||
Lock,
|
||||
Trophy,
|
||||
TrendingUp,
|
||||
RotateCcw
|
||||
} from 'lucide-react';
|
||||
import type { EjercicioProgreso } from '../stores/progressStore';
|
||||
|
||||
const MODULOS_INFO: Record<number, { titulo: string; descripcion: string }> = {
|
||||
1: { titulo: 'Fundamentos de Economía', descripcion: 'Introducción a los conceptos básicos de economía' },
|
||||
2: { titulo: 'Oferta, Demanda y Equilibrio', descripcion: 'Curvas de oferta y demanda en el mercado' },
|
||||
3: { titulo: 'Utilidad y Elasticidad', descripcion: 'Teoría del consumidor y elasticidades' },
|
||||
4: { titulo: 'Teoría del Productor', descripcion: 'Costos de producción y competencia perfecta' },
|
||||
// Importar ejercicios reales
|
||||
import { FlujoCircular } from '../components/exercises/modulo1/FlujoCircular';
|
||||
import { QuizBienes } from '../components/exercises/modulo1/QuizBienes';
|
||||
import { SimuladorDisyuntivas } from '../components/exercises/modulo1/SimuladorDisyuntivas';
|
||||
import { ConstructorCurvas } from '../components/exercises/modulo2/ConstructorCurvas';
|
||||
import { IdentificarShocks } from '../components/exercises/modulo2/IdentificarShocks';
|
||||
import { SimuladorPrecios } from '../components/exercises/modulo2/SimuladorPrecios';
|
||||
import { ClasificadorBienes } from '../components/exercises/modulo3/ClasificadorBienes';
|
||||
import { CalculadoraElasticidad } from '../components/exercises/modulo3/CalculadoraElasticidad';
|
||||
import { EjerciciosExamen } from '../components/exercises/modulo3/EjerciciosExamen';
|
||||
import { CalculadoraCostos } from '../components/exercises/modulo4/CalculadoraCostos';
|
||||
import { SimuladorProduccion } from '../components/exercises/modulo4/SimuladorProduccion';
|
||||
import { VisualizadorExcedentes } from '../components/exercises/modulo4/VisualizadorExcedentes';
|
||||
|
||||
const MODULOS_INFO: Record<number, {
|
||||
id: string;
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
color: string;
|
||||
}> = {
|
||||
1: {
|
||||
id: 'modulo1',
|
||||
titulo: 'Fundamentos de Economía',
|
||||
descripcion: 'Introducción a los conceptos básicos de economía',
|
||||
color: 'from-blue-500 to-blue-600'
|
||||
},
|
||||
2: {
|
||||
id: 'modulo2',
|
||||
titulo: 'Oferta, Demanda y Equilibrio',
|
||||
descripcion: 'Curvas de oferta y demanda en el mercado',
|
||||
color: 'from-green-500 to-green-600'
|
||||
},
|
||||
3: {
|
||||
id: 'modulo3',
|
||||
titulo: 'Utilidad y Elasticidad',
|
||||
descripcion: 'Teoría del consumidor y elasticidades',
|
||||
color: 'from-purple-500 to-purple-600'
|
||||
},
|
||||
4: {
|
||||
id: 'modulo4',
|
||||
titulo: 'Teoría del Productor',
|
||||
descripcion: 'Costos de producción y competencia perfecta',
|
||||
color: 'from-orange-500 to-orange-600'
|
||||
},
|
||||
};
|
||||
|
||||
const EJERCICIOS_MOCK = [
|
||||
{ id: 'e1', titulo: 'Conceptos básicos', descripcion: 'Repasa los fundamentos de la economía' },
|
||||
{ id: 'e2', titulo: 'Agentes económicos', descripcion: 'Identifica los diferentes agentes en la economía' },
|
||||
{ id: 'e3', titulo: 'Factores de producción', descripcion: 'Aprende sobre tierra, trabajo y capital' },
|
||||
{ id: 'e4', titulo: 'Flujo circular', descripcion: 'Comprende el flujo de bienes y dinero' },
|
||||
{ id: 'e5', titulo: 'Evaluación final', descripcion: 'Pon a prueba todo lo aprendido' },
|
||||
];
|
||||
const EJERCICIOS_POR_MODULO: Record<number, Array<{
|
||||
id: string;
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
componente: React.ComponentType<{ ejercicioId: string; onComplete?: (puntuacion: number) => void }>;
|
||||
}>> = {
|
||||
1: [
|
||||
{ id: 'simulador-disyuntivas', titulo: 'Simulador de Disyuntivas', descripcion: 'Explora las decisiones económicas fundamentales', componente: SimuladorDisyuntivas },
|
||||
{ id: 'quiz-bienes', titulo: 'Quiz de Bienes', descripcion: 'Identifica diferentes tipos de bienes', componente: QuizBienes },
|
||||
{ id: 'flujo-circular', titulo: 'Flujo Circular', descripcion: 'Comprende el flujo de bienes y dinero en la economía', componente: FlujoCircular },
|
||||
],
|
||||
2: [
|
||||
{ id: 'constructor-curvas', titulo: 'Constructor de Curvas', descripcion: 'Construye curvas de oferta y demanda', componente: ConstructorCurvas },
|
||||
{ id: 'identificar-shocks', titulo: 'Identificar Shocks', descripcion: 'Reconoce cambios en el mercado', componente: IdentificarShocks },
|
||||
{ id: 'simulador-precios', titulo: 'Simulador de Precios', descripcion: 'Simula el equilibrio de precios', componente: SimuladorPrecios },
|
||||
],
|
||||
3: [
|
||||
{ id: 'clasificador-bienes', titulo: 'Clasificador de Bienes', descripcion: 'Clasifica bienes según su elasticidad', componente: ClasificadorBienes },
|
||||
{ id: 'calculadora-elasticidad', titulo: 'Calculadora de Elasticidad', descripcion: 'Calcula elasticidades de demanda', componente: CalculadoraElasticidad },
|
||||
{ id: 'ejercicios-examen', titulo: 'Ejercicios de Examen', descripcion: 'Pon a prueba tus conocimientos', componente: EjerciciosExamen },
|
||||
],
|
||||
4: [
|
||||
{ id: 'calculadora-costos', titulo: 'Calculadora de Costos', descripcion: 'Calcula costos de producción', componente: CalculadoraCostos },
|
||||
{ id: 'simulador-produccion', titulo: 'Simulador de Producción', descripcion: 'Simula la producción óptima', componente: SimuladorProduccion },
|
||||
{ id: 'visualizador-excedentes', titulo: 'Visualizador de Excedentes', descripcion: 'Visualiza excedentes del consumidor y productor', componente: VisualizadorExcedentes },
|
||||
],
|
||||
};
|
||||
|
||||
export function Modulo() {
|
||||
const { numero } = useParams<{ numero: string }>();
|
||||
const num = parseInt(numero || '1', 10);
|
||||
const [progresos, setProgresos] = useState<Progreso[]>([]);
|
||||
|
||||
const {
|
||||
puntuacionTotal,
|
||||
getProgresoEjercicio,
|
||||
saveProgreso,
|
||||
calcularPorcentajeModulo,
|
||||
loadProgreso,
|
||||
isLoading,
|
||||
error,
|
||||
} = useProgressStore();
|
||||
|
||||
const moduloInfo = MODULOS_INFO[num] || MODULOS_INFO[1];
|
||||
const ejercicios = EJERCICIOS_MOCK;
|
||||
const [ejercicioActivo, setEjercicioActivo] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadProgreso();
|
||||
}, [num]);
|
||||
}, [loadProgreso]);
|
||||
|
||||
const loadProgreso = async () => {
|
||||
const moduloInfo = MODULOS_INFO[num] || MODULOS_INFO[1];
|
||||
const ejercicios = EJERCICIOS_POR_MODULO[num] || [];
|
||||
const porcentaje = calcularPorcentajeModulo(moduloInfo.id, ejercicios.length);
|
||||
|
||||
const getProgresoEjercicioLocal = (ejercicioId: string): EjercicioProgreso | undefined => {
|
||||
return getProgresoEjercicio(moduloInfo.id, ejercicioId);
|
||||
};
|
||||
|
||||
const handleCompleteEjercicio = async (ejercicioId: string, puntuacion: number) => {
|
||||
try {
|
||||
const data = await progresoService.getProgreso();
|
||||
setProgresos(data);
|
||||
} catch {
|
||||
// Silencio
|
||||
await saveProgreso(moduloInfo.id, ejercicioId, puntuacion);
|
||||
setEjercicioActivo(null);
|
||||
} catch (err) {
|
||||
console.error('Error al guardar progreso:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getProgresoForEjercicio = (ejercicioId: string) => {
|
||||
return progresos.find(
|
||||
(p) => p.modulo_numero === num && p.ejercicio_id === ejercicioId
|
||||
);
|
||||
const completados = ejercicios.filter(
|
||||
(e) => getProgresoEjercicioLocal(e.id)?.completado
|
||||
).length;
|
||||
|
||||
// Determinar si un ejercicio está bloqueado (el primero siempre desbloqueado)
|
||||
const isEjercicioBloqueado = (index: number): boolean => {
|
||||
if (index === 0) return false;
|
||||
// Ejercicio anterior completado?
|
||||
const ejercicioAnterior = ejercicios[index - 1];
|
||||
return !getProgresoEjercicioLocal(ejercicioAnterior.id)?.completado;
|
||||
};
|
||||
|
||||
const completados = ejercicios.filter(
|
||||
(e) => getProgresoForEjercicio(e.id)?.completado
|
||||
).length;
|
||||
const porcentaje = Math.round((completados / ejercicios.length) * 100);
|
||||
if (ejercicioActivo) {
|
||||
const ejercicio = ejercicios.find(e => e.id === ejercicioActivo);
|
||||
if (!ejercicio) return null;
|
||||
|
||||
const EjercicioComponent = ejercicio.componente;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<button
|
||||
onClick={() => setEjercicioActivo(null)}
|
||||
className="inline-flex items-center text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Volver al módulo
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{ejercicio.titulo}</h1>
|
||||
<p className="text-gray-600">{ejercicio.descripcion}</p>
|
||||
</div>
|
||||
|
||||
<EjercicioComponent
|
||||
ejercicioId={ejercicio.id}
|
||||
onComplete={(puntuacion: number) => handleCompleteEjercicio(ejercicio.id, puntuacion)}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader size="lg" className="mx-auto mb-4" />
|
||||
<p className="text-gray-600">Cargando ejercicios...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center max-w-md mx-auto px-4">
|
||||
<div className="text-red-500 mb-4">
|
||||
<svg className="w-16 h-16 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Error al cargar el progreso</h2>
|
||||
<p className="text-gray-600 mb-4">{error}</p>
|
||||
<Button onClick={loadProgreso}>Reintentar</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm">
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<Link to="/" className="inline-flex items-center text-primary hover:underline">
|
||||
<Link to="/" className="inline-flex items-center text-blue-600 hover:text-blue-700 font-medium">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Volver al Dashboard
|
||||
</Link>
|
||||
@@ -67,7 +217,7 @@ export function Modulo() {
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-primary to-blue-600 rounded-xl flex items-center justify-center text-white text-2xl font-bold shadow-lg">
|
||||
<div className={`w-16 h-16 bg-gradient-to-br ${moduloInfo.color} rounded-xl flex items-center justify-center text-white text-2xl font-bold shadow-lg`}>
|
||||
{num}
|
||||
</div>
|
||||
<div>
|
||||
@@ -76,76 +226,153 @@ export function Modulo() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gradient-to-r from-primary to-blue-600 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card className={`bg-gradient-to-r ${moduloInfo.color} text-white border-none`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-blue-100">Tu progreso en este módulo</p>
|
||||
<p className="text-white/80 text-sm">Tu progreso en este módulo</p>
|
||||
<p className="text-3xl font-bold mt-1">{porcentaje}%</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-blue-100">{completados}/{ejercicios.length} ejercicios</p>
|
||||
<p className="text-white/80 text-sm">Ejercicios</p>
|
||||
<p className="text-xl font-bold">{completados}/{ejercicios.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 w-full bg-white/20 rounded-full h-2">
|
||||
<div
|
||||
className="bg-white h-2 rounded-full transition-all"
|
||||
style={{ width: `${porcentaje}%` }}
|
||||
|
||||
<div className="w-full bg-white/20 rounded-full h-3 overflow-hidden">
|
||||
<motion.div
|
||||
className="bg-white h-full rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${porcentaje}%` }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Ejercicios</h2>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Ejercicios</h2>
|
||||
<ScoreDisplay puntos={puntuacionTotal} animar={false} showNivel={false} size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{ejercicios.map((ejercicio, index) => {
|
||||
const progreso = getProgresoForEjercicio(ejercicio.id);
|
||||
const progreso = getProgresoEjercicioLocal(ejercicio.id);
|
||||
const completado = progreso?.completado || false;
|
||||
const bloqueado = isEjercicioBloqueado(index);
|
||||
|
||||
return (
|
||||
<Card key={ejercicio.id} className="hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
completado ? 'bg-success text-white' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{completado ? (
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
) : (
|
||||
<span className="font-medium">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<motion.div
|
||||
key={ejercicio.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Card className={`transition-all ${
|
||||
bloqueado
|
||||
? 'opacity-60 bg-gray-50'
|
||||
: 'hover:shadow-md cursor-pointer'
|
||||
}`}
|
||||
onClick={() => !bloqueado && setEjercicioActivo(ejercicio.id)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
|
||||
completado
|
||||
? 'bg-green-500 text-white'
|
||||
: bloqueado
|
||||
? 'bg-gray-200 text-gray-400'
|
||||
: 'bg-blue-100 text-blue-600'
|
||||
}`}>
|
||||
{completado ? (
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
) : bloqueado ? (
|
||||
<Lock className="w-5 h-5" />
|
||||
) : (
|
||||
<span className="font-bold">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900">{ejercicio.titulo}</h3>
|
||||
<p className="text-sm text-gray-500">{ejercicio.descripcion}</p>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${
|
||||
completado ? 'text-gray-900' : bloqueado ? 'text-gray-500' : 'text-gray-900'
|
||||
}`}>
|
||||
{ejercicio.titulo}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{ejercicio.descripcion}</p>
|
||||
{completado && progreso && progreso.puntuacion > 0 && (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs font-medium text-green-600">
|
||||
Mejor puntuación: {progreso.puntuacion} pts
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
({progreso.intentos} {progreso.intentos === 1 ? 'intento' : 'intentos'})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button size="sm">
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{completado ? 'Repetir' : 'Comenzar'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={bloqueado}
|
||||
variant={completado ? 'outline' : 'primary'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
!bloqueado && setEjercicioActivo(ejercicio.id);
|
||||
}}
|
||||
>
|
||||
{completado ? (
|
||||
<>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Repetir
|
||||
</>
|
||||
) : bloqueado ? (
|
||||
'Bloqueado'
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Comenzar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{porcentaje === 100 && (
|
||||
<Card className="mt-6 bg-success/10 border border-success">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-success rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-white" />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Card className="mt-6 bg-gradient-to-r from-green-500 to-emerald-600 text-white border-none">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<Trophy className="w-7 h-7" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-lg">¡Felicitaciones!</h3>
|
||||
<p className="text-green-100">
|
||||
Has completado todos los ejercicios de este módulo.
|
||||
{num < 4 ? ' ¡Continúa con el siguiente módulo!' : ' ¡Has completado todos los módulos!'}
|
||||
</p>
|
||||
</div>
|
||||
{num < 4 && (
|
||||
<Link to={`/modulo/${num + 1}`}>
|
||||
<Button variant="primary" className="bg-white text-green-600 hover:bg-green-50">
|
||||
Siguiente módulo
|
||||
<TrendingUp className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-success">¡Felicitaciones!</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Has completado todos los ejercicios de este módulo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modulo;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { ArrowRight, ArrowLeft } from 'lucide-react';
|
||||
import { useProgresoStore } from '../stores/progresoStore';
|
||||
import { ArrowRight, ArrowLeft, CheckCircle, Lock, Play } from 'lucide-react';
|
||||
|
||||
const MODULOS = [
|
||||
{
|
||||
@@ -9,28 +10,45 @@ const MODULOS = [
|
||||
titulo: 'Fundamentos de Economía',
|
||||
descripcion: 'Aprende los conceptos básicos: definición de economía, agentes económicos, factores de producción y el flujo circular de la economía.',
|
||||
temas: ['Definición de economía', 'Agentes económicos', 'Factores de producción', 'Flujo circular'],
|
||||
totalEjercicios: 5,
|
||||
bloqueado: false,
|
||||
},
|
||||
{
|
||||
numero: 2,
|
||||
titulo: 'Oferta, Demanda y Equilibrio',
|
||||
descripcion: 'Domina las curvas de oferta y demanda, aprende cómo se determinan los precios y entiende los controles de mercado.',
|
||||
temas: ['Curva de demanda', 'Curva de oferta', 'Equilibrio de mercado', 'Controles de precios'],
|
||||
totalEjercicios: 5,
|
||||
bloqueado: false,
|
||||
},
|
||||
{
|
||||
numero: 3,
|
||||
titulo: 'Utilidad y Elasticidad',
|
||||
descripcion: 'Explora la teoría del consumidor, aprende a calcular elasticidades y clasifica diferentes tipos de bienes.',
|
||||
temas: ['Utilidad marginal', 'Elasticidad precio', 'Elasticidad ingreso', 'Clasificación de bienes'],
|
||||
totalEjercicios: 5,
|
||||
bloqueado: false,
|
||||
},
|
||||
{
|
||||
numero: 4,
|
||||
titulo: 'Teoría del Productor',
|
||||
descripcion: 'Comprende los costos de producción, la toma de decisiones del productor y los fundamentos de la competencia perfecta.',
|
||||
temas: ['Costos de producción', 'Producción y costos', 'Competencia perfecta', 'Maximización de beneficios'],
|
||||
totalEjercicios: 5,
|
||||
bloqueado: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function Modulos() {
|
||||
const { progresoModulos } = useProgresoStore();
|
||||
|
||||
const getModuloProgress = (moduloNumero: number, totalEjercicios: number) => {
|
||||
const progreso = progresoModulos.find(p => p.moduloNumero === moduloNumero);
|
||||
const completados = progreso?.ejercicios.filter(e => e.completado).length || 0;
|
||||
const porcentaje = Math.round((completados / totalEjercicios) * 100);
|
||||
return { completados, porcentaje };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm">
|
||||
@@ -52,42 +70,90 @@ export function Modulos() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{MODULOS.map((modulo) => (
|
||||
<Card key={modulo.numero} className="hover:shadow-lg transition-shadow">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-6">
|
||||
<div className="flex items-center gap-4 md:w-32">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-primary to-blue-600 rounded-2xl flex items-center justify-center text-white text-2xl font-bold shadow-lg">
|
||||
{modulo.numero}
|
||||
{MODULOS.map((modulo) => {
|
||||
const { completados, porcentaje } = getModuloProgress(modulo.numero, modulo.totalEjercicios);
|
||||
const estaCompletado = porcentaje === 100;
|
||||
|
||||
return (
|
||||
<Card key={modulo.numero} className={`hover:shadow-lg transition-shadow ${
|
||||
modulo.bloqueado ? 'opacity-75' : ''
|
||||
}`}>
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-6">
|
||||
<div className="flex items-center gap-4 md:w-32">
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center text-white text-2xl font-bold shadow-lg ${
|
||||
estaCompletado
|
||||
? 'bg-gradient-to-br from-success to-green-600'
|
||||
: 'bg-gradient-to-br from-primary to-blue-600'
|
||||
}`}>
|
||||
{modulo.numero}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h2 className="text-xl font-bold text-gray-900">{modulo.titulo}</h2>
|
||||
{estaCompletado && (
|
||||
<span className="flex items-center gap-1 text-success text-sm">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Completado
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 mb-4">{modulo.descripcion}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{modulo.temas.map((tema) => (
|
||||
<span
|
||||
key={tema}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full"
|
||||
>
|
||||
{tema}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
estaCompletado ? 'bg-success' : 'bg-primary'
|
||||
}`}
|
||||
style={{ width: `${porcentaje}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{completados}/{modulo.totalEjercicios} ejercicios completados ({porcentaje}%)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="md:text-right">
|
||||
{modulo.bloqueado ? (
|
||||
<Button disabled>
|
||||
<Lock className="w-4 h-4 mr-2" />
|
||||
Bloqueado
|
||||
</Button>
|
||||
) : (
|
||||
<Link to={`/modulo/${modulo.numero}`}>
|
||||
<Button>
|
||||
{estaCompletado ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Revisar
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Entrar
|
||||
</>
|
||||
)}
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">{modulo.titulo}</h2>
|
||||
<p className="text-gray-600 mb-4">{modulo.descripcion}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{modulo.temas.map((tema) => (
|
||||
<span
|
||||
key={tema}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full"
|
||||
>
|
||||
{tema}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:text-right">
|
||||
<Link to={`/modulo/${modulo.numero}`}>
|
||||
<Button>
|
||||
Entrar
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
134
frontend/src/pages/Recursos.tsx
Normal file
134
frontend/src/pages/Recursos.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { FileText, Download, BookOpen, ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const recursos = [
|
||||
{
|
||||
id: 1,
|
||||
titulo: 'Resumen Clase 1 - Fundamentos de Economía',
|
||||
descripcion: 'Definición de economía, agentes económicos, factores de producción y flujo circular',
|
||||
archivo: '/pdfs/resumen_clase_1.pdf',
|
||||
modulo: 'Módulo 1',
|
||||
icono: FileText
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
titulo: 'Resumen Clase 2 - Oferta, Demanda y Equilibrio',
|
||||
descripcion: 'Ley de la demanda, ley de la oferta, equilibrio de mercado y controles de precios',
|
||||
archivo: '/pdfs/resumen_clase_2.pdf',
|
||||
modulo: 'Módulo 2',
|
||||
icono: FileText
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
titulo: 'Resumen Clase 3 - Elasticidad',
|
||||
descripcion: 'Tipos de elasticidad, cálculos y clasificación de bienes según elasticidad',
|
||||
archivo: '/pdfs/resumen_clase_3.pdf',
|
||||
modulo: 'Módulo 3',
|
||||
icono: FileText
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
titulo: 'Resumen Clase 4 - Teoría del Productor',
|
||||
descripcion: 'Costos, producción, competencia perfecta y maximización de beneficios',
|
||||
archivo: '/pdfs/resumen_clase_4.pdf',
|
||||
modulo: 'Módulo 4',
|
||||
icono: FileText
|
||||
}
|
||||
];
|
||||
|
||||
export function RecursosPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center text-gray-600 hover:text-blue-600 mb-4"
|
||||
>
|
||||
<ArrowLeft size={20} className="mr-2" />
|
||||
Volver al Dashboard
|
||||
</Link>
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Recursos de Estudio</h1>
|
||||
<p className="text-gray-600">
|
||||
Material académico en PDF para consultar offline
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
<Card className="mb-8 bg-blue-50 border-blue-200">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<BookOpen className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-blue-900 mb-1">Material de Apoyo</h2>
|
||||
<p className="text-blue-800 text-sm">
|
||||
Estos documentos PDF contienen el contenido teórico de cada módulo.
|
||||
Úsalos como referencia mientras realizas los ejercicios interactivos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recursos Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{recursos.map((recurso) => (
|
||||
<Card key={recurso.id} className="hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-gray-100 rounded-lg">
|
||||
<recurso.icono className="w-8 h-8 text-gray-600" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="mb-2">
|
||||
<span className="inline-block px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded">
|
||||
{recurso.modulo}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{recurso.titulo}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
{recurso.descripcion}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={recurso.archivo}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="outline" className="w-full">
|
||||
<Download size={18} className="mr-2" />
|
||||
Descargar PDF
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-gray-500 text-sm">
|
||||
¿Tienes dudas sobre el contenido? Revisa los ejercicios interactivos en cada módulo.
|
||||
</p>
|
||||
<Link to="/modulos">
|
||||
<Button className="mt-4">
|
||||
Ver Módulos
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecursosPage;
|
||||
364
frontend/src/pages/modulos/Modulo1Page.tsx
Normal file
364
frontend/src/pages/modulos/Modulo1Page.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
// @ts-nocheck
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { progresoService } from '../../services/api';
|
||||
import type { Progreso } from '../../types';
|
||||
import { ArrowLeft, CheckCircle, Play, BookOpen, Trophy, ChevronRight } from 'lucide-react';
|
||||
|
||||
// Importar contenido del módulo 1
|
||||
import { introduccion, agentes, factores } from '../../content/modulo1';
|
||||
import { ejercicios as modulo1Ejercicios } from '../../content/modulo1/ejercicios';
|
||||
|
||||
// Importar componentes de ejercicios
|
||||
import { SimuladorDisyuntivas, QuizBienes, FlujoCircular } from '../../components/exercises/modulo1';
|
||||
|
||||
const TABS = ['Contenido', 'Ejercicios'] as const;
|
||||
type Tab = typeof TABS[number];
|
||||
|
||||
interface EjercicioConfig {
|
||||
id: string;
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
componente: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Modulo1Page() {
|
||||
const { numero } = useParams<{ numero: string }>();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('Contenido');
|
||||
const [activeSeccion, setActiveSeccion] = useState<'introduccion' | 'agentes' | 'factores'>('introduccion');
|
||||
const [activeEjercicio, setActiveEjercicio] = useState<string | null>(null);
|
||||
const [progresos, setProgresos] = useState<Progreso[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Cargar progreso al montar
|
||||
useEffect(() => {
|
||||
loadProgreso();
|
||||
}, []);
|
||||
|
||||
const loadProgreso = async () => {
|
||||
try {
|
||||
const data = await progresoService.getProgreso();
|
||||
setProgresos(data);
|
||||
} catch {
|
||||
// Silenciar error
|
||||
}
|
||||
};
|
||||
|
||||
const getProgresoForEjercicio = (ejercicioId: string) => {
|
||||
return progresos.find(
|
||||
(p) => p.modulo_numero === 1 && p.ejercicio_id === ejercicioId
|
||||
);
|
||||
};
|
||||
|
||||
const handleCompleteEjercicio = async (ejercicioId: string, puntuacion: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await progresoService.saveProgreso(ejercicioId, puntuacion);
|
||||
await loadProgreso();
|
||||
} catch {
|
||||
// Silenciar error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Configuración de ejercicios con sus componentes
|
||||
const ejerciciosConfig: EjercicioConfig[] = [
|
||||
{
|
||||
id: 'simulador-disyuntivas',
|
||||
titulo: modulo1Ejercicios.ejercicios[0].titulo,
|
||||
descripcion: modulo1Ejercicios.ejercicios[0].descripcion,
|
||||
componente: (
|
||||
<SimuladorDisyuntivas
|
||||
ejercicioId="simulador-disyuntivas"
|
||||
onComplete={(puntuacion) => handleCompleteEjercicio('simulador-disyuntivas', puntuacion)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'quiz-clasificacion-bienes',
|
||||
titulo: modulo1Ejercicios.ejercicios[1].titulo,
|
||||
descripcion: modulo1Ejercicios.ejercicios[1].descripcion,
|
||||
componente: (
|
||||
<QuizBienes
|
||||
ejercicioId="quiz-clasificacion-bienes"
|
||||
onComplete={(puntuacion) => handleCompleteEjercicio('quiz-clasificacion-bienes', puntuacion)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'juego-flujo-circular',
|
||||
titulo: modulo1Ejercicios.ejercicios[2].titulo,
|
||||
descripcion: modulo1Ejercicios.ejercicios[2].descripcion,
|
||||
componente: (
|
||||
<FlujoCircular
|
||||
ejercicioId="juego-flujo-circular"
|
||||
onComplete={(puntuacion) => handleCompleteEjercicio('juego-flujo-circular', puntuacion)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const seccionesContenido = {
|
||||
introduccion: {
|
||||
titulo: introduccion.titulo,
|
||||
data: introduccion,
|
||||
},
|
||||
agentes: {
|
||||
titulo: agentes.titulo,
|
||||
data: agentes,
|
||||
},
|
||||
factores: {
|
||||
titulo: factores.titulo,
|
||||
data: factores,
|
||||
},
|
||||
};
|
||||
|
||||
const currentSeccion = seccionesContenido[activeSeccion];
|
||||
|
||||
// Calcular progreso del módulo
|
||||
const ejerciciosCompletados = ejerciciosConfig.filter(
|
||||
(e) => getProgresoForEjercicio(e.id)?.completado
|
||||
).length;
|
||||
const porcentajeProgreso = Math.round((ejerciciosCompletados / ejerciciosConfig.length) * 100);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm sticky top-0 z-10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link to="/modulos" className="inline-flex items-center text-blue-600 hover:underline">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Volver a Módulos
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Progreso:</span>
|
||||
<div className="w-32 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${porcentajeProgreso}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{porcentajeProgreso}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Título del módulo */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 bg-white/20 rounded-xl flex items-center justify-center text-3xl font-bold">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Módulo 1: Fundamentos de Economía</h1>
|
||||
<p className="text-blue-100 mt-1">
|
||||
Introducción a los conceptos básicos, agentes económicos y factores de producción
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => {
|
||||
setActiveTab(tab);
|
||||
setActiveEjercicio(null);
|
||||
}}
|
||||
className={`px-6 py-3 font-medium text-sm transition-colors relative ${
|
||||
activeTab === tab
|
||||
? 'text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab === 'Contenido' && <BookOpen className="w-4 h-4 inline mr-2" />}
|
||||
{tab === 'Ejercicios' && <Trophy className="w-4 h-4 inline mr-2" />}
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<motion.div
|
||||
layoutId="activeTab"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Contenido según tab activo */}
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'Contenido' ? (
|
||||
<motion.div
|
||||
key="contenido"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mt-6 grid grid-cols-1 lg:grid-cols-4 gap-6"
|
||||
>
|
||||
{/* Navegación de secciones */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="sticky top-24">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Secciones</h3>
|
||||
<nav className="space-y-2">
|
||||
{(Object.keys(seccionesContenido) as Array<keyof typeof seccionesContenido>).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveSeccion(key)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between ${
|
||||
activeSeccion === key
|
||||
? 'bg-blue-50 text-blue-700 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{seccionesContenido[key].titulo}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Contenido de la sección */}
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
<Card>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">{currentSeccion.titulo}</h2>
|
||||
<div className="space-y-6">
|
||||
{currentSeccion.data.contenido.map((seccion, index) => (
|
||||
<div key={index} className="border-b border-gray-100 last:border-0 pb-6 last:pb-0">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-3">{seccion.titulo}</h3>
|
||||
<div className="prose prose-blue max-w-none">
|
||||
{seccion.contenido.split('\n\n').map((parrafo, pIndex) => (
|
||||
<p key={pIndex} className="text-gray-600 mb-4 leading-relaxed whitespace-pre-line">
|
||||
{parrafo}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Ejercicios relacionados con la sección */}
|
||||
{currentSeccion.data.ejercicios && currentSeccion.data.ejercicios.length > 0 && (
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<h3 className="font-semibold text-blue-900 mb-3">Ejercicios Relacionados</h3>
|
||||
<p className="text-blue-700 text-sm mb-4">
|
||||
Practica lo aprendido con estos ejercicios interactivos
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setActiveTab('Ejercicios')}
|
||||
variant="outline"
|
||||
className="border-blue-300 text-blue-700 hover:bg-blue-100"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Ir a Ejercicios
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="ejercicios"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mt-6"
|
||||
>
|
||||
{activeEjercicio ? (
|
||||
// Vista de ejercicio activo
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={() => setActiveEjercicio(null)}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Volver a ejercicios
|
||||
</Button>
|
||||
{loading && <span className="text-sm text-gray-500">Guardando progreso...</span>}
|
||||
</div>
|
||||
{ejerciciosConfig.find((e) => e.id === activeEjercicio)?.componente}
|
||||
</div>
|
||||
) : (
|
||||
// Lista de ejercicios
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{ejerciciosConfig.map((ejercicio, index) => {
|
||||
const progreso = getProgresoForEjercicio(ejercicio.id);
|
||||
const completado = progreso?.completado || false;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={ejercicio.id}
|
||||
className="hover:shadow-lg transition-shadow cursor-pointer"
|
||||
onClick={() => setActiveEjercicio(ejercicio.id)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${
|
||||
completado ? 'bg-green-100 text-green-600' : 'bg-blue-100 text-blue-600'
|
||||
}`}
|
||||
>
|
||||
{completado ? (
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
) : (
|
||||
<span className="text-xl font-bold">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900">{ejercicio.titulo}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{ejercicio.descripcion}</p>
|
||||
{completado && progreso && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full">
|
||||
Completado
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{progreso.puntuacion} pts
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full mt-4" size="sm">
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{completado ? 'Repetir' : 'Comenzar'}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mensaje de completado */}
|
||||
{ejerciciosCompletados === ejerciciosConfig.length && ejerciciosConfig.length > 0 && (
|
||||
<Card className="mt-6 bg-green-50 border-green-200">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Trophy className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-green-900">¡Felicitaciones!</h3>
|
||||
<p className="text-green-700 text-sm">
|
||||
Has completado todos los ejercicios de este módulo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modulo1Page;
|
||||
499
frontend/src/pages/modulos/Modulo2Page.tsx
Normal file
499
frontend/src/pages/modulos/Modulo2Page.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
// @ts-nocheck
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { progresoService } from '../../services/api';
|
||||
import type { Progreso } from '../../types';
|
||||
import { ArrowLeft, CheckCircle, Play, BookOpen, Trophy, ChevronRight } from 'lucide-react';
|
||||
|
||||
// Importar contenido del módulo 2
|
||||
import { default as demandaContent } from '../../content/modulo2/demanda';
|
||||
import { default as ofertaContent } from '../../content/modulo2/oferta';
|
||||
import { default as equilibrioContent } from '../../content/modulo2/equilibrio';
|
||||
|
||||
// Importar componentes de ejercicios
|
||||
import { ConstructorCurvas, SimuladorPrecios, IdentificarShocks } from '../../components/exercises/modulo2';
|
||||
|
||||
const TABS = ['Contenido', 'Ejercicios'] as const;
|
||||
type Tab = typeof TABS[number];
|
||||
|
||||
interface EjercicioConfig {
|
||||
id: string;
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
componente: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Modulo2Page() {
|
||||
const { numero } = useParams<{ numero: string }>();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('Contenido');
|
||||
const [activeSeccion, setActiveSeccion] = useState<'demanda' | 'oferta' | 'equilibrio'>('demanda');
|
||||
const [activeEjercicio, setActiveEjercicio] = useState<string | null>(null);
|
||||
const [progresos, setProgresos] = useState<Progreso[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadProgreso();
|
||||
}, []);
|
||||
|
||||
const loadProgreso = async () => {
|
||||
try {
|
||||
const data = await progresoService.getProgreso();
|
||||
setProgresos(data);
|
||||
} catch {
|
||||
// Silenciar error
|
||||
}
|
||||
};
|
||||
|
||||
const getProgresoForEjercicio = (ejercicioId: string) => {
|
||||
return progresos.find(
|
||||
(p) => p.modulo_numero === 2 && p.ejercicio_id === ejercicioId
|
||||
);
|
||||
};
|
||||
|
||||
const handleCompleteEjercicio = async (ejercicioId: string, puntuacion: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await progresoService.saveProgreso(ejercicioId, puntuacion);
|
||||
await loadProgreso();
|
||||
} catch {
|
||||
// Silenciar error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Configuración de ejercicios
|
||||
const ejerciciosConfig: EjercicioConfig[] = [
|
||||
{
|
||||
id: 'constructor-curvas',
|
||||
titulo: 'Constructor de Curvas de Oferta y Demanda',
|
||||
descripcion: 'Construye curvas de oferta y demanda arrastrando puntos para entender sus pendientes y movimientos.',
|
||||
componente: (
|
||||
<ConstructorCurvas
|
||||
ejercicioId="constructor-curvas"
|
||||
onComplete={(puntuacion) => handleCompleteEjercicio('constructor-curvas', puntuacion)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'simulador-precios',
|
||||
titulo: 'Simulador de Precios Intervenidos',
|
||||
descripcion: 'Ajusta precios máximos y mínimos para observar sus efectos en el mercado: escasez, superávit, y pérdida de bienestar.',
|
||||
componente: (
|
||||
<SimuladorPrecios
|
||||
ejercicioId="simulador-precios"
|
||||
onComplete={(puntuacion) => handleCompleteEjercicio('simulador-precios', puntuacion)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'identificar-shocks',
|
||||
titulo: 'Identificador de Shocks del Mercado',
|
||||
descripcion: 'Analiza escenarios económicos reales e identifica si afectan la oferta, la demanda, ambas, y cómo cambian precio y cantidad de equilibrio.',
|
||||
componente: (
|
||||
<IdentificarShocks
|
||||
ejercicioId="identificar-shocks"
|
||||
onComplete={(puntuacion) => handleCompleteEjercicio('identificar-shocks', puntuacion)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Estructura de contenido del módulo 2
|
||||
const seccionesContenido = {
|
||||
demanda: {
|
||||
titulo: 'Ley de la Demanda',
|
||||
contenido: [
|
||||
{
|
||||
titulo: demandaContent.definicion.titulo,
|
||||
texto: demandaContent.definicion.definicion,
|
||||
elementos: demandaContent.definicion.elementosClave,
|
||||
},
|
||||
{
|
||||
titulo: demandaContent.ley.titulo,
|
||||
texto: demandaContent.ley.enunciado,
|
||||
efectos: demandaContent.ley.efectos,
|
||||
},
|
||||
{
|
||||
titulo: 'Factores que Desplazan la Demanda',
|
||||
texto: 'Los siguientes factores causan desplazamientos de la curva de demanda:',
|
||||
factores: demandaContent.factores,
|
||||
},
|
||||
],
|
||||
},
|
||||
oferta: {
|
||||
titulo: 'Ley de la Oferta',
|
||||
contenido: [
|
||||
{
|
||||
titulo: ofertaContent.definicion.titulo,
|
||||
texto: ofertaContent.definicion.definicion,
|
||||
elementos: ofertaContent.definicion.elementosClave,
|
||||
},
|
||||
{
|
||||
titulo: ofertaContent.ley.titulo,
|
||||
texto: ofertaContent.ley.enunciado,
|
||||
razones: ofertaContent.ley.razones,
|
||||
},
|
||||
{
|
||||
titulo: 'Factores que Desplazan la Oferta',
|
||||
texto: 'Los siguientes factores causan desplazamientos de la curva de oferta:',
|
||||
factores: ofertaContent.factores,
|
||||
},
|
||||
],
|
||||
},
|
||||
equilibrio: {
|
||||
titulo: 'Equilibrio de Mercado',
|
||||
contenido: [
|
||||
{
|
||||
titulo: equilibrioContent.definicion.titulo,
|
||||
texto: equilibrioContent.definicion.definicion,
|
||||
caracteristicas: equilibrioContent.definicion.caracteristicas,
|
||||
},
|
||||
{
|
||||
titulo: 'Excedentes del Mercado',
|
||||
texto: 'En el equilibrio se generan beneficios para consumidores y productores:',
|
||||
excedentes: [
|
||||
equilibrioContent.excedentes.excedenteConsumidor,
|
||||
equilibrioContent.excedentes.excedenteProductor,
|
||||
],
|
||||
},
|
||||
{
|
||||
titulo: 'Controles de Precio',
|
||||
texto: 'Los gobiernos pueden intervenir el mercado estableciendo precios máximos o mínimos:',
|
||||
controles: [
|
||||
equilibrioContent.controles.precioMaximo,
|
||||
equilibrioContent.controles.precioMinimo,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const currentSeccion = seccionesContenido[activeSeccion];
|
||||
|
||||
// Calcular progreso
|
||||
const ejerciciosCompletados = ejerciciosConfig.filter(
|
||||
(e) => getProgresoForEjercicio(e.id)?.completado
|
||||
).length;
|
||||
const porcentajeProgreso = Math.round((ejerciciosCompletados / ejerciciosConfig.length) * 100);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm sticky top-0 z-10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link to="/modulos" className="inline-flex items-center text-blue-600 hover:underline">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Volver a Módulos
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Progreso:</span>
|
||||
<div className="w-32 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${porcentajeProgreso}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{porcentajeProgreso}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Título del módulo */}
|
||||
<div className="bg-gradient-to-r from-green-600 to-green-800 text-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 bg-white/20 rounded-xl flex items-center justify-center text-3xl font-bold">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Módulo 2: Oferta, Demanda y Equilibrio</h1>
|
||||
<p className="text-green-100 mt-1">
|
||||
Curvas de oferta y demanda, equilibrio de mercado y controles de precio
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => {
|
||||
setActiveTab(tab);
|
||||
setActiveEjercicio(null);
|
||||
}}
|
||||
className={`px-6 py-3 font-medium text-sm transition-colors relative ${
|
||||
activeTab === tab
|
||||
? 'text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab === 'Contenido' && <BookOpen className="w-4 h-4 inline mr-2" />}
|
||||
{tab === 'Ejercicios' && <Trophy className="w-4 h-4 inline mr-2" />}
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<motion.div
|
||||
layoutId="activeTab2"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Contenido según tab activo */}
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'Contenido' ? (
|
||||
<motion.div
|
||||
key="contenido"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mt-6 grid grid-cols-1 lg:grid-cols-4 gap-6"
|
||||
>
|
||||
{/* Navegación de secciones */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="sticky top-24">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Secciones</h3>
|
||||
<nav className="space-y-2">
|
||||
{(Object.keys(seccionesContenido) as Array<keyof typeof seccionesContenido>).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveSeccion(key)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between ${
|
||||
activeSeccion === key
|
||||
? 'bg-green-50 text-green-700 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{seccionesContenido[key].titulo}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Contenido de la sección */}
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
<Card>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">{currentSeccion.titulo}</h2>
|
||||
<div className="space-y-6">
|
||||
{currentSeccion.contenido.map((item, index) => (
|
||||
<div key={index} className="border-b border-gray-100 last:border-0 pb-6 last:pb-0">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-3">{item.titulo}</h3>
|
||||
<p className="text-gray-600 mb-4 leading-relaxed">{item.texto}</p>
|
||||
|
||||
{/* Mostrar elementos clave si existen */}
|
||||
{item.elementos && (
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-600">
|
||||
{item.elementos.map((el: any, i: number) => (
|
||||
<li key={i}>
|
||||
<strong>{el.elemento}:</strong> {el.descripcion}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Mostrar efectos/razones si existen */}
|
||||
{item.efectos && (
|
||||
<div className="space-y-3">
|
||||
{item.efectos.map((efecto: any, i: number) => (
|
||||
<div key={i} className="bg-gray-50 p-3 rounded-lg">
|
||||
<h4 className="font-medium text-gray-800">{efecto.nombre}</h4>
|
||||
<p className="text-sm text-gray-600">{efecto.descripcion}</p>
|
||||
<p className="text-sm text-blue-600 mt-1">Ejemplo: {efecto.ejemplo}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mostrar razones si existen */}
|
||||
{item.razones && (
|
||||
<div className="space-y-3">
|
||||
{item.razones.map((razon: any, i: number) => (
|
||||
<div key={i} className="bg-gray-50 p-3 rounded-lg">
|
||||
<h4 className="font-medium text-gray-800">{razon.nombre}</h4>
|
||||
<p className="text-sm text-gray-600">{razon.descripcion}</p>
|
||||
<p className="text-sm text-green-600 mt-1">Ejemplo: {razon.ejemplo}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mostrar factores si existen */}
|
||||
{item.factores && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{item.factores.map((factor: any, i: number) => (
|
||||
<div key={i} className="bg-blue-50 p-3 rounded-lg border border-blue-100">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{factor.icono}</span>
|
||||
<h4 className="font-medium text-blue-900">{factor.nombre}</h4>
|
||||
</div>
|
||||
<p className="text-sm text-blue-700">{factor.descripcion}</p>
|
||||
<p className="text-sm text-blue-600 mt-1 italic">{factor.ejemplo}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mostrar características si existen */}
|
||||
{item.caracteristicas && (
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-600">
|
||||
{item.caracteristicas.map((car: any, i: number) => (
|
||||
<li key={i}>
|
||||
<strong>{car.caracteristica}:</strong> {car.explicacion}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Mostrar excedentes si existen */}
|
||||
{item.excedentes && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{item.excedentes.map((exc: any, i: number) => (
|
||||
<div key={i} className="bg-purple-50 p-3 rounded-lg border border-purple-100">
|
||||
<h4 className="font-medium text-purple-900">{exc.nombre}</h4>
|
||||
<p className="text-sm text-purple-700">{exc.definicion}</p>
|
||||
<p className="text-sm text-purple-600 mt-1">Fórmula: {exc.formula}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mostrar controles si existen */}
|
||||
{item.controles && (
|
||||
<div className="space-y-3">
|
||||
{item.controles.map((control: any, i: number) => (
|
||||
<div key={i} className="bg-orange-50 p-3 rounded-lg border border-orange-100">
|
||||
<h4 className="font-medium text-orange-900">{control.nombre}</h4>
|
||||
<p className="text-sm text-orange-700">{control.definicion}</p>
|
||||
<p className="text-sm text-orange-600 mt-1">
|
||||
Condición: {control.condicionEfectivo}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-green-50 border-green-200">
|
||||
<h3 className="font-semibold text-green-900 mb-3">Ejercicios Relacionados</h3>
|
||||
<p className="text-green-700 text-sm mb-4">
|
||||
Pon a prueba tus conocimientos con ejercicios interactivos sobre oferta, demanda y equilibrio
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setActiveTab('Ejercicios')}
|
||||
variant="outline"
|
||||
className="border-green-300 text-green-700 hover:bg-green-100"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Ir a Ejercicios
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="ejercicios"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mt-6"
|
||||
>
|
||||
{activeEjercicio ? (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={() => setActiveEjercicio(null)}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Volver a ejercicios
|
||||
</Button>
|
||||
{loading && <span className="text-sm text-gray-500">Guardando progreso...</span>}
|
||||
</div>
|
||||
{ejerciciosConfig.find((e) => e.id === activeEjercicio)?.componente}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{ejerciciosConfig.map((ejercicio, index) => {
|
||||
const progreso = getProgresoForEjercicio(ejercicio.id);
|
||||
const completado = progreso?.completado || false;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={ejercicio.id}
|
||||
className="hover:shadow-lg transition-shadow cursor-pointer"
|
||||
onClick={() => setActiveEjercicio(ejercicio.id)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${
|
||||
completado ? 'bg-green-100 text-green-600' : 'bg-green-100 text-green-600'
|
||||
}`}
|
||||
>
|
||||
{completado ? (
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
) : (
|
||||
<span className="text-xl font-bold">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900">{ejercicio.titulo}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{ejercicio.descripcion}</p>
|
||||
{completado && progreso && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full">
|
||||
Completado
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{progreso.puntuacion} pts
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full mt-4" size="sm">
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{completado ? 'Repetir' : 'Comenzar'}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ejerciciosCompletados === ejerciciosConfig.length && ejerciciosConfig.length > 0 && (
|
||||
<Card className="mt-6 bg-green-50 border-green-200">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Trophy className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-green-900">¡Felicitaciones!</h3>
|
||||
<p className="text-green-700 text-sm">
|
||||
Has completado todos los ejercicios de este módulo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modulo2Page;
|
||||
610
frontend/src/pages/modulos/Modulo3Page.tsx
Normal file
610
frontend/src/pages/modulos/Modulo3Page.tsx
Normal file
@@ -0,0 +1,610 @@
|
||||
// @ts-nocheck
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { progresoService } from '../../services/api';
|
||||
import type { Progreso } from '../../types';
|
||||
import { ArrowLeft, CheckCircle, Play, BookOpen, Trophy, ChevronRight } from 'lucide-react';
|
||||
|
||||
// Importar contenido del módulo 3
|
||||
import { conceptosElasticidad } from '../../content/modulo3/conceptos';
|
||||
import { tiposElasticidad } from '../../content/modulo3/tipos';
|
||||
import { clasificacionBienes } from '../../content/modulo3/clasificacion';
|
||||
import { ejercicios as modulo3Ejercicios } from '../../content/modulo3/ejercicios';
|
||||
|
||||
// Importar componentes de ejercicios
|
||||
import { CalculadoraElasticidad, ClasificadorBienes, EjerciciosExamen } from '../../components/exercises/modulo3';
|
||||
|
||||
const TABS = ['Contenido', 'Ejercicios'] as const;
|
||||
type Tab = typeof TABS[number];
|
||||
|
||||
interface EjercicioConfig {
|
||||
id: string;
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
componente: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ContenidoItem {
|
||||
titulo: string;
|
||||
texto: string;
|
||||
interpretacion?: string;
|
||||
formula?: { latex?: string; ecuacion?: string } | string;
|
||||
interpretaciones?: Array<{
|
||||
rango: string;
|
||||
clasificacion: string;
|
||||
significado?: string;
|
||||
descripcion?: string;
|
||||
ejemplo?: string;
|
||||
}>;
|
||||
factores?: Array<{
|
||||
factor?: string;
|
||||
nombre?: string;
|
||||
efecto?: string;
|
||||
descripcion?: string;
|
||||
explicacion?: string;
|
||||
}>;
|
||||
reglas?: Array<{
|
||||
elasticidad: string;
|
||||
efectoPrecioArriba: string;
|
||||
efectoPrecioAbajo: string;
|
||||
}>;
|
||||
clasificacion?: Array<{
|
||||
tipo: string;
|
||||
descripcion: string;
|
||||
condicion: string;
|
||||
subtipos?: Array<{ tipo: string; rango: string }>;
|
||||
}>;
|
||||
categorias?: Array<{
|
||||
tipo: string;
|
||||
descripcion: string;
|
||||
condicion: string;
|
||||
ejemplos?: string[];
|
||||
}>;
|
||||
matriz?: Array<{
|
||||
combinacion: string;
|
||||
ejemplo: string;
|
||||
caracteristicas: string;
|
||||
}>;
|
||||
determinantes?: string[];
|
||||
}
|
||||
|
||||
export function Modulo3Page() {
|
||||
const { numero } = useParams<{ numero: string }>();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('Contenido');
|
||||
const [activeSeccion, setActiveSeccion] = useState<'conceptos' | 'tipos' | 'clasificacion'>('conceptos');
|
||||
const [activeEjercicio, setActiveEjercicio] = useState<string | null>(null);
|
||||
const [progresos, setProgresos] = useState<Progreso[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadProgreso();
|
||||
}, []);
|
||||
|
||||
const loadProgreso = async () => {
|
||||
try {
|
||||
const data = await progresoService.getProgreso();
|
||||
setProgresos(data);
|
||||
} catch {
|
||||
// Silenciar error
|
||||
}
|
||||
};
|
||||
|
||||
const getProgresoForEjercicio = (ejercicioId: string) => {
|
||||
return progresos.find(
|
||||
(p) => p.modulo_numero === 3 && p.ejercicio_id === ejercicioId
|
||||
);
|
||||
};
|
||||
|
||||
const handleCompleteEjercicio = async (ejercicioId: string, puntuacion: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await progresoService.saveProgreso(ejercicioId, puntuacion);
|
||||
await loadProgreso();
|
||||
} catch {
|
||||
// Silenciar error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Configuración de ejercicios
|
||||
const ejerciciosConfig: EjercicioConfig[] = [
|
||||
{
|
||||
id: 'calculadora-elasticidad',
|
||||
titulo: 'Calculadora de Elasticidad',
|
||||
descripcion: 'Calcula paso a paso la elasticidad precio, ingreso y cruzada con ejemplos prácticos.',
|
||||
componente: (
|
||||
<CalculadoraElasticidad
|
||||
ejercicioId="calculadora-elasticidad"
|
||||
onComplete={(puntuacion) => handleCompleteEjercicio('calculadora-elasticidad', puntuacion)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'clasificador-bienes',
|
||||
titulo: 'Clasificador de Bienes',
|
||||
descripcion: 'Clasifica bienes según su elasticidad ingreso y cruzada. Identifica normales, inferiores, lujos, sustitutos y complementos.',
|
||||
componente: (
|
||||
<ClasificadorBienes
|
||||
ejercicioId="clasificador-bienes"
|
||||
onComplete={(puntuacion) => handleCompleteEjercicio('clasificador-bienes', puntuacion)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ejercicios-examen',
|
||||
titulo: 'Ejercicios Tipo Examen',
|
||||
descripcion: 'Resuelve problemas integradores de elasticidad con dificultad de examen.',
|
||||
componente: (
|
||||
<EjerciciosExamen
|
||||
ejercicioId="ejercicios-examen"
|
||||
onComplete={(puntuacion) => handleCompleteEjercicio('ejercicios-examen', puntuacion)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Estructura de contenido del módulo 3
|
||||
const seccionesContenido: {
|
||||
conceptos: { titulo: string; contenido: ContenidoItem[] };
|
||||
tipos: { titulo: string; contenido: ContenidoItem[] };
|
||||
clasificacion: { titulo: string; contenido: ContenidoItem[] };
|
||||
} = {
|
||||
conceptos: {
|
||||
titulo: 'Conceptos Fundamentales',
|
||||
contenido: [
|
||||
{
|
||||
titulo: conceptosElasticidad.definicionElasticidad.titulo,
|
||||
texto: conceptosElasticidad.definicionElasticidad.definicion,
|
||||
interpretacion: conceptosElasticidad.definicionElasticidad.interpretacionIntuitiva,
|
||||
formula: conceptosElasticidad.definicionElasticidad.formulaGeneral,
|
||||
},
|
||||
{
|
||||
titulo: conceptosElasticidad.elasticidadPrecioDemanda.titulo,
|
||||
texto: conceptosElasticidad.elasticidadPrecioDemanda.definicion,
|
||||
interpretaciones: conceptosElasticidad.elasticidadPrecioDemanda.interpretacion,
|
||||
},
|
||||
{
|
||||
titulo: conceptosElasticidad.determinantesElasticidad.titulo,
|
||||
texto: 'Los siguientes factores determinan la elasticidad de un bien:',
|
||||
factores: conceptosElasticidad.determinantesElasticidad.factores,
|
||||
},
|
||||
{
|
||||
titulo: conceptosElasticidad.relacionIngresoTotal.titulo,
|
||||
texto: conceptosElasticidad.relacionIngresoTotal.definicion,
|
||||
reglas: conceptosElasticidad.relacionIngresoTotal.reglas,
|
||||
},
|
||||
],
|
||||
},
|
||||
tipos: {
|
||||
titulo: 'Tipos de Elasticidad',
|
||||
contenido: [
|
||||
{
|
||||
titulo: 'Elasticidad Precio de la Demanda (Ed)',
|
||||
texto: tiposElasticidad.tipos[0].descripcion,
|
||||
formula: tiposElasticidad.tipos[0].formula,
|
||||
determinantes: tiposElasticidad.tipos[0].determinantes,
|
||||
},
|
||||
{
|
||||
titulo: 'Elasticidad Ingreso de la Demanda (Ei)',
|
||||
texto: tiposElasticidad.tipos[1].descripcion,
|
||||
formula: tiposElasticidad.tipos[1].formula,
|
||||
clasificacion: tiposElasticidad.tipos[1].clasificacionBienes,
|
||||
},
|
||||
{
|
||||
titulo: 'Elasticidad Cruzada (Exy)',
|
||||
texto: tiposElasticidad.tipos[2].descripcion,
|
||||
formula: tiposElasticidad.tipos[2].formula,
|
||||
categorias: tiposElasticidad.tipos[2].clasificacionBienes,
|
||||
},
|
||||
{
|
||||
titulo: 'Elasticidad Precio de la Oferta (Es)',
|
||||
texto: tiposElasticidad.tipos[3].descripcion,
|
||||
formula: tiposElasticidad.tipos[3].formula,
|
||||
interpretacion: tiposElasticidad.tipos[3].interpretacion,
|
||||
},
|
||||
],
|
||||
},
|
||||
clasificacion: {
|
||||
titulo: 'Clasificación de Bienes',
|
||||
contenido: [
|
||||
{
|
||||
titulo: 'Según Elasticidad Ingreso',
|
||||
texto: clasificacionBienes.clasificacionPorIngreso.descripcion,
|
||||
formula: clasificacionBienes.clasificacionPorIngreso.formulaReferencia,
|
||||
categorias: clasificacionBienes.clasificacionPorIngreso.categorias,
|
||||
},
|
||||
{
|
||||
titulo: 'Según Elasticidad Cruzada',
|
||||
texto: clasificacionBienes.clasificacionPorElasticidadCruzada.descripcion,
|
||||
formula: clasificacionBienes.clasificacionPorElasticidadCruzada.formulaReferencia,
|
||||
categorias: clasificacionBienes.clasificacionPorElasticidadCruzada.categorias,
|
||||
},
|
||||
{
|
||||
titulo: 'Matriz de Clasificación Completa',
|
||||
texto: clasificacionBienes.matrizClasificacionCompleta.descripcion,
|
||||
matriz: clasificacionBienes.matrizClasificacionCompleta.matriz,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const currentSeccion = seccionesContenido[activeSeccion];
|
||||
|
||||
// Calcular progreso
|
||||
const ejerciciosCompletados = ejerciciosConfig.filter(
|
||||
(e) => getProgresoForEjercicio(e.id)?.completado
|
||||
).length;
|
||||
const porcentajeProgreso = Math.round((ejerciciosCompletados / ejerciciosConfig.length) * 100);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm sticky top-0 z-10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link to="/modulos" className="inline-flex items-center text-blue-600 hover:underline">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Volver a Módulos
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Progreso:</span>
|
||||
<div className="w-32 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${porcentajeProgreso}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{porcentajeProgreso}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Título del módulo */}
|
||||
<div className="bg-gradient-to-r from-purple-600 to-purple-800 text-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 bg-white/20 rounded-xl flex items-center justify-center text-3xl font-bold">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Módulo 3: Elasticidad</h1>
|
||||
<p className="text-purple-100 mt-1">
|
||||
Tipos de elasticidad, clasificación de bienes y análisis de sensibilidad
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => {
|
||||
setActiveTab(tab);
|
||||
setActiveEjercicio(null);
|
||||
}}
|
||||
className={`px-6 py-3 font-medium text-sm transition-colors relative ${
|
||||
activeTab === tab
|
||||
? 'text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab === 'Contenido' && <BookOpen className="w-4 h-4 inline mr-2" />}
|
||||
{tab === 'Ejercicios' && <Trophy className="w-4 h-4 inline mr-2" />}
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<motion.div
|
||||
layoutId="activeTab3"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Contenido según tab activo */}
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'Contenido' ? (
|
||||
<motion.div
|
||||
key="contenido"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mt-6 grid grid-cols-1 lg:grid-cols-4 gap-6"
|
||||
>
|
||||
{/* Navegación de secciones */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="sticky top-24">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Secciones</h3>
|
||||
<nav className="space-y-2">
|
||||
{(Object.keys(seccionesContenido) as Array<keyof typeof seccionesContenido>).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveSeccion(key)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between ${
|
||||
activeSeccion === key
|
||||
? 'bg-purple-50 text-purple-700 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{seccionesContenido[key].titulo}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Contenido de la sección */}
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
<Card>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">{currentSeccion.titulo}</h2>
|
||||
<div className="space-y-6">
|
||||
{currentSeccion.contenido.map((item, index) => (
|
||||
<div key={index} className="border-b border-gray-100 last:border-0 pb-6 last:pb-0">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-3">{item.titulo}</h3>
|
||||
<p className="text-gray-600 mb-4 leading-relaxed">{item.texto}</p>
|
||||
|
||||
{/* Mostrar interpretación si existe */}
|
||||
{item.interpretacion && (
|
||||
<div className="bg-blue-50 p-4 rounded-lg mb-4">
|
||||
<p className="text-blue-800 text-sm">{item.interpretacion}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mostrar fórmula si existe */}
|
||||
{item.formula && (
|
||||
<div className="bg-gray-100 p-4 rounded-lg mb-4">
|
||||
<pre className="text-sm text-gray-800 overflow-x-auto">
|
||||
{item.formula.latex || item.formula.ecuacion || item.formula}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mostrar interpretaciones de elasticidad */}
|
||||
{item.interpretaciones && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{item.interpretaciones.map((interp: any, i: number) => (
|
||||
<div key={i} className={`p-3 rounded-lg border ${
|
||||
interp.rango === '|Ed| > 1' || interp.rango === 'Es > 1'
|
||||
? 'bg-green-50 border-green-200'
|
||||
: interp.rango === '|Ed| < 1' || interp.rango === 'Es < 1'
|
||||
? 'bg-yellow-50 border-yellow-200'
|
||||
: 'bg-blue-50 border-blue-200'
|
||||
}`}>
|
||||
<h4 className="font-medium text-gray-800">{interp.clasificacion}</h4>
|
||||
<p className="text-sm text-gray-600">{interp.significado || interp.descripcion}</p>
|
||||
{interp.ejemplo && (
|
||||
<p className="text-sm text-gray-500 mt-1">Ejemplo: {interp.ejemplo}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mostrar factores determinantes */}
|
||||
{item.factores && (
|
||||
<div className="space-y-2">
|
||||
{item.factores.map((factor: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-800">{factor.factor || factor.nombre}</h4>
|
||||
<p className="text-sm text-gray-600">{factor.efecto || factor.descripcion}</p>
|
||||
{factor.explicacion && (
|
||||
<p className="text-sm text-gray-500 mt-1">{factor.explicacion}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mostrar reglas de ingreso total */}
|
||||
{item.reglas && (
|
||||
<div className="space-y-3">
|
||||
{item.reglas.map((regla: any, i: number) => (
|
||||
<div key={i} className="bg-purple-50 p-3 rounded-lg border border-purple-200">
|
||||
<h4 className="font-medium text-purple-900">{regla.elasticidad}</h4>
|
||||
<div className="text-sm text-purple-700 mt-1 space-y-1">
|
||||
<p>Precio ↑: {regla.efectoPrecioArriba}</p>
|
||||
<p>Precio ↓: {regla.efectoPrecioAbajo}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mostrar clasificación de bienes por ingreso */}
|
||||
{item.clasificacion && item.titulo?.includes('Ingreso') && (
|
||||
<div className="space-y-3">
|
||||
{item.clasificacion.map((cat: any, i: number) => (
|
||||
<div key={i} className={`p-3 rounded-lg border ${
|
||||
cat.condicion?.includes('> 0') && !cat.condicion?.includes('<')
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}`}>
|
||||
<h4 className="font-medium text-gray-800">{cat.tipo}</h4>
|
||||
<p className="text-sm text-gray-600">{cat.descripcion}</p>
|
||||
<p className="text-sm font-medium mt-1">Condición: {cat.condicion}</p>
|
||||
{cat.subtipos && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{cat.subtipos.map((sub: any, j: number) => (
|
||||
<div key={j} className="text-sm text-gray-600 pl-3 border-l-2 border-gray-300">
|
||||
<strong>{sub.tipo}:</strong> {sub.rango}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mostrar clasificación por elasticidad cruzada */}
|
||||
{item.categorias && item.titulo?.includes('Cruzada') && (
|
||||
<div className="space-y-3">
|
||||
{item.categorias.map((cat: any, i: number) => (
|
||||
<div key={i} className={`p-3 rounded-lg border ${
|
||||
cat.condicion?.includes('> 0')
|
||||
? 'bg-green-50 border-green-200'
|
||||
: cat.condicion?.includes('< 0')
|
||||
? 'bg-red-50 border-red-200'
|
||||
: 'bg-gray-50 border-gray-200'
|
||||
}`}>
|
||||
<h4 className="font-medium text-gray-800">{cat.tipo}</h4>
|
||||
<p className="text-sm text-gray-600">{cat.descripcion}</p>
|
||||
<p className="text-sm font-medium mt-1">Exy {cat.condicion}</p>
|
||||
{cat.ejemplos && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Ejemplos: {cat.ejemplos.slice(0, 3).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mostrar matriz de clasificación */}
|
||||
{item.matriz && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700">Combinación</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700">Ejemplo</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700">Características</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{item.matriz.map((fila: any, i: number) => (
|
||||
<tr key={i}>
|
||||
<td className="px-3 py-2 font-medium text-gray-800">{fila.combinacion}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{fila.ejemplo}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{fila.caracteristicas}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-purple-50 border-purple-200">
|
||||
<h3 className="font-semibold text-purple-900 mb-3">Ejercicios Relacionados</h3>
|
||||
<p className="text-purple-700 text-sm mb-4">
|
||||
Practica el cálculo y clasificación de elasticidad con ejercicios interactivos
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setActiveTab('Ejercicios')}
|
||||
variant="outline"
|
||||
className="border-purple-300 text-purple-700 hover:bg-purple-100"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Ir a Ejercicios
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="ejercicios"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mt-6"
|
||||
>
|
||||
{activeEjercicio ? (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={() => setActiveEjercicio(null)}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Volver a ejercicios
|
||||
</Button>
|
||||
{loading && <span className="text-sm text-gray-500">Guardando progreso...</span>}
|
||||
</div>
|
||||
{ejerciciosConfig.find((e) => e.id === activeEjercicio)?.componente}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{ejerciciosConfig.map((ejercicio, index) => {
|
||||
const progreso = getProgresoForEjercicio(ejercicio.id);
|
||||
const completado = progreso?.completado || false;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={ejercicio.id}
|
||||
className="hover:shadow-lg transition-shadow cursor-pointer"
|
||||
onClick={() => setActiveEjercicio(ejercicio.id)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${
|
||||
completado ? 'bg-green-100 text-green-600' : 'bg-purple-100 text-purple-600'
|
||||
}`}
|
||||
>
|
||||
{completado ? (
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
) : (
|
||||
<span className="text-xl font-bold">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900">{ejercicio.titulo}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{ejercicio.descripcion}</p>
|
||||
{completado && progreso && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full">
|
||||
Completado
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{progreso.puntuacion} pts
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full mt-4" size="sm">
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{completado ? 'Repetir' : 'Comenzar'}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ejerciciosCompletados === ejerciciosConfig.length && ejerciciosConfig.length > 0 && (
|
||||
<Card className="mt-6 bg-green-50 border-green-200">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Trophy className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-green-900">¡Felicitaciones!</h3>
|
||||
<p className="text-green-700 text-sm">
|
||||
Has completado todos los ejercicios de este módulo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modulo3Page;
|
||||
423
frontend/src/pages/modulos/Modulo4Page.tsx
Normal file
423
frontend/src/pages/modulos/Modulo4Page.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
// @ts-nocheck
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { progresoService } from '../../services/api';
|
||||
import type { Progreso } from '../../types';
|
||||
import { ArrowLeft, CheckCircle, Play, BookOpen, Trophy, ChevronRight } from 'lucide-react';
|
||||
|
||||
// Importar contenido del módulo 4
|
||||
import { produccion } from '../../content/modulo4/produccion';
|
||||
import { costos } from '../../content/modulo4/costos';
|
||||
import { mercado } from '../../content/modulo4/mercado';
|
||||
|
||||
// Importar componentes de ejercicios
|
||||
import { CalculadoraCostos, SimuladorProduccion, VisualizadorExcedentes } from '../../components/exercises/modulo4';
|
||||
|
||||
const TABS = ['Contenido', 'Ejercicios'] as const;
|
||||
type Tab = typeof TABS[number];
|
||||
|
||||
interface EjercicioConfig {
|
||||
id: string;
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
componente: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Modulo4Page() {
|
||||
const { numero: _numero } = useParams<{ numero: string }>();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('Contenido');
|
||||
const [activeSeccion, setActiveSeccion] = useState<'produccion' | 'costos' | 'mercado'>('produccion');
|
||||
const [activeEjercicio, setActiveEjercicio] = useState<string | null>(null);
|
||||
const [progresos, setProgresos] = useState<Progreso[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadProgreso();
|
||||
}, []);
|
||||
|
||||
const loadProgreso = async () => {
|
||||
try {
|
||||
const data = await progresoService.getProgreso();
|
||||
setProgresos(data);
|
||||
} catch {
|
||||
// Silenciar error
|
||||
}
|
||||
};
|
||||
|
||||
const getProgresoForEjercicio = (ejercicioId: string) => {
|
||||
return progresos.find(
|
||||
(p) => p.modulo_numero === 4 && p.ejercicio_id === ejercicioId
|
||||
);
|
||||
};
|
||||
|
||||
const handleCompleteEjercicio = async (ejercicioId: string, puntuacion: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await progresoService.saveProgreso(ejercicioId, puntuacion);
|
||||
await loadProgreso();
|
||||
} catch {
|
||||
// Silenciar error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Configuración de ejercicios
|
||||
const ejerciciosConfig: EjercicioConfig[] = [
|
||||
{
|
||||
id: 'calculadora-costos',
|
||||
titulo: 'Calculadora de Costos',
|
||||
descripcion: 'Ingresa CF, CV para cada nivel de producción y calcula automáticamente todos los costos medios y marginales.',
|
||||
componente: (
|
||||
<CalculadoraCostos
|
||||
ejercicioId="calculadora-costos"
|
||||
onComplete={() => handleCompleteEjercicio('calculadora-costos', 100)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'simulador-produccion',
|
||||
titulo: 'Simulador de Producción',
|
||||
descripcion: 'Dado un precio de mercado y curva de costos, encuentra la cantidad óptima y determina si debes producir o cerrar.',
|
||||
componente: (
|
||||
<SimuladorProduccion
|
||||
ejercicioId="simulador-produccion"
|
||||
onComplete={() => handleCompleteEjercicio('simulador-produccion', 100)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'visualizador-excedentes',
|
||||
titulo: 'Visualizador de Excedentes',
|
||||
descripcion: 'Interactúa con el gráfico para ver cómo cambia el excedente del productor al variar el precio y la cantidad.',
|
||||
componente: (
|
||||
<VisualizadorExcedentes
|
||||
ejercicioId="visualizador-excedentes"
|
||||
onComplete={() => handleCompleteEjercicio('visualizador-excedentes', 100)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Estructura de contenido del módulo 4
|
||||
const seccionesContenido = {
|
||||
produccion: {
|
||||
titulo: produccion.titulo,
|
||||
contenido: produccion.contenido,
|
||||
},
|
||||
costos: {
|
||||
titulo: costos.titulo,
|
||||
contenido: costos.contenido,
|
||||
},
|
||||
mercado: {
|
||||
titulo: mercado.titulo,
|
||||
contenido: mercado.contenido,
|
||||
},
|
||||
};
|
||||
|
||||
const currentSeccion = seccionesContenido[activeSeccion];
|
||||
|
||||
// Calcular progreso
|
||||
const ejerciciosCompletados = ejerciciosConfig.filter(
|
||||
(e) => getProgresoForEjercicio(e.id)?.completado
|
||||
).length;
|
||||
const porcentajeProgreso = Math.round((ejerciciosCompletados / ejerciciosConfig.length) * 100);
|
||||
|
||||
// Función auxiliar para renderizar contenido markdown-like
|
||||
const renderContenido = (texto: string) => {
|
||||
const partes = texto.split(/(\*\*.*?\*\*|\$.*?\$|`.*?`|\n\n)/);
|
||||
return partes.map((parte, index) => {
|
||||
if (parte.startsWith('**') && parte.endsWith('**')) {
|
||||
return <strong key={index}>{parte.slice(2, -2)}</strong>;
|
||||
}
|
||||
if (parte.startsWith('$') && parte.endsWith('$')) {
|
||||
return <code key={index} className="bg-gray-100 px-1 rounded">{parte.slice(1, -1)}</code>;
|
||||
}
|
||||
if (parte.startsWith('`') && parte.endsWith('`')) {
|
||||
return <code key={index} className="bg-gray-100 px-1 rounded text-red-600">{parte.slice(1, -1)}</code>;
|
||||
}
|
||||
if (parte === '\n\n') {
|
||||
return <br key={index} />;
|
||||
}
|
||||
return <span key={index}>{parte}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm sticky top-0 z-10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link to="/modulos" className="inline-flex items-center text-blue-600 hover:underline">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Volver a Módulos
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Progreso:</span>
|
||||
<div className="w-32 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${porcentajeProgreso}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{porcentajeProgreso}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Título del módulo */}
|
||||
<div className="bg-gradient-to-r from-orange-600 to-orange-800 text-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 bg-white/20 rounded-xl flex items-center justify-center text-3xl font-bold">
|
||||
4
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Módulo 4: Teoría del Productor</h1>
|
||||
<p className="text-orange-100 mt-1">
|
||||
Producción, costos y competencia perfecta
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => {
|
||||
setActiveTab(tab);
|
||||
setActiveEjercicio(null);
|
||||
}}
|
||||
className={`px-6 py-3 font-medium text-sm transition-colors relative ${
|
||||
activeTab === tab
|
||||
? 'text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab === 'Contenido' && <BookOpen className="w-4 h-4 inline mr-2" />}
|
||||
{tab === 'Ejercicios' && <Trophy className="w-4 h-4 inline mr-2" />}
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<motion.div
|
||||
layoutId="activeTab4"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Contenido según tab activo */}
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'Contenido' ? (
|
||||
<motion.div
|
||||
key="contenido"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mt-6 grid grid-cols-1 lg:grid-cols-4 gap-6"
|
||||
>
|
||||
{/* Navegación de secciones */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="sticky top-24">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Secciones</h3>
|
||||
<nav className="space-y-2">
|
||||
{(Object.keys(seccionesContenido) as Array<keyof typeof seccionesContenido>).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveSeccion(key)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between ${
|
||||
activeSeccion === key
|
||||
? 'bg-orange-50 text-orange-700 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{seccionesContenido[key].titulo}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Contenido de la sección */}
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
<Card>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">{currentSeccion.titulo}</h2>
|
||||
<div className="space-y-6">
|
||||
{currentSeccion.contenido.map((seccion, index) => (
|
||||
<div key={index} className="border-b border-gray-100 last:border-0 pb-6 last:pb-0">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-3">{seccion.titulo}</h3>
|
||||
<div className="prose prose-orange max-w-none">
|
||||
{seccion.contenido.split('\n\n').map((parrafo, pIndex) => {
|
||||
// Detectar si es una tabla
|
||||
if (parrafo.includes('|') && parrafo.includes('---')) {
|
||||
const lineas = parrafo.split('\n').filter(l => l.trim() && !l.includes('---'));
|
||||
if (lineas.length >= 2) {
|
||||
return (
|
||||
<div key={pIndex} className="overflow-x-auto my-4">
|
||||
<table className="min-w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
{lineas[0].split('|').filter(Boolean).map((cell, cIndex) => (
|
||||
<th key={cIndex} className="px-3 py-2 text-left font-medium border">
|
||||
{cell.trim()}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineas.slice(1).map((linea, lIndex) => (
|
||||
<tr key={lIndex} className="border-b">
|
||||
{linea.split('|').filter(Boolean).map((cell, cIndex) => (
|
||||
<td key={cIndex} className="px-3 py-2 border">
|
||||
{cell.trim()}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Detectar si es código
|
||||
if (parrafo.startsWith('```')) {
|
||||
return (
|
||||
<pre key={pIndex} className="bg-gray-900 text-white p-4 rounded-lg overflow-x-auto my-4">
|
||||
<code>{parrafo.replace(/```/g, '').trim()}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p key={pIndex} className="text-gray-600 mb-4 leading-relaxed whitespace-pre-line">
|
||||
{renderContenido(parrafo)}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-orange-50 border-orange-200">
|
||||
<h3 className="font-semibold text-orange-900 mb-3">Ejercicios Relacionados</h3>
|
||||
<p className="text-orange-700 text-sm mb-4">
|
||||
Practica con simuladores interactivos de costos, producción y excedentes
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setActiveTab('Ejercicios')}
|
||||
variant="outline"
|
||||
className="border-orange-300 text-orange-700 hover:bg-orange-100"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Ir a Ejercicios
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="ejercicios"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mt-6"
|
||||
>
|
||||
{activeEjercicio ? (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={() => setActiveEjercicio(null)}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Volver a ejercicios
|
||||
</Button>
|
||||
{loading && <span className="text-sm text-gray-500">Guardando progreso...</span>}
|
||||
</div>
|
||||
{ejerciciosConfig.find((e) => e.id === activeEjercicio)?.componente}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{ejerciciosConfig.map((ejercicio, index) => {
|
||||
const progreso = getProgresoForEjercicio(ejercicio.id);
|
||||
const completado = progreso?.completado || false;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={ejercicio.id}
|
||||
className="hover:shadow-lg transition-shadow cursor-pointer"
|
||||
onClick={() => setActiveEjercicio(ejercicio.id)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${
|
||||
completado ? 'bg-green-100 text-green-600' : 'bg-orange-100 text-orange-600'
|
||||
}`}
|
||||
>
|
||||
{completado ? (
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
) : (
|
||||
<span className="text-xl font-bold">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900">{ejercicio.titulo}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{ejercicio.descripcion}</p>
|
||||
{completado && progreso && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full">
|
||||
Completado
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{progreso.puntuacion} pts
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full mt-4" size="sm">
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{completado ? 'Repetir' : 'Comenzar'}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ejerciciosCompletados === ejerciciosConfig.length && ejerciciosConfig.length > 0 && (
|
||||
<Card className="mt-6 bg-green-50 border-green-200">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Trophy className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-green-900">¡Felicitaciones!</h3>
|
||||
<p className="text-green-700 text-sm">
|
||||
Has completado todos los ejercicios de este módulo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modulo4Page;
|
||||
4
frontend/src/pages/modulos/index.ts
Normal file
4
frontend/src/pages/modulos/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { Modulo1Page } from './Modulo1Page';
|
||||
export { Modulo2Page } from './Modulo2Page';
|
||||
export { Modulo3Page } from './Modulo3Page';
|
||||
export { Modulo4Page } from './Modulo4Page';
|
||||
Reference in New Issue
Block a user