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:
184
frontend/src/components/exercises/EjercicioWrapper.tsx
Normal file
184
frontend/src/components/exercises/EjercicioWrapper.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useState, isValidElement } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card } from '../ui/Card';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Trophy, Star, RotateCcw, Home, ArrowRight, CheckCircle } from 'lucide-react';
|
||||
import { useEjercicioProgreso } from '../../hooks/useEjercicioProgreso';
|
||||
|
||||
interface EjercicioWrapperProps {
|
||||
moduloId: string;
|
||||
ejercicioId: string;
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
puntosMaximos: number;
|
||||
onComplete?: (puntuacion?: number) => void;
|
||||
onRetry?: () => void;
|
||||
onExit?: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EjercicioWrapper({
|
||||
moduloId,
|
||||
ejercicioId,
|
||||
titulo,
|
||||
descripcion,
|
||||
puntosMaximos,
|
||||
onComplete,
|
||||
onRetry,
|
||||
onExit,
|
||||
children,
|
||||
}: EjercicioWrapperProps) {
|
||||
const { puntuacionAnterior, intentos, guardarProgreso } = useEjercicioProgreso({
|
||||
moduloId,
|
||||
ejercicioId,
|
||||
onComplete,
|
||||
});
|
||||
|
||||
const [mostrarCompletado, setMostrarCompletado] = useState(false);
|
||||
const [puntuacionActual, setPuntuacionActual] = useState(0);
|
||||
|
||||
const handleCompletar = (puntuacion: number) => {
|
||||
guardarProgreso(puntuacion);
|
||||
setPuntuacionActual(puntuacion);
|
||||
setMostrarCompletado(true);
|
||||
};
|
||||
|
||||
const esMejorPuntuacion = puntuacionAnterior !== undefined && puntuacionActual > puntuacionAnterior;
|
||||
|
||||
// Pasar handleCompletar a los hijos
|
||||
const childrenWithProps = isValidElement(children)
|
||||
? React.cloneElement(children as React.ReactElement<any>, { onCompletar: handleCompletar })
|
||||
: children;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AnimatePresence mode="wait">
|
||||
{!mostrarCompletado ? (
|
||||
<motion.div
|
||||
key="ejercicio"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{titulo}</h2>
|
||||
<p className="text-gray-600 mt-1">{descripcion}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 px-3 py-1 rounded-full">
|
||||
<Trophy size={16} />
|
||||
<span className="font-semibold">{puntosMaximos} pts máx.</span>
|
||||
</div>
|
||||
{puntuacionAnterior !== undefined && (
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
Mejor puntuación: {puntuacionAnterior} pts
|
||||
<span className="text-gray-400"> ({intentos} {intentos === 1 ? 'intento' : 'intentos'})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{childrenWithProps}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="completado"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200 }}
|
||||
>
|
||||
<Card className="text-center py-12">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 200,
|
||||
delay: 0.2
|
||||
}}
|
||||
className="inline-flex items-center justify-center w-24 h-24 bg-gradient-to-br from-yellow-400 to-orange-500 rounded-full mb-6 shadow-lg"
|
||||
>
|
||||
<Trophy size={48} className="text-white" />
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
¡Ejercicio Completado!
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
||||
Has completado el ejercicio. Revisa tu puntuación y decide si quieres intentarlo de nuevo para mejorar tu marca.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 max-w-lg mx-auto mb-8">
|
||||
<div className="bg-blue-50 rounded-xl p-4">
|
||||
<Star className="w-6 h-6 text-blue-500 mx-auto mb-2" />
|
||||
<p className="text-2xl font-bold text-blue-700">{puntuacionActual}</p>
|
||||
<p className="text-sm text-blue-600">Puntuación</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 rounded-xl p-4">
|
||||
<Trophy className="w-6 h-6 text-purple-500 mx-auto mb-2" />
|
||||
<p className="text-2xl font-bold text-purple-700">{puntosMaximos}</p>
|
||||
<p className="text-sm text-purple-600">Máximo</p>
|
||||
</div>
|
||||
|
||||
<div className={`rounded-xl p-4 ${esMejorPuntuacion ? 'bg-green-50' : 'bg-gray-50'}`}>
|
||||
<CheckCircle className={`w-6 h-6 mx-auto mb-2 ${esMejorPuntuacion ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
<p className={`text-2xl font-bold ${esMejorPuntuacion ? 'text-green-700' : 'text-gray-700'}`}>
|
||||
{Math.round((puntuacionActual / puntosMaximos) * 100)}%
|
||||
</p>
|
||||
<p className={`text-sm ${esMejorPuntuacion ? 'text-green-600' : 'text-gray-500'}`}>
|
||||
{esMejorPuntuacion ? '¡Récord!' : 'Precisión'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{esMejorPuntuacion && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-green-100 text-green-700 rounded-full font-medium">
|
||||
<Star size={18} />
|
||||
¡Nueva mejor puntuación! +{puntuacionActual - (puntuacionAnterior || 0)} pts
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<Button variant="outline" onClick={onExit}>
|
||||
<Home size={18} className="mr-2" />
|
||||
Volver al módulo
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setMostrarCompletado(false);
|
||||
if (onRetry) onRetry();
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={18} className="mr-2" />
|
||||
Intentar de nuevo
|
||||
</Button>
|
||||
|
||||
{!esMejorPuntuacion && puntuacionActual < puntosMaximos && (
|
||||
<Button onClick={onExit}>
|
||||
Siguiente ejercicio
|
||||
<ArrowRight size={18} className="ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EjercicioWrapper;
|
||||
1
frontend/src/components/exercises/index.ts
Normal file
1
frontend/src/components/exercises/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { EjercicioWrapper } from './EjercicioWrapper';
|
||||
431
frontend/src/components/exercises/modulo1/FlujoCircular.tsx
Normal file
431
frontend/src/components/exercises/modulo1/FlujoCircular.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardHeader } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { CheckCircle, XCircle, Trophy, Users, Building2, Landmark, Globe, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface FlujoCircularProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
type Agente = 'familias' | 'empresas' | 'estado' | 'exterior';
|
||||
type TipoFlujo = 'real' | 'monetario';
|
||||
|
||||
interface Elemento {
|
||||
id: string;
|
||||
texto: string;
|
||||
tipo: TipoFlujo;
|
||||
origen: Agente;
|
||||
destino: Agente;
|
||||
}
|
||||
|
||||
interface Nivel {
|
||||
nombre: string;
|
||||
descripcion: string;
|
||||
agentes: Agente[];
|
||||
elementos: Elemento[];
|
||||
}
|
||||
|
||||
const NIVELES: Nivel[] = [
|
||||
{
|
||||
nombre: 'Básico',
|
||||
descripcion: 'Solo Familias y Empresas',
|
||||
agentes: ['familias', 'empresas'],
|
||||
elementos: [
|
||||
{ id: 'trabajo', texto: '💪 Trabajo', tipo: 'real', origen: 'familias', destino: 'empresas' },
|
||||
{ id: 'salarios', texto: '💵 Salarios', tipo: 'monetario', origen: 'empresas', destino: 'familias' },
|
||||
{ id: 'bienes', texto: '📦 Bienes', tipo: 'real', origen: 'empresas', destino: 'familias' },
|
||||
{ id: 'gasto', texto: '💳 Gasto', tipo: 'monetario', origen: 'familias', destino: 'empresas' },
|
||||
]
|
||||
},
|
||||
{
|
||||
nombre: 'Intermedio',
|
||||
descripcion: 'Incluye al Estado',
|
||||
agentes: ['familias', 'empresas', 'estado'],
|
||||
elementos: [
|
||||
{ id: 'trabajo', texto: '💪 Trabajo', tipo: 'real', origen: 'familias', destino: 'empresas' },
|
||||
{ id: 'tierra', texto: '🌾 Tierra', tipo: 'real', origen: 'familias', destino: 'empresas' },
|
||||
{ id: 'capital', texto: '💰 Capital', tipo: 'real', origen: 'familias', destino: 'empresas' },
|
||||
{ id: 'salarios', texto: '💵 Salarios', tipo: 'monetario', origen: 'empresas', destino: 'familias' },
|
||||
{ id: 'renta', texto: '🏠 Renta', tipo: 'monetario', origen: 'empresas', destino: 'familias' },
|
||||
{ id: 'intereses', texto: '📈 Intereses', tipo: 'monetario', origen: 'empresas', destino: 'familias' },
|
||||
{ id: 'bienes', texto: '📦 Bienes', tipo: 'real', origen: 'empresas', destino: 'familias' },
|
||||
{ id: 'servicios', texto: '🔧 Servicios', tipo: 'real', origen: 'empresas', destino: 'familias' },
|
||||
{ id: 'gasto', texto: '💳 Gasto', tipo: 'monetario', origen: 'familias', destino: 'empresas' },
|
||||
{ id: 'impuestos', texto: '📝 Impuestos', tipo: 'monetario', origen: 'familias', destino: 'estado' },
|
||||
{ id: 'transferencias', texto: '🎁 Transferencias', tipo: 'monetario', origen: 'estado', destino: 'familias' },
|
||||
{ id: 'gasto-publico', texto: '🏗️ Gasto Público', tipo: 'monetario', origen: 'estado', destino: 'empresas' },
|
||||
]
|
||||
},
|
||||
{
|
||||
nombre: 'Avanzado',
|
||||
descripcion: 'Todos los agentes incluyendo Sector Externo',
|
||||
agentes: ['familias', 'empresas', 'estado', 'exterior'],
|
||||
elementos: [
|
||||
{ id: 'trabajo', texto: '💪 Trabajo', tipo: 'real', origen: 'familias', destino: 'empresas' },
|
||||
{ id: 'tierra', texto: '🌾 Tierra', tipo: 'real', origen: 'familias', destino: 'empresas' },
|
||||
{ id: 'capital', texto: '💰 Capital', tipo: 'real', origen: 'familias', destino: 'empresas' },
|
||||
{ id: 'salarios', texto: '💵 Salarios', tipo: 'monetario', origen: 'empresas', destino: 'familias' },
|
||||
{ id: 'renta', texto: '🏠 Renta', tipo: 'monetario', origen: 'empresas', destino: 'familias' },
|
||||
{ id: 'intereses', texto: '📈 Intereses', tipo: 'monetario', origen: 'empresas', destino: 'familias' },
|
||||
{ id: 'bienes', texto: '📦 Bienes', tipo: 'real', origen: 'empresas', destino: 'familias' },
|
||||
{ id: 'servicios', texto: '🔧 Servicios', tipo: 'real', origen: 'empresas', destino: 'familias' },
|
||||
{ id: 'gasto', texto: '💳 Gasto', tipo: 'monetario', origen: 'familias', destino: 'empresas' },
|
||||
{ id: 'impuestos', texto: '📝 Impuestos', tipo: 'monetario', origen: 'familias', destino: 'estado' },
|
||||
{ id: 'transferencias', texto: '🎁 Transferencias', tipo: 'monetario', origen: 'estado', destino: 'familias' },
|
||||
{ id: 'gasto-publico', texto: '🏗️ Gasto Público', tipo: 'monetario', origen: 'estado', destino: 'empresas' },
|
||||
{ id: 'exportaciones', texto: '📤 Exportaciones', tipo: 'real', origen: 'empresas', destino: 'exterior' },
|
||||
{ id: 'importaciones', texto: '📥 Importaciones', tipo: 'real', origen: 'exterior', destino: 'empresas' },
|
||||
{ id: 'divisas-ent', texto: '💱 Divisas (Ent.)', tipo: 'monetario', origen: 'exterior', destino: 'empresas' },
|
||||
{ id: 'divisas-sal', texto: '💱 Divisas (Sal.)', tipo: 'monetario', origen: 'empresas', destino: 'exterior' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const AGENTE_CONFIG: Record<Agente, { icon: React.ReactNode; label: string; color: string; position: string }> = {
|
||||
familias: { icon: <Users size={24} />, label: 'Familias', color: 'bg-green-100 text-green-700 border-green-300', position: 'left-4 top-1/2 -translate-y-1/2' },
|
||||
empresas: { icon: <Building2 size={24} />, label: 'Empresas', color: 'bg-blue-100 text-blue-700 border-blue-300', position: 'right-4 top-1/2 -translate-y-1/2' },
|
||||
estado: { icon: <Landmark size={24} />, label: 'Estado', color: 'bg-orange-100 text-orange-700 border-orange-300', position: 'left-1/2 -translate-x-1/2 top-4' },
|
||||
exterior: { icon: <Globe size={24} />, label: 'Sector Externo', color: 'bg-purple-100 text-purple-700 border-purple-300', position: 'left-1/2 -translate-x-1/2 bottom-4' },
|
||||
};
|
||||
|
||||
export function FlujoCircular({ ejercicioId: _ejercicioId, onComplete }: FlujoCircularProps) {
|
||||
const [nivelActual, setNivelActual] = useState(0);
|
||||
const [elementosColocados, setElementosColocados] = useState<Record<string, { origen: Agente; destino: Agente } | null>>({});
|
||||
const [elementoSeleccionado, setElementoSeleccionado] = useState<string | null>(null);
|
||||
const [puntuacion, setPuntuacion] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
const [aciertos, setAciertos] = useState(0);
|
||||
const [errores, setErrores] = useState(0);
|
||||
|
||||
const nivel = NIVELES[nivelActual];
|
||||
|
||||
useEffect(() => {
|
||||
const inicial: Record<string, { origen: Agente; destino: Agente } | null> = {};
|
||||
nivel.elementos.forEach(el => {
|
||||
inicial[el.id] = null;
|
||||
});
|
||||
setElementosColocados(inicial);
|
||||
}, [nivel]);
|
||||
|
||||
const handleElementoClick = (elementoId: string) => {
|
||||
if (elementosColocados[elementoId]) return;
|
||||
setElementoSeleccionado(elementoId === elementoSeleccionado ? null : elementoId);
|
||||
};
|
||||
|
||||
const handleConexionClick = (origen: Agente, destino: Agente) => {
|
||||
if (!elementoSeleccionado) return;
|
||||
|
||||
const elemento = nivel.elementos.find(el => el.id === elementoSeleccionado);
|
||||
if (!elemento) return;
|
||||
|
||||
const esCorrecto = elemento.origen === origen && elemento.destino === destino;
|
||||
|
||||
setElementosColocados(prev => ({
|
||||
...prev,
|
||||
[elementoSeleccionado]: { origen, destino }
|
||||
}));
|
||||
|
||||
if (esCorrecto) {
|
||||
setAciertos(prev => prev + 1);
|
||||
setPuntuacion(prev => prev + 10);
|
||||
} else {
|
||||
setErrores(prev => prev + 1);
|
||||
setPuntuacion(prev => Math.max(0, prev - 2));
|
||||
}
|
||||
|
||||
setElementoSeleccionado(null);
|
||||
};
|
||||
|
||||
const verificarCompletitud = () => {
|
||||
const todosColocados = nivel.elementos.every(el => elementosColocados[el.id] !== null);
|
||||
if (todosColocados) {
|
||||
const bonus = 50;
|
||||
setPuntuacion(prev => prev + bonus);
|
||||
|
||||
if (nivelActual < NIVELES.length - 1) {
|
||||
setNivelActual(prev => prev + 1);
|
||||
} else {
|
||||
setCompletado(true);
|
||||
if (onComplete) {
|
||||
onComplete(puntuacion + bonus);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const todosColocados = nivel.elementos.every(el => elementosColocados[el.id] !== null);
|
||||
if (todosColocados && !completado) {
|
||||
setTimeout(verificarCompletitud, 500);
|
||||
}
|
||||
}, [elementosColocados]);
|
||||
|
||||
const handleReiniciarNivel = () => {
|
||||
const inicial: Record<string, { origen: Agente; destino: Agente } | null> = {};
|
||||
nivel.elementos.forEach(el => {
|
||||
inicial[el.id] = null;
|
||||
});
|
||||
setElementosColocados(inicial);
|
||||
setElementoSeleccionado(null);
|
||||
setAciertos(0);
|
||||
setErrores(0);
|
||||
};
|
||||
|
||||
const getConexionesPosibles = (): { origen: Agente; destino: Agente; label: string }[] => {
|
||||
const conexiones: { origen: Agente; destino: Agente; label: string }[] = [];
|
||||
|
||||
if (nivel.agentes.includes('familias') && nivel.agentes.includes('empresas')) {
|
||||
conexiones.push({ origen: 'familias', destino: 'empresas', label: 'Familias → Empresas' });
|
||||
conexiones.push({ origen: 'empresas', destino: 'familias', label: 'Empresas → Familias' });
|
||||
}
|
||||
|
||||
if (nivel.agentes.includes('estado')) {
|
||||
if (nivel.agentes.includes('familias')) {
|
||||
conexiones.push({ origen: 'familias', destino: 'estado', label: 'Familias → Estado' });
|
||||
conexiones.push({ origen: 'estado', destino: 'familias', label: 'Estado → Familias' });
|
||||
}
|
||||
if (nivel.agentes.includes('empresas')) {
|
||||
conexiones.push({ origen: 'estado', destino: 'empresas', label: 'Estado → Empresas' });
|
||||
}
|
||||
}
|
||||
|
||||
if (nivel.agentes.includes('exterior') && nivel.agentes.includes('empresas')) {
|
||||
conexiones.push({ origen: 'empresas', destino: 'exterior', label: 'Empresas → Exterior' });
|
||||
conexiones.push({ origen: 'exterior', destino: 'empresas', label: 'Exterior → Empresas' });
|
||||
}
|
||||
|
||||
return conexiones;
|
||||
};
|
||||
|
||||
if (completado) {
|
||||
return (
|
||||
<Card className="w-full max-w-3xl mx-auto">
|
||||
<div className="text-center py-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200 }}
|
||||
className="inline-flex items-center justify-center w-20 h-20 bg-yellow-100 rounded-full mb-4"
|
||||
>
|
||||
<Trophy size={40} className="text-yellow-600" />
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">¡Juego Completado!</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Has completado todos los niveles del Flujo Circular
|
||||
</p>
|
||||
|
||||
<div className="bg-blue-50 rounded-lg p-6 mb-6">
|
||||
<p className="text-sm text-blue-600 mb-1">Puntuación Final</p>
|
||||
<p className="text-4xl font-bold text-blue-700">{puntuacion} puntos</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 max-w-xs mx-auto mb-6">
|
||||
<div className="bg-green-50 rounded-lg p-3">
|
||||
<p className="text-2xl font-bold text-green-600">{aciertos}</p>
|
||||
<p className="text-sm text-green-700">Aciertos</p>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-3">
|
||||
<p className="text-2xl font-bold text-red-600">{errores}</p>
|
||||
<p className="text-sm text-red-700">Errores</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => {
|
||||
setNivelActual(0);
|
||||
setPuntuacion(0);
|
||||
setCompletado(false);
|
||||
setAciertos(0);
|
||||
setErrores(0);
|
||||
handleReiniciarNivel();
|
||||
}} variant="outline">
|
||||
<RefreshCw size={16} className="mr-2" />
|
||||
Jugar de Nuevo
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-4xl mx-auto">
|
||||
<CardHeader
|
||||
title={`Nivel ${nivelActual + 1}: ${nivel.nombre}`}
|
||||
subtitle={nivel.descripcion}
|
||||
action={
|
||||
<Button variant="ghost" size="sm" onClick={handleReiniciarNivel}>
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
{NIVELES.map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
|
||||
idx < nivelActual
|
||||
? 'bg-green-500 text-white'
|
||||
: idx === nivelActual
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{idx < nivelActual ? <CheckCircle size={16} /> : idx + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-600">Puntuación</p>
|
||||
<p className="text-xl font-bold text-blue-600">{puntuacion} pts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="relative bg-gray-50 rounded-xl p-8 min-h-[400px]">
|
||||
{nivel.agentes.map((agente) => (
|
||||
<motion.div
|
||||
key={agente}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className={`absolute ${AGENTE_CONFIG[agente].position} w-24 h-24 rounded-xl border-2 ${AGENTE_CONFIG[agente].color} flex flex-col items-center justify-center gap-1 cursor-pointer hover:shadow-lg transition-shadow`}
|
||||
>
|
||||
{AGENTE_CONFIG[agente].icon}
|
||||
<span className="text-xs font-bold text-center">{AGENTE_CONFIG[agente].label}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none" style={{ zIndex: 0 }}>
|
||||
{getConexionesPosibles().map((conexion, idx) => (
|
||||
<g key={idx}>
|
||||
<line
|
||||
x1={conexion.origen === 'familias' ? '15%' : conexion.origen === 'empresas' ? '85%' : conexion.origen === 'estado' ? '50%' : '50%'}
|
||||
y1={conexion.origen === 'familias' ? '50%' : conexion.origen === 'empresas' ? '50%' : conexion.origen === 'estado' ? '15%' : '85%'}
|
||||
x2={conexion.destino === 'familias' ? '15%' : conexion.destino === 'empresas' ? '85%' : conexion.destino === 'estado' ? '50%' : '50%'}
|
||||
y2={conexion.destino === 'familias' ? '50%' : conexion.destino === 'empresas' ? '50%' : conexion.destino === 'estado' ? '15%' : '85%'}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5,5"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{getConexionesPosibles().map((conexion, idx) => {
|
||||
const elementosEnConexion = nivel.elementos.filter(el =>
|
||||
elementosColocados[el.id]?.origen === conexion.origen &&
|
||||
elementosColocados[el.id]?.destino === conexion.destino
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleConexionClick(conexion.origen, conexion.destino)}
|
||||
disabled={!elementoSeleccionado}
|
||||
className={`p-2 rounded-lg border-2 text-xs font-medium transition-all min-w-[120px] ${
|
||||
elementoSeleccionado
|
||||
? 'border-blue-300 bg-blue-50 hover:bg-blue-100 cursor-pointer'
|
||||
: 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="text-gray-500 mb-1">{conexion.label}</div>
|
||||
<div className="flex flex-wrap gap-1 justify-center">
|
||||
{elementosEnConexion.map((el, i) => (
|
||||
<span key={i} className="text-lg" title={el.texto}>
|
||||
{el.texto.split(' ')[0]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-3">Elementos ({nivel.elementos.length})</h4>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
{elementoSeleccionado
|
||||
? 'Selecciona una conexión en el diagrama'
|
||||
: 'Haz clic en un elemento para colocarlo'}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{nivel.elementos.map((elemento) => {
|
||||
const colocado = elementosColocados[elemento.id];
|
||||
const seleccionado = elementoSeleccionado === elemento.id;
|
||||
const esCorrecto = colocado && colocado.origen === elemento.origen && colocado.destino === elemento.destino;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={elemento.id}
|
||||
onClick={() => handleElementoClick(elemento.id)}
|
||||
disabled={!!colocado}
|
||||
whileHover={!colocado ? { scale: 1.02 } : {}}
|
||||
whileTap={!colocado ? { scale: 0.98 } : {}}
|
||||
className={`w-full p-3 rounded-lg border-2 text-left transition-all ${
|
||||
colocado
|
||||
? esCorrecto
|
||||
? 'border-green-300 bg-green-50'
|
||||
: 'border-red-300 bg-red-50'
|
||||
: seleccionado
|
||||
? 'border-blue-500 bg-blue-50 shadow-md'
|
||||
: 'border-gray-200 bg-white hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{elemento.texto.split(' ')[0]}</span>
|
||||
<span className="text-sm font-medium">{elemento.texto.split(' ').slice(1).join(' ')}</span>
|
||||
</div>
|
||||
{colocado && (
|
||||
esCorrecto
|
||||
? <CheckCircle size={16} className="text-green-600" />
|
||||
: <XCircle size={16} className="text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
elemento.tipo === 'real' ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{elemento.tipo === 'real' ? 'Real' : 'Monetario'}
|
||||
</span>
|
||||
{colocado && !esCorrecto && (
|
||||
<span className="text-xs text-red-600">
|
||||
{AGENTE_CONFIG[elemento.origen].label} → {AGENTE_CONFIG[elemento.destino].label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<p className="text-xs text-gray-600 mb-2">Leyenda:</p>
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-blue-100 border border-blue-300 rounded"></span>
|
||||
Flujo Real
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 bg-green-100 border border-green-300 rounded"></span>
|
||||
Flujo Monetario
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlujoCircular;
|
||||
310
frontend/src/components/exercises/modulo1/QuizBienes.tsx
Normal file
310
frontend/src/components/exercises/modulo1/QuizBienes.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Card, CardHeader } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { CheckCircle, XCircle, ArrowRight, Trophy, BookOpen } from 'lucide-react';
|
||||
|
||||
interface QuizBienesProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface Pregunta {
|
||||
id: string;
|
||||
bien: string;
|
||||
descripcion: string;
|
||||
opciones: string[];
|
||||
respuestaCorrecta: string;
|
||||
explicacionDetallada: string;
|
||||
}
|
||||
|
||||
const PREGUNTAS: Pregunta[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
bien: 'Carne de primera calidad',
|
||||
descripcion: 'Carne de res premium vendida en supermercados de alta gama',
|
||||
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
|
||||
respuestaCorrecta: 'Bien de lujo',
|
||||
explicacionDetallada: 'La carne premium es considerada un bien de lujo porque cuando el ingreso aumenta significativamente, las familias aumentan su consumo de este tipo de carne sustituyendo carnes de menor calidad.'
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
bien: 'Pan',
|
||||
descripcion: 'Pan básico de consumo diario',
|
||||
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
|
||||
respuestaCorrecta: 'Bien normal',
|
||||
explicacionDetallada: 'El pan es un bien normal porque su consumo aumenta moderadamente con el ingreso, aunque llega un punto donde se estabiliza (saturación).'
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
bien: 'Transporte público (autobús)',
|
||||
descripcion: 'Servicio de autobuses urbanos',
|
||||
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
|
||||
respuestaCorrecta: 'Bien inferior',
|
||||
explicacionDetallada: 'El transporte público es un bien inferior porque cuando los ingresos aumentan, las personas tienden a comprar automóviles o usar taxis/Uber, reduciendo el uso del autobús.'
|
||||
},
|
||||
{
|
||||
id: 'p4',
|
||||
bien: 'Fideos instantáneos',
|
||||
descripcion: 'Comida rápida económica',
|
||||
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
|
||||
respuestaCorrecta: 'Bien inferior',
|
||||
explicacionDetallada: 'Los fideos instantáneos son claramente un bien inferior. A medida que aumentan los ingresos, las personas prefieren alimentos más nutritivos y de mejor calidad.'
|
||||
},
|
||||
{
|
||||
id: 'p5',
|
||||
bien: 'Vacaciones en el extranjero',
|
||||
descripcion: 'Viajes turísticos internacionales',
|
||||
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
|
||||
respuestaCorrecta: 'Bien de lujo',
|
||||
explicacionDetallada: 'Las vacaciones internacionales son un bien de lujo porque su consumo aumenta significativamente cuando el ingreso crece, incluso más que proporcionalmente.'
|
||||
},
|
||||
{
|
||||
id: 'p6',
|
||||
bien: 'Ropa de marca',
|
||||
descripcion: 'Vestimenta de diseñador',
|
||||
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
|
||||
respuestaCorrecta: 'Bien de lujo',
|
||||
explicacionDetallada: 'La ropa de marca es un bien de lujo porque su demanda crece más rápido que el ingreso, especialmente en rangos de ingreso altos.'
|
||||
},
|
||||
{
|
||||
id: 'p7',
|
||||
bien: 'Cine',
|
||||
descripcion: 'Entradas a salas de cine',
|
||||
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
|
||||
respuestaCorrecta: 'Bien normal',
|
||||
explicacionDetallada: 'El cine es un bien normal. Aunque con el auge del streaming podría debatirse, generalmente el consumo de entretenimiento aumenta con el ingreso de forma moderada.'
|
||||
},
|
||||
{
|
||||
id: 'p8',
|
||||
bien: 'Productos de marca blanca',
|
||||
descripcion: 'Productos genéricos de supermercado',
|
||||
opciones: ['Bien normal', 'Bien inferior', 'Bien de lujo'],
|
||||
respuestaCorrecta: 'Bien inferior',
|
||||
explicacionDetallada: 'Los productos de marca blanca son bienes inferiores porque son sustituidos por marcas reconocidas cuando el consumidor tiene mayores ingresos.'
|
||||
}
|
||||
];
|
||||
|
||||
export function QuizBienes({ ejercicioId: _ejercicioId, onComplete }: QuizBienesProps) {
|
||||
const [preguntaActual, setPreguntaActual] = useState(0);
|
||||
const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<string | null>(null);
|
||||
const [mostrarRetroalimentacion, setMostrarRetroalimentacion] = useState(false);
|
||||
const [puntuacion, setPuntuacion] = useState(0);
|
||||
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
const [progreso, setProgreso] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setProgreso(((preguntaActual + (completado ? 1 : 0)) / PREGUNTAS.length) * 100);
|
||||
}, [preguntaActual, completado]);
|
||||
|
||||
const handleSeleccionarRespuesta = (opcion: string) => {
|
||||
if (mostrarRetroalimentacion) return;
|
||||
setRespuestaSeleccionada(opcion);
|
||||
};
|
||||
|
||||
const handleValidar = () => {
|
||||
if (!respuestaSeleccionada) return;
|
||||
|
||||
const esCorrecta = respuestaSeleccionada === PREGUNTAS[preguntaActual].respuestaCorrecta;
|
||||
setMostrarRetroalimentacion(true);
|
||||
|
||||
if (esCorrecta) {
|
||||
setRespuestasCorrectas(prev => prev + 1);
|
||||
setPuntuacion(prev => prev + 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
if (preguntaActual < PREGUNTAS.length - 1) {
|
||||
setPreguntaActual(prev => prev + 1);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarRetroalimentacion(false);
|
||||
} else {
|
||||
setCompletado(true);
|
||||
const puntuacionFinal = puntuacion + (respuestaSeleccionada === PREGUNTAS[preguntaActual].respuestaCorrecta ? 100 : 0);
|
||||
if (onComplete) {
|
||||
onComplete(puntuacionFinal);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReiniciar = () => {
|
||||
setPreguntaActual(0);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarRetroalimentacion(false);
|
||||
setPuntuacion(0);
|
||||
setRespuestasCorrectas(0);
|
||||
setCompletado(false);
|
||||
};
|
||||
|
||||
const pregunta = PREGUNTAS[preguntaActual];
|
||||
const esCorrecta = respuestaSeleccionada === pregunta.respuestaCorrecta;
|
||||
|
||||
if (completado) {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<div className="text-center py-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200 }}
|
||||
className="inline-flex items-center justify-center w-20 h-20 bg-yellow-100 rounded-full mb-4"
|
||||
>
|
||||
<Trophy size={40} className="text-yellow-600" />
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">¡Quiz Completado!</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Respondiste correctamente {respuestasCorrectas} de {PREGUNTAS.length} preguntas
|
||||
</p>
|
||||
|
||||
<div className="bg-blue-50 rounded-lg p-6 mb-6">
|
||||
<p className="text-sm text-blue-600 mb-1">Puntuación Total</p>
|
||||
<p className="text-4xl font-bold text-blue-700">{puntuacion} puntos</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleReiniciar} variant="outline">
|
||||
Intentar de Nuevo
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<CardHeader
|
||||
title="Quiz: Clasificación de Bienes"
|
||||
subtitle={`Pregunta ${preguntaActual + 1} de ${PREGUNTAS.length}`}
|
||||
/>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
||||
<span>Progreso</span>
|
||||
<span>{Math.round(progreso)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<motion.div
|
||||
className="bg-blue-600 h-2.5 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progreso}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 text-blue-600 mb-3">
|
||||
<BookOpen size={20} />
|
||||
<span className="font-medium">Clasifica el siguiente bien:</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">{pregunta.bien}</h3>
|
||||
<p className="text-gray-600">{pregunta.descripcion}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
{pregunta.opciones.map((opcion, index) => {
|
||||
const isSelected = respuestaSeleccionada === opcion;
|
||||
const isCorrect = opcion === pregunta.respuestaCorrecta;
|
||||
const showCorrect = mostrarRetroalimentacion && isCorrect;
|
||||
const showIncorrect = mostrarRetroalimentacion && isSelected && !isCorrect;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={index}
|
||||
onClick={() => handleSeleccionarRespuesta(opcion)}
|
||||
disabled={mostrarRetroalimentacion}
|
||||
whileHover={!mostrarRetroalimentacion ? { scale: 1.02 } : {}}
|
||||
whileTap={!mostrarRetroalimentacion ? { scale: 0.98 } : {}}
|
||||
className={`w-full p-4 rounded-lg border-2 text-left transition-all ${
|
||||
showCorrect
|
||||
? 'border-green-500 bg-green-50'
|
||||
: showIncorrect
|
||||
? 'border-red-500 bg-red-50'
|
||||
: isSelected
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 text-gray-700 font-semibold text-sm">
|
||||
{String.fromCharCode(65 + index)}
|
||||
</span>
|
||||
<span className="font-medium">{opcion}</span>
|
||||
</div>
|
||||
{showCorrect && <CheckCircle size={20} className="text-green-600" />}
|
||||
{showIncorrect && <XCircle size={20} className="text-red-600" />}
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mostrarRetroalimentacion && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden mb-6"
|
||||
>
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
esCorrecta
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{esCorrecta ? (
|
||||
<CheckCircle size={20} className="text-green-600" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-red-600" />
|
||||
)}
|
||||
<span className={`font-semibold ${
|
||||
esCorrecta ? 'text-green-800' : 'text-red-800'
|
||||
}`}>
|
||||
{esCorrecta ? '¡Correcto!' : 'Incorrecto'}
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-sm ${
|
||||
esCorrecta ? 'text-green-700' : 'text-red-700'
|
||||
}`}>
|
||||
{pregunta.explicacionDetallada}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm text-gray-600">
|
||||
Puntuación: <span className="font-bold text-blue-600">{puntuacion}</span> pts
|
||||
</div>
|
||||
|
||||
{!mostrarRetroalimentacion ? (
|
||||
<Button
|
||||
onClick={handleValidar}
|
||||
disabled={!respuestaSeleccionada}
|
||||
>
|
||||
Validar Respuesta
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSiguiente}>
|
||||
{preguntaActual < PREGUNTAS.length - 1 ? (
|
||||
<>
|
||||
Siguiente
|
||||
<ArrowRight size={16} className="ml-2" />
|
||||
</>
|
||||
) : (
|
||||
'Finalizar'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuizBienes;
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface SimuladorDisyuntivasProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
export function SimuladorDisyuntivas({ ejercicioId: _ejercicioId, onComplete }: SimuladorDisyuntivasProps) {
|
||||
const [bienX, setBienX] = useState(50);
|
||||
const [bienY, setBienY] = useState(50);
|
||||
const [validacion, setValidacion] = useState<'eficiente' | 'ineficiente' | 'inalcanzable' | null>(null);
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const MAX_X = 100;
|
||||
const MAX_Y = 80;
|
||||
|
||||
const calcularFPP = useCallback((x: number): number => {
|
||||
const ratio = x / MAX_X;
|
||||
const y = MAX_Y * Math.pow(1 - ratio, 0.7);
|
||||
return Math.max(0, Math.min(MAX_Y, y));
|
||||
}, []);
|
||||
|
||||
const validarPosicion = useCallback(() => {
|
||||
const yFPP = calcularFPP(bienX);
|
||||
const diferencia = Math.abs(bienY - yFPP);
|
||||
const tolerancia = 3;
|
||||
|
||||
if (bienY > yFPP + tolerancia) {
|
||||
return 'inalcanzable';
|
||||
} else if (diferencia <= tolerancia) {
|
||||
return 'eficiente';
|
||||
} else {
|
||||
return 'ineficiente';
|
||||
}
|
||||
}, [bienX, bienY, calcularFPP]);
|
||||
|
||||
const handleValidar = () => {
|
||||
const resultado = validarPosicion();
|
||||
setValidacion(resultado);
|
||||
|
||||
if (resultado === 'eficiente' && !completado) {
|
||||
setCompletado(true);
|
||||
if (onComplete) {
|
||||
onComplete(100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setBienX(50);
|
||||
setBienY(50);
|
||||
setValidacion(null);
|
||||
setCompletado(false);
|
||||
};
|
||||
|
||||
// Generar puntos para la curva FPP
|
||||
const puntosFPP: string[] = [];
|
||||
for (let x = 0; x <= MAX_X; x += 2) {
|
||||
const y = calcularFPP(x);
|
||||
const svgX = 40 + (x / MAX_X) * 260;
|
||||
const svgY = 200 - (y / MAX_Y) * 180;
|
||||
puntosFPP.push(`${svgX},${svgY}`);
|
||||
}
|
||||
const pathData = puntosFPP.length > 0
|
||||
? `M ${puntosFPP.join(' L ')}`
|
||||
: '';
|
||||
|
||||
const colorValidacion = validacion === 'eficiente'
|
||||
? 'text-green-600 bg-green-50 border-green-200'
|
||||
: validacion === 'ineficiente'
|
||||
? 'text-yellow-600 bg-yellow-50 border-yellow-200'
|
||||
: validacion === 'inalcanzable'
|
||||
? 'text-red-600 bg-red-50 border-red-200'
|
||||
: 'text-gray-600 bg-gray-50 border-gray-200';
|
||||
|
||||
const mensajeValidacion = validacion === 'eficiente'
|
||||
? '¡Excelente! Estás sobre la FPP (Asignación eficiente)'
|
||||
: validacion === 'ineficiente'
|
||||
? 'Punto ineficiente: Estás dentro de la FPP, hay recursos sin usar'
|
||||
: validacion === 'inalcanzable'
|
||||
? 'Punto inalcanzable: No tienes suficientes recursos'
|
||||
: 'Ajusta los sliders para explorar la FPP';
|
||||
|
||||
const puntoColor = validacion === 'eficiente'
|
||||
? '#10b981'
|
||||
: validacion === 'ineficiente'
|
||||
? '#f59e0b'
|
||||
: validacion === 'inalcanzable'
|
||||
? '#ef4444'
|
||||
: '#6b7280';
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto bg-white rounded-lg shadow-lg p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Simulador de Disyuntivas Económicas</h3>
|
||||
<p className="text-sm text-gray-500">Explora la Frontera de Posibilidades de Producción (FPP)</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-6">
|
||||
{/* Slider X */}
|
||||
<div>
|
||||
<label className="flex justify-between text-sm font-medium text-gray-700 mb-2">
|
||||
<span>Alimentos (X)</span>
|
||||
<span className="text-blue-600 font-bold">{bienX} millones de toneladas</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={MAX_X}
|
||||
value={bienX}
|
||||
onChange={(e) => setBienX(Number(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg cursor-pointer"
|
||||
style={{ accentColor: '#2563eb' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slider Y */}
|
||||
<div>
|
||||
<label className="flex justify-between text-sm font-medium text-gray-700 mb-2">
|
||||
<span>Tecnología (Y)</span>
|
||||
<span className="text-green-600 font-bold">{bienY} millones de unidades</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={MAX_Y}
|
||||
value={bienY}
|
||||
onChange={(e) => setBienY(Number(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg cursor-pointer"
|
||||
style={{ accentColor: '#16a34a' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mensaje de validación */}
|
||||
<div className={`p-4 rounded-lg border-2 ${colorValidacion}`}>
|
||||
<span className="font-semibold capitalize">{validacion || 'Selecciona'}:</span>
|
||||
<p className="text-sm mt-1">{mensajeValidacion}</p>
|
||||
</div>
|
||||
|
||||
{/* Botones */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleValidar}
|
||||
disabled={completado}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors"
|
||||
>
|
||||
Validar Posición
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Reiniciar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mensaje de éxito */}
|
||||
{completado && (
|
||||
<div className="bg-green-100 border border-green-300 rounded-lg p-4 text-center">
|
||||
<p className="text-green-800 font-semibold">¡Ejercicio Completado!</p>
|
||||
<p className="text-green-700 text-2xl font-bold mt-1">100 puntos</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gráfico SVG */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 border-2 border-gray-200">
|
||||
<svg viewBox="0 0 340 240" className="w-full" style={{ minHeight: '300px' }}>
|
||||
{/* Grid */}
|
||||
<defs>
|
||||
<pattern id="grid" width="30" height="27" patternUnits="userSpaceOnUse">
|
||||
<path d="M 30 0 L 0 0 0 27" fill="none" stroke="#e5e7eb" strokeWidth="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="340" height="240" fill="url(#grid)" />
|
||||
|
||||
{/* Ejes */}
|
||||
<line x1="40" y1="210" x2="320" y2="210" stroke="#374151" strokeWidth="2" />
|
||||
<line x1="40" y1="210" x2="40" y2="30" stroke="#374151" strokeWidth="2" />
|
||||
|
||||
{/* Flechas */}
|
||||
<polygon points="320,210 315,207 315,213" fill="#374151" />
|
||||
<polygon points="40,30 37,35 43,35" fill="#374151" />
|
||||
|
||||
{/* Etiquetas */}
|
||||
<text x="180" y="235" textAnchor="middle" fill="#6b7280" fontSize="12">
|
||||
Alimentos (millones de toneladas)
|
||||
</text>
|
||||
<text x="15" y="120" textAnchor="middle" fill="#6b7280" fontSize="12" transform="rotate(-90, 15, 120)">
|
||||
Tecnología (millones de unidades)
|
||||
</text>
|
||||
|
||||
{/* Marcas X */}
|
||||
{[0, 25, 50, 75, 100].map((val, i) => (
|
||||
<g key={`x-${val}`}>
|
||||
<line x1={40 + i * 70} y1="210" x2={40 + i * 70} y2="215" stroke="#374151" />
|
||||
<text x={40 + i * 70} y="228" textAnchor="middle" fill="#6b7280" fontSize="11">{val}</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Marcas Y */}
|
||||
{[0, 20, 40, 60, 80].map((val, i) => (
|
||||
<g key={`y-${val}`}>
|
||||
<line x1="35" y1={210 - i * 45} x2="40" y2={210 - i * 45} stroke="#374151" />
|
||||
<text x="30" y={210 - i * 45 + 4} textAnchor="end" fill="#6b7280" fontSize="11">{val}</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Curva FPP */}
|
||||
{pathData && (
|
||||
<path
|
||||
d={pathData}
|
||||
fill="none"
|
||||
stroke="#2563eb"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Punto actual */}
|
||||
<circle
|
||||
cx={40 + (bienX / MAX_X) * 280}
|
||||
cy={210 - (bienY / MAX_Y) * 180}
|
||||
r="8"
|
||||
fill={puntoColor}
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
|
||||
{/* Coordenadas */}
|
||||
<text
|
||||
x={40 + (bienX / MAX_X) * 280}
|
||||
y={210 - (bienY / MAX_Y) * 180 - 15}
|
||||
textAnchor="middle"
|
||||
fill="#1f2937"
|
||||
fontSize="12"
|
||||
fontWeight="bold"
|
||||
>
|
||||
({bienX}, {bienY})
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SimuladorDisyuntivas;
|
||||
3
frontend/src/components/exercises/modulo1/index.ts
Normal file
3
frontend/src/components/exercises/modulo1/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { SimuladorDisyuntivas } from './SimuladorDisyuntivas';
|
||||
export { QuizBienes } from './QuizBienes';
|
||||
export { FlujoCircular } from './FlujoCircular';
|
||||
505
frontend/src/components/exercises/modulo2/ConstructorCurvas.tsx
Normal file
505
frontend/src/components/exercises/modulo2/ConstructorCurvas.tsx
Normal file
@@ -0,0 +1,505 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { LineChart, Check, X, RotateCcw, Trophy, ArrowRight, HelpCircle } from 'lucide-react';
|
||||
|
||||
interface Punto {
|
||||
x: number;
|
||||
y: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface ConstructorCurvasProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
type Nivel = 'demanda' | 'oferta' | 'equilibrio';
|
||||
type TipoCurva = 'demanda' | 'oferta';
|
||||
|
||||
interface NivelConfig {
|
||||
tipo: Nivel;
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
tipoCurvaEsperada: TipoCurva | 'ambas';
|
||||
mensajeExito: string;
|
||||
}
|
||||
|
||||
const niveles: NivelConfig[] = [
|
||||
{
|
||||
tipo: 'demanda',
|
||||
titulo: 'Nivel 1: Curva de Demanda',
|
||||
descripcion: 'La demanda tiene pendiente negativa (cuando el precio sube, la cantidad demandada baja). Coloca al menos 2 puntos y traza la línea.',
|
||||
tipoCurvaEsperada: 'demanda',
|
||||
mensajeExito: '¡Correcto! La curva de demanda tiene pendiente negativa.'
|
||||
},
|
||||
{
|
||||
tipo: 'oferta',
|
||||
titulo: 'Nivel 2: Curva de Oferta',
|
||||
descripcion: 'La oferta tiene pendiente positiva (cuando el precio sube, los productores quieren vender más). Coloca al menos 2 puntos.',
|
||||
tipoCurvaEsperada: 'oferta',
|
||||
mensajeExito: '¡Correcto! La curva de oferta tiene pendiente positiva.'
|
||||
},
|
||||
{
|
||||
tipo: 'equilibrio',
|
||||
titulo: 'Nivel 3: Equilibrio de Mercado',
|
||||
descripcion: 'Dibuja ambas curvas para encontrar el punto de equilibrio donde se cruzan.',
|
||||
tipoCurvaEsperada: 'ambas',
|
||||
mensajeExito: '¡Excelente! Has encontrado el equilibrio de mercado.'
|
||||
}
|
||||
];
|
||||
|
||||
const GRID_SIZE = 300;
|
||||
const PADDING = 40;
|
||||
const MAX_PRECIO = 100;
|
||||
const MAX_CANTIDAD = 100;
|
||||
|
||||
export const ConstructorCurvas: React.FC<ConstructorCurvasProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
|
||||
const [nivelActual, setNivelActual] = useState(0);
|
||||
const [puntosDemanda, setPuntosDemanda] = useState<Punto[]>([]);
|
||||
const [puntosOferta, setPuntosOferta] = useState<Punto[]>([]);
|
||||
const [modoActivo, setModoActivo] = useState<TipoCurva>('demanda');
|
||||
const [mensaje, setMensaje] = useState<string>('');
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [_startTime] = useState(Date.now());
|
||||
const [, setPuntosDibujados] = useState<{ demanda: boolean; oferta: boolean }>({ demanda: false, oferta: false });
|
||||
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [draggedPoint, setDraggedPoint] = useState<string | null>(null);
|
||||
|
||||
const nivel = niveles[nivelActual];
|
||||
|
||||
const cartesianToSvg = useCallback((x: number, y: number) => {
|
||||
const svgX = PADDING + (x / MAX_CANTIDAD) * GRID_SIZE;
|
||||
const svgY = PADDING + GRID_SIZE - (y / MAX_PRECIO) * GRID_SIZE;
|
||||
return { x: svgX, y: svgY };
|
||||
}, []);
|
||||
|
||||
const svgToCartesian = useCallback((svgX: number, svgY: number) => {
|
||||
const x = ((svgX - PADDING) / GRID_SIZE) * MAX_CANTIDAD;
|
||||
const y = ((PADDING + GRID_SIZE - svgY) / GRID_SIZE) * MAX_PRECIO;
|
||||
return {
|
||||
x: Math.max(0, Math.min(MAX_CANTIDAD, Math.round(x))),
|
||||
y: Math.max(0, Math.min(MAX_PRECIO, Math.round(y)))
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSvgClick = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (draggedPoint || nivelActual === 2) return;
|
||||
|
||||
const rect = svgRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const svgX = e.clientX - rect.left;
|
||||
const svgY = e.clientY - rect.top;
|
||||
const cartesian = svgToCartesian(svgX, svgY);
|
||||
|
||||
if (nivelActual === 0 && puntosDemanda.length >= 4) {
|
||||
setMensaje('Máximo 4 puntos para la demanda');
|
||||
return;
|
||||
}
|
||||
if (nivelActual === 1 && puntosOferta.length >= 4) {
|
||||
setMensaje('Máximo 4 puntos para la oferta');
|
||||
return;
|
||||
}
|
||||
|
||||
const newPoint: Punto = {
|
||||
x: cartesian.x,
|
||||
y: cartesian.y,
|
||||
id: `point-${Date.now()}-${Math.random()}`
|
||||
};
|
||||
|
||||
if (modoActivo === 'demanda') {
|
||||
setPuntosDemanda(prev => [...prev, newPoint]);
|
||||
} else {
|
||||
setPuntosOferta(prev => [...prev, newPoint]);
|
||||
}
|
||||
setMensaje('');
|
||||
};
|
||||
|
||||
const handlePointDrag = (pointId: string, _tipo: TipoCurva) => {
|
||||
setDraggedPoint(pointId);
|
||||
};
|
||||
|
||||
const handlePointMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!draggedPoint) return;
|
||||
|
||||
const rect = svgRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const svgX = e.clientX - rect.left;
|
||||
const svgY = e.clientY - rect.top;
|
||||
const cartesian = svgToCartesian(svgX, svgY);
|
||||
|
||||
const updatePoint = (puntos: Punto[]) =>
|
||||
puntos.map(p => p.id === draggedPoint ? { ...p, x: cartesian.x, y: cartesian.y } : p);
|
||||
|
||||
if (puntosDemanda.some(p => p.id === draggedPoint)) {
|
||||
setPuntosDemanda(updatePoint);
|
||||
} else if (puntosOferta.some(p => p.id === draggedPoint)) {
|
||||
setPuntosOferta(updatePoint);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointUp = () => {
|
||||
setDraggedPoint(null);
|
||||
};
|
||||
|
||||
const calcularPendiente = (puntos: Punto[]): number | null => {
|
||||
if (puntos.length < 2) return null;
|
||||
const sorted = [...puntos].sort((a, b) => a.x - b.x);
|
||||
const first = sorted[0];
|
||||
const last = sorted[sorted.length - 1];
|
||||
if (last.x === first.x) return 0;
|
||||
return (last.y - first.y) / (last.x - first.x);
|
||||
};
|
||||
|
||||
const validarCurva = () => {
|
||||
const puntos = modoActivo === 'demanda' ? puntosDemanda : puntosOferta;
|
||||
|
||||
if (puntos.length < 2) {
|
||||
setMensaje('Necesitas al menos 2 puntos para trazar una curva');
|
||||
return;
|
||||
}
|
||||
|
||||
const pendiente = calcularPendiente(puntos);
|
||||
if (pendiente === null) return;
|
||||
|
||||
if (modoActivo === 'demanda') {
|
||||
if (pendiente >= 0) {
|
||||
setMensaje('La demanda debe tener pendiente negativa (bajar de izquierda a derecha)');
|
||||
return;
|
||||
}
|
||||
setPuntosDibujados(prev => ({ ...prev, demanda: true }));
|
||||
} else {
|
||||
if (pendiente <= 0) {
|
||||
setMensaje('La oferta debe tener pendiente positiva (subir de izquierda a derecha)');
|
||||
return;
|
||||
}
|
||||
setPuntosDibujados(prev => ({ ...prev, oferta: true }));
|
||||
}
|
||||
|
||||
setMensaje('');
|
||||
setShowSuccess(true);
|
||||
setScore(prev => prev + 33);
|
||||
|
||||
setTimeout(() => {
|
||||
if (nivelActual < 2) {
|
||||
setNivelActual(prev => prev + 1);
|
||||
setShowSuccess(false);
|
||||
setMensaje('');
|
||||
if (nivelActual === 0) setModoActivo('oferta');
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const validarEquilibrio = () => {
|
||||
if (puntosDemanda.length < 2 || puntosOferta.length < 2) {
|
||||
setMensaje('Necesitas trazar ambas curvas con al menos 2 puntos cada una');
|
||||
return;
|
||||
}
|
||||
|
||||
setShowSuccess(true);
|
||||
setScore(100);
|
||||
|
||||
setTimeout(() => {
|
||||
if (onComplete) {
|
||||
onComplete(100);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const reiniciar = () => {
|
||||
setPuntosDemanda([]);
|
||||
setPuntosOferta([]);
|
||||
setNivelActual(0);
|
||||
setModoActivo('demanda');
|
||||
setMensaje('');
|
||||
setShowSuccess(false);
|
||||
setScore(0);
|
||||
setPuntosDibujados({ demanda: false, oferta: false });
|
||||
};
|
||||
|
||||
const eliminarPunto = (id: string, tipo: TipoCurva) => {
|
||||
if (tipo === 'demanda') {
|
||||
setPuntosDemanda(prev => prev.filter(p => p.id !== id));
|
||||
} else {
|
||||
setPuntosOferta(prev => prev.filter(p => p.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const renderLineaCurva = (puntos: Punto[], color: string) => {
|
||||
if (puntos.length < 2) return null;
|
||||
const sorted = [...puntos].sort((a, b) => a.x - b.x);
|
||||
const points = sorted.map(p => {
|
||||
const svg = cartesianToSvg(p.x, p.y);
|
||||
return `${svg.x},${svg.y}`;
|
||||
}).join(' ');
|
||||
|
||||
return (
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto p-6 bg-white rounded-xl shadow-lg">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<LineChart className="w-8 h-8 text-blue-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">{nivel.titulo}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">Nivel {nivelActual + 1} de 3</span>
|
||||
<div className="w-32 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-blue-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={reiniciar}
|
||||
className="p-2 text-gray-500 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600">{nivel.descripcion}</p>
|
||||
</div>
|
||||
|
||||
{nivelActual === 2 && (
|
||||
<div className="mb-4 p-3 bg-blue-50 rounded-lg flex items-center gap-2">
|
||||
<HelpCircle className="w-5 h-5 text-blue-600" />
|
||||
<span className="text-sm text-blue-700">
|
||||
Nivel Avanzado: Dibuja ambas curvas. La demanda (azul) con pendiente negativa,
|
||||
y la oferta (verde) con pendiente positiva.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex-1">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={GRID_SIZE + 2 * PADDING}
|
||||
height={GRID_SIZE + 2 * PADDING}
|
||||
className="border-2 border-gray-300 rounded-lg bg-white cursor-crosshair"
|
||||
onClick={handleSvgClick}
|
||||
onMouseMove={handlePointMove}
|
||||
onMouseUp={handlePointUp}
|
||||
onMouseLeave={handlePointUp}
|
||||
>
|
||||
{/* Grid */}
|
||||
{Array.from({ length: 11 }).map((_, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={PADDING + (i * GRID_SIZE) / 10}
|
||||
y1={PADDING}
|
||||
x2={PADDING + (i * GRID_SIZE) / 10}
|
||||
y2={PADDING + GRID_SIZE}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1={PADDING}
|
||||
y1={PADDING + (i * GRID_SIZE) / 10}
|
||||
x2={PADDING + GRID_SIZE}
|
||||
y2={PADDING + (i * GRID_SIZE) / 10}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ejes */}
|
||||
<line
|
||||
x1={PADDING}
|
||||
y1={PADDING + GRID_SIZE}
|
||||
x2={PADDING + GRID_SIZE}
|
||||
y2={PADDING + GRID_SIZE}
|
||||
stroke="#374151"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line
|
||||
x1={PADDING}
|
||||
y1={PADDING}
|
||||
x2={PADDING}
|
||||
y2={PADDING + GRID_SIZE}
|
||||
stroke="#374151"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Labels ejes */}
|
||||
<text x={PADDING + GRID_SIZE / 2} y={PADDING + GRID_SIZE + 25} textAnchor="middle" className="text-sm fill-gray-600">
|
||||
Cantidad
|
||||
</text>
|
||||
<text x={15} y={PADDING + GRID_SIZE / 2} textAnchor="middle" transform={`rotate(-90, 15, ${PADDING + GRID_SIZE / 2})`} className="text-sm fill-gray-600">
|
||||
Precio
|
||||
</text>
|
||||
|
||||
{/* Curvas */}
|
||||
{(nivelActual === 0 || nivelActual === 2) && renderLineaCurva(puntosDemanda, '#3b82f6')}
|
||||
{(nivelActual === 1 || nivelActual === 2) && renderLineaCurva(puntosOferta, '#22c55e')}
|
||||
|
||||
{/* Puntos Demanda */}
|
||||
{(nivelActual === 0 || nivelActual === 2) && puntosDemanda.map(punto => {
|
||||
const svg = cartesianToSvg(punto.x, punto.y);
|
||||
return (
|
||||
<motion.g key={punto.id}>
|
||||
<circle
|
||||
cx={svg.x}
|
||||
cy={svg.y}
|
||||
r="8"
|
||||
fill="#3b82f6"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
className="cursor-move hover:r-10"
|
||||
onMouseDown={() => handlePointDrag(punto.id, 'demanda')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
eliminarPunto(punto.id, 'demanda');
|
||||
}}
|
||||
/>
|
||||
<text x={svg.x} y={svg.y - 12} textAnchor="middle" className="text-xs fill-gray-500">
|
||||
({punto.x}, {punto.y})
|
||||
</text>
|
||||
</motion.g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Puntos Oferta */}
|
||||
{(nivelActual === 1 || nivelActual === 2) && puntosOferta.map(punto => {
|
||||
const svg = cartesianToSvg(punto.x, punto.y);
|
||||
return (
|
||||
<motion.g key={punto.id}>
|
||||
<circle
|
||||
cx={svg.x}
|
||||
cy={svg.y}
|
||||
r="8"
|
||||
fill="#22c55e"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
className="cursor-move"
|
||||
onMouseDown={() => handlePointDrag(punto.id, 'oferta')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
eliminarPunto(punto.id, 'oferta');
|
||||
}}
|
||||
/>
|
||||
<text x={svg.x} y={svg.y - 12} textAnchor="middle" className="text-xs fill-gray-500">
|
||||
({punto.x}, {punto.y})
|
||||
</text>
|
||||
</motion.g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-64 space-y-4">
|
||||
{nivelActual === 2 && (
|
||||
<div className="flex gap-2 p-3 bg-gray-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => setModoActivo('demanda')}
|
||||
className={`flex-1 py-2 px-3 rounded text-sm font-medium transition-colors ${
|
||||
modoActivo === 'demanda'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Demanda
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModoActivo('oferta')}
|
||||
className={`flex-1 py-2 px-3 rounded text-sm font-medium transition-colors ${
|
||||
modoActivo === 'oferta'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Oferta
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-700 mb-2">Puntos colocados:</h3>
|
||||
{modoActivo === 'demanda' || nivelActual === 2 ? (
|
||||
<div className="mb-2">
|
||||
<span className="text-sm text-blue-600 font-medium">Demanda: </span>
|
||||
<span className="text-sm text-gray-600">{puntosDemanda.length} puntos</span>
|
||||
</div>
|
||||
) : null}
|
||||
{(modoActivo === 'oferta' || nivelActual === 2) && (
|
||||
<div>
|
||||
<span className="text-sm text-green-600 font-medium">Oferta: </span>
|
||||
<span className="text-sm text-gray-600">{puntosOferta.length} puntos</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mensaje && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2"
|
||||
>
|
||||
<X className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-700">{mensaje}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{nivelActual < 2 ? (
|
||||
<button
|
||||
onClick={validarCurva}
|
||||
disabled={showSuccess}
|
||||
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
Validar Curva
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={validarEquilibrio}
|
||||
disabled={showSuccess}
|
||||
className="w-full py-3 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
Validar Equilibrio
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{showSuccess && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="p-4 bg-green-50 border border-green-200 rounded-lg text-center"
|
||||
>
|
||||
<Trophy className="w-8 h-8 text-green-500 mx-auto mb-2" />
|
||||
<p className="font-semibold text-green-700">{nivel.mensajeExito}</p>
|
||||
{nivelActual === 2 && (
|
||||
<div className="mt-3 flex items-center justify-center gap-2 text-green-600">
|
||||
<span>Completado</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConstructorCurvas;
|
||||
467
frontend/src/components/exercises/modulo2/IdentificarShocks.tsx
Normal file
467
frontend/src/components/exercises/modulo2/IdentificarShocks.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Brain, ArrowRight, ArrowLeft, TrendingUp, TrendingDown, CheckCircle2, XCircle, Trophy, RotateCcw, BookOpen } from 'lucide-react';
|
||||
|
||||
interface IdentificarShocksProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
type DireccionShock = 'oferta-up' | 'oferta-down' | 'demanda-up' | 'demanda-down';
|
||||
type CurvaTipo = 'oferta' | 'demanda';
|
||||
type Direccion = 'arriba' | 'abajo';
|
||||
|
||||
interface Escenario {
|
||||
id: number;
|
||||
descripcion: string;
|
||||
respuesta: DireccionShock;
|
||||
curva: CurvaTipo;
|
||||
direccion: Direccion;
|
||||
explicacion: string;
|
||||
dificultad: 'facil' | 'medio' | 'dificil';
|
||||
}
|
||||
|
||||
const escenarios: Escenario[] = [
|
||||
{
|
||||
id: 1,
|
||||
descripcion: 'Una nueva tecnología permite producir smartphones más rápido y barato.',
|
||||
respuesta: 'oferta-up',
|
||||
curva: 'oferta',
|
||||
direccion: 'arriba',
|
||||
explicacion: 'La tecnología mejora la productividad, reduciendo costos. Esto aumenta la oferta (la curva se desplaza a la derecha).',
|
||||
dificultad: 'facil'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
descripcion: 'Se anuncia que el café causa cáncer y la demanda disminuye drásticamente.',
|
||||
respuesta: 'demanda-down',
|
||||
curva: 'demanda',
|
||||
direccion: 'abajo',
|
||||
explicacion: 'Las preferencias de los consumidores cambian negativamente. La demanda disminuye (la curva se desplaza a la izquierda).',
|
||||
dificultad: 'facil'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
descripcion: 'Una sequía severa destruye el 40% de la cosecha de trigo.',
|
||||
respuesta: 'oferta-down',
|
||||
curva: 'oferta',
|
||||
direccion: 'abajo',
|
||||
explicacion: 'La sequía reduce la cantidad disponible de trigo. La oferta disminuye (la curva se desplaza a la izquierda).',
|
||||
dificultad: 'facil'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
descripcion: 'El ingreso promedio de la población aumenta un 15% (bien normal).',
|
||||
respuesta: 'demanda-up',
|
||||
curva: 'demanda',
|
||||
direccion: 'arriba',
|
||||
explicacion: 'Para bienes normales, al aumentar el ingreso, aumenta la demanda (la curva se desplaza a la derecha).',
|
||||
dificultad: 'medio'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
descripcion: 'El precio del petróleo (insumo) sube un 30%.',
|
||||
respuesta: 'oferta-down',
|
||||
curva: 'oferta',
|
||||
direccion: 'abajo',
|
||||
explicacion: 'Al subir el costo de los insumos, producir es más caro. La oferta disminuye (la curva se desplaza a la izquierda).',
|
||||
dificultad: 'medio'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
descripcion: 'El gobierno subsidia la compra de autos eléctricos con $5,000.',
|
||||
respuesta: 'demanda-up',
|
||||
curva: 'demanda',
|
||||
direccion: 'arriba',
|
||||
explicacion: 'El subsidio reduce el precio efectivo para consumidores. La demanda aumenta (la curva se desplaza a la derecha).',
|
||||
dificultad: 'dificil'
|
||||
}
|
||||
];
|
||||
|
||||
interface Opcion {
|
||||
value: DireccionShock;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const opciones: Opcion[] = [
|
||||
{ value: 'oferta-up', label: 'Oferta ↑', icon: <TrendingUp className="w-5 h-5" />, color: 'green' },
|
||||
{ value: 'oferta-down', label: 'Oferta ↓', icon: <TrendingDown className="w-5 h-5" />, color: 'red' },
|
||||
{ value: 'demanda-up', label: 'Demanda ↑', icon: <TrendingUp className="w-5 h-5" />, color: 'blue' },
|
||||
{ value: 'demanda-down', label: 'Demanda ↓', icon: <TrendingDown className="w-5 h-5" />, color: 'orange' },
|
||||
];
|
||||
|
||||
export const IdentificarShocks: React.FC<IdentificarShocksProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
|
||||
const [escenarioActual, setEscenarioActual] = useState(0);
|
||||
const [respuestaSeleccionada, setRespuestaSeleccionada] = useState<DireccionShock | null>(null);
|
||||
const [mostrarResultado, setMostrarResultado] = useState(false);
|
||||
const [score, setScore] = useState(0);
|
||||
const [respuestasCorrectas, setRespuestasCorrectas] = useState(0);
|
||||
const [_startTime] = useState(Date.now());
|
||||
const [completado, setCompletado] = useState(false);
|
||||
|
||||
const escenario = escenarios[escenarioActual];
|
||||
|
||||
const handleSeleccionar = (respuesta: DireccionShock) => {
|
||||
if (mostrarResultado) return;
|
||||
setRespuestaSeleccionada(respuesta);
|
||||
};
|
||||
|
||||
const handleVerificar = () => {
|
||||
if (!respuestaSeleccionada) return;
|
||||
|
||||
const esCorrecta = respuestaSeleccionada === escenario.respuesta;
|
||||
setMostrarResultado(true);
|
||||
|
||||
if (esCorrecta) {
|
||||
setScore(prev => prev + Math.round(100 / escenarios.length));
|
||||
setRespuestasCorrectas(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSiguiente = () => {
|
||||
if (escenarioActual < escenarios.length - 1) {
|
||||
setEscenarioActual(prev => prev + 1);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarResultado(false);
|
||||
} else {
|
||||
setCompletado(true);
|
||||
if (onComplete) {
|
||||
onComplete(score);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReiniciar = () => {
|
||||
setEscenarioActual(0);
|
||||
setRespuestaSeleccionada(null);
|
||||
setMostrarResultado(false);
|
||||
setScore(0);
|
||||
setRespuestasCorrectas(0);
|
||||
setCompletado(false);
|
||||
};
|
||||
|
||||
const getDificultadColor = (dificultad: string) => {
|
||||
switch (dificultad) {
|
||||
case 'facil': return 'bg-green-100 text-green-700';
|
||||
case 'medio': return 'bg-yellow-100 text-yellow-700';
|
||||
case 'dificil': return 'bg-red-100 text-red-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const renderGraficoShock = () => {
|
||||
const { curva, direccion } = escenario;
|
||||
const isOferta = curva === 'oferta';
|
||||
const isUp = direccion === 'arriba';
|
||||
|
||||
return (
|
||||
<svg width="300" height="250" className="mx-auto">
|
||||
{/* Grid */}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<g key={i}>
|
||||
<line x1={50 + i * 40} y1="30" x2={50 + i * 40} y2="210" stroke="#e5e7eb" strokeWidth="1" />
|
||||
<line x1="50" y1={30 + i * 36} x2="250" y2={30 + i * 36} stroke="#e5e7eb" strokeWidth="1" />
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ejes */}
|
||||
<line x1="50" y1="210" x2="250" y2="210" stroke="#374151" strokeWidth="2" />
|
||||
<line x1="50" y1="30" x2="50" y2="210" stroke="#374151" strokeWidth="2" />
|
||||
|
||||
{/* Curva original */}
|
||||
{isOferta ? (
|
||||
<line x1="80" y1="180" x2="220" y2="80" stroke="#22c55e" strokeWidth="3" />
|
||||
) : (
|
||||
<line x1="80" y1="80" x2="220" y2="180" stroke="#3b82f6" strokeWidth="3" />
|
||||
)}
|
||||
<text x={isOferta ? 230 : 230} y={isOferta ? 75 : 190} className={`text-sm ${isOferta ? 'fill-green-600' : 'fill-blue-600'}`}>
|
||||
{isOferta ? 'S₁' : 'D₁'}
|
||||
</text>
|
||||
|
||||
{/* Curva desplazada */}
|
||||
<motion.g
|
||||
initial={{ opacity: 0, x: isUp ? 30 : -30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{isOferta ? (
|
||||
<line x1={isUp ? 110 : 50} y1="180" x2={isUp ? 250 : 190} y2="80" stroke="#22c55e" strokeWidth="3" strokeDasharray="5,5" />
|
||||
) : (
|
||||
<line x1={isUp ? 110 : 50} y1="80" x2={isUp ? 250 : 190} y2="180" stroke="#3b82f6" strokeWidth="3" strokeDasharray="5,5" />
|
||||
)}
|
||||
<text x={isUp ? 260 : 200} y={isOferta ? 75 : 190} className={`text-sm ${isOferta ? 'fill-green-600' : 'fill-blue-600'}`}>
|
||||
{isOferta ? 'S₂' : 'D₂'}
|
||||
</text>
|
||||
</motion.g>
|
||||
|
||||
{/* Flecha de dirección */}
|
||||
<motion.path
|
||||
d={isUp ? 'M 280 130 L 300 130' : 'M 300 130 L 280 130'}
|
||||
stroke={isOferta ? '#22c55e' : '#3b82f6'}
|
||||
strokeWidth="3"
|
||||
markerEnd={`url(#arrowhead-${isOferta ? 'green' : 'blue'})`}
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
/>
|
||||
|
||||
{/* Defs para flechas */}
|
||||
<defs>
|
||||
<marker id="arrowhead-green" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#22c55e" />
|
||||
</marker>
|
||||
<marker id="arrowhead-blue" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#3b82f6" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
if (completado) {
|
||||
const porcentaje = Math.round((respuestasCorrectas / escenarios.length) * 100);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="w-full max-w-2xl mx-auto p-8 bg-white rounded-xl shadow-lg text-center"
|
||||
>
|
||||
<Trophy className="w-16 h-16 text-yellow-500 mx-auto mb-4" />
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-2">¡Ejercicio Completado!</h2>
|
||||
<p className="text-gray-600 mb-6">Has identificado shocks del mercado</p>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-6 mb-6">
|
||||
<div className="text-5xl font-bold text-purple-600 mb-2">{porcentaje}%</div>
|
||||
<p className="text-gray-600">
|
||||
{respuestasCorrectas} de {escenarios.length} respuestas correctas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleReiniciar}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
Intentar de nuevo
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto p-6 bg-white rounded-xl shadow-lg">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Brain className="w-8 h-8 text-purple-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-800">Identificar Shocks del Mercado</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getDificultadColor(escenario.dificultad)}`}>
|
||||
{escenario.dificultad.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{escenarioActual + 1} de {escenarios.length}
|
||||
</span>
|
||||
<div className="w-32 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-purple-600"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${((escenarioActual + 1) / escenarios.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
Lee cada escenario e identifica qué curva se desplaza y en qué dirección.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="bg-gradient-to-br from-purple-50 to-blue-50 rounded-lg p-6 mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<BookOpen className="w-6 h-6 text-purple-600 flex-shrink-0 mt-1" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-2">Escenario {escenario.id}</h3>
|
||||
<p className="text-gray-700 text-lg">{escenario.descripcion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{opciones.map((opcion) => {
|
||||
const isSelected = respuestaSeleccionada === opcion.value;
|
||||
const isCorrect = mostrarResultado && opcion.value === escenario.respuesta;
|
||||
const isWrong = mostrarResultado && isSelected && opcion.value !== escenario.respuesta;
|
||||
|
||||
let buttonClass = 'p-4 rounded-lg border-2 transition-all flex flex-col items-center gap-2 ';
|
||||
|
||||
if (isCorrect) {
|
||||
buttonClass += 'border-green-500 bg-green-50';
|
||||
} else if (isWrong) {
|
||||
buttonClass += 'border-red-500 bg-red-50';
|
||||
} else if (isSelected) {
|
||||
buttonClass += `border-${opcion.color}-500 bg-${opcion.color}-50`;
|
||||
} else {
|
||||
buttonClass += 'border-gray-200 hover:border-gray-300 hover:bg-gray-50';
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={opcion.value}
|
||||
onClick={() => handleSeleccionar(opcion.value)}
|
||||
disabled={mostrarResultado}
|
||||
whileHover={!mostrarResultado ? { scale: 1.02 } : {}}
|
||||
whileTap={!mostrarResultado ? { scale: 0.98 } : {}}
|
||||
className={buttonClass}
|
||||
>
|
||||
{opcion.icon}
|
||||
<span className={`font-semibold ${
|
||||
isCorrect ? 'text-green-700' :
|
||||
isWrong ? 'text-red-700' :
|
||||
isSelected ? `text-${opcion.color}-700` : 'text-gray-700'
|
||||
}`}>
|
||||
{opcion.label}
|
||||
</span>
|
||||
{isCorrect && <CheckCircle2 className="w-5 h-5 text-green-600" />}
|
||||
{isWrong && <XCircle className="w-5 h-5 text-red-600" />}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mostrarResultado && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className={`mt-4 p-4 rounded-lg ${
|
||||
respuestaSeleccionada === escenario.respuesta
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{respuestaSeleccionada === escenario.respuesta ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-semibold ${
|
||||
respuestaSeleccionada === escenario.respuesta ? 'text-green-800' : 'text-red-800'
|
||||
}`}>
|
||||
{respuestaSeleccionada === escenario.respuesta ? '¡Correcto!' : 'Incorrecto'}
|
||||
</p>
|
||||
<p className="text-sm mt-1 text-gray-700">{escenario.explicacion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="mt-4 flex gap-3">
|
||||
{!mostrarResultado ? (
|
||||
<button
|
||||
onClick={handleVerificar}
|
||||
disabled={!respuestaSeleccionada}
|
||||
className="flex-1 py-3 px-4 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Verificar respuesta
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSiguiente}
|
||||
className="flex-1 py-3 px-4 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{escenarioActual < escenarios.length - 1 ? (
|
||||
<>
|
||||
Siguiente
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</>
|
||||
) : (
|
||||
'Finalizar'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-700 mb-4 text-center">Visualización del Shock</h3>
|
||||
{renderGraficoShock()}
|
||||
|
||||
<div className="mt-4 p-3 bg-white rounded-lg">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Leyenda:</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-0.5 bg-blue-600"></div>
|
||||
<span className="text-gray-600">Curva de Demanda (D)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-0.5 bg-green-600"></div>
|
||||
<span className="text-gray-600">Curva de Oferta (S)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-0.5 border-t-2 border-dashed border-gray-400"></div>
|
||||
<span className="text-gray-600">Curva después del shock</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Tip:</strong> Recuerda que:
|
||||
</p>
|
||||
<ul className="text-sm text-yellow-700 mt-1 space-y-1">
|
||||
<li>• Factores de oferta: tecnología, insumos, número de vendedores</li>
|
||||
<li>• Factores de demanda: ingreso, preferencias, precios relacionados</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-between items-center">
|
||||
<button
|
||||
onClick={() => setEscenarioActual(Math.max(0, escenarioActual - 1))}
|
||||
disabled={escenarioActual === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Anterior
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{escenarios.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
index === escenarioActual
|
||||
? 'bg-purple-600'
|
||||
: index < escenarioActual
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setEscenarioActual(Math.min(escenarios.length - 1, escenarioActual + 1))}
|
||||
disabled={escenarioActual === escenarios.length - 1}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Siguiente
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IdentificarShocks;
|
||||
454
frontend/src/components/exercises/modulo2/SimuladorPrecios.tsx
Normal file
454
frontend/src/components/exercises/modulo2/SimuladorPrecios.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { TrendingUp, TrendingDown, AlertTriangle, Calculator, RotateCcw, Info } from 'lucide-react';
|
||||
|
||||
interface SimuladorPreciosProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
interface CurvaParams {
|
||||
pendienteDemanda: number;
|
||||
interceptoDemanda: number;
|
||||
pendienteOferta: number;
|
||||
interceptoOferta: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS: CurvaParams = {
|
||||
pendienteDemanda: -1.5,
|
||||
interceptoDemanda: 90,
|
||||
pendienteOferta: 1.2,
|
||||
interceptoOferta: 10
|
||||
};
|
||||
|
||||
const calcularEquilibrio = (params: CurvaParams) => {
|
||||
const { pendienteDemanda, interceptoDemanda, pendienteOferta, interceptoOferta } = params;
|
||||
// Pd = Po => a + b*Q = c + d*Q
|
||||
const Q = (interceptoOferta - interceptoDemanda) / (pendienteDemanda - pendienteOferta);
|
||||
const P = interceptoDemanda + pendienteDemanda * Q;
|
||||
return { Q: Math.max(0, Q), P: Math.max(0, P) };
|
||||
};
|
||||
|
||||
const calcularCantidadEnPrecio = (precio: number, params: CurvaParams) => {
|
||||
// P = a + b*Q => Q = (P - a) / b
|
||||
const Qd = (precio - params.interceptoDemanda) / params.pendienteDemanda;
|
||||
const Qo = (precio - params.interceptoOferta) / params.pendienteOferta;
|
||||
return { Qd: Math.max(0, Qd), Qo: Math.max(0, Qo) };
|
||||
};
|
||||
|
||||
export const SimuladorPrecios: React.FC<SimuladorPreciosProps> = ({ onComplete, ejercicioId: _ejercicioId }) => {
|
||||
const [params, _setParams] = useState<CurvaParams>(DEFAULT_PARAMS);
|
||||
const [precioMaximo, setPrecioMaximo] = useState<number | null>(null);
|
||||
const [precioMinimo, setPrecioMinimo] = useState<number | null>(null);
|
||||
const [showInfo, setShowInfo] = useState(true);
|
||||
const [startTime] = useState(Date.now());
|
||||
const [hasInteracted, setHasInteracted] = useState(false);
|
||||
|
||||
const equilibrio = useMemo(() => calcularEquilibrio(params), [params]);
|
||||
|
||||
const analisis = useMemo(() => {
|
||||
if (precioMaximo !== null && precioMaximo < equilibrio.P) {
|
||||
const { Qd, Qo } = calcularCantidadEnPrecio(precioMaximo, params);
|
||||
const excesoDemanda = Qd - Qo;
|
||||
const cantidadTransada = Qo;
|
||||
|
||||
// Pérdida de peso muerto: área del triángulo
|
||||
const base = equilibrio.Q - cantidadTransada;
|
||||
const altura = precioMaximo - (params.interceptoOferta + params.pendienteOferta * cantidadTransada);
|
||||
const deadweightLoss = 0.5 * base * altura;
|
||||
|
||||
return {
|
||||
tipo: 'precio-maximo' as const,
|
||||
excesoDemanda: Math.max(0, excesoDemanda),
|
||||
excesoOferta: 0,
|
||||
cantidadTransada,
|
||||
deadweightLoss: Math.max(0, deadweightLoss),
|
||||
mensaje: 'Precio máximo crea escasez (exceso de demanda)'
|
||||
};
|
||||
}
|
||||
|
||||
if (precioMinimo !== null && precioMinimo > equilibrio.P) {
|
||||
const { Qd, Qo } = calcularCantidadEnPrecio(precioMinimo, params);
|
||||
const excesoOferta = Qo - Qd;
|
||||
const cantidadTransada = Qd;
|
||||
|
||||
const base = equilibrio.Q - cantidadTransada;
|
||||
const altura = (params.interceptoDemanda + params.pendienteDemanda * cantidadTransada) - precioMinimo;
|
||||
const deadweightLoss = 0.5 * base * altura;
|
||||
|
||||
return {
|
||||
tipo: 'precio-minimo' as const,
|
||||
excesoDemanda: 0,
|
||||
excesoOferta: Math.max(0, excesoOferta),
|
||||
cantidadTransada,
|
||||
deadweightLoss: Math.max(0, deadweightLoss),
|
||||
mensaje: 'Precio mínimo crea superávit (exceso de oferta)'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tipo: 'equilibrio' as const,
|
||||
excesoDemanda: 0,
|
||||
excesoOferta: 0,
|
||||
cantidadTransada: equilibrio.Q,
|
||||
deadweightLoss: 0,
|
||||
mensaje: 'Mercado en equilibrio'
|
||||
};
|
||||
}, [precioMaximo, precioMinimo, equilibrio, params]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInteracted && (precioMaximo !== null || precioMinimo !== null)) {
|
||||
const timer = setTimeout(() => {
|
||||
if (onComplete) {
|
||||
onComplete(100);
|
||||
}
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [hasInteracted, precioMaximo, precioMinimo, startTime, onComplete]);
|
||||
|
||||
const reset = () => {
|
||||
setPrecioMaximo(null);
|
||||
setPrecioMinimo(null);
|
||||
setHasInteracted(false);
|
||||
};
|
||||
|
||||
// Generar puntos para las curvas
|
||||
const generateCurvePoints = () => {
|
||||
const points = [];
|
||||
for (let Q = 0; Q <= 60; Q += 2) {
|
||||
const Pd = params.interceptoDemanda + params.pendienteDemanda * Q;
|
||||
const Po = params.interceptoOferta + params.pendienteOferta * Q;
|
||||
points.push({ Q, Pd: Math.max(0, Pd), Po: Math.max(0, Po) });
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
const curvePoints = generateCurvePoints();
|
||||
|
||||
// Escalar para SVG
|
||||
const scaleX = (Q: number) => 50 + (Q / 60) * 400;
|
||||
const scaleY = (P: number) => 350 - (P / 100) * 300;
|
||||
|
||||
const demandaPath = curvePoints.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${scaleX(p.Q)} ${scaleY(p.Pd)}`
|
||||
).join(' ');
|
||||
|
||||
const ofertaPath = curvePoints.map((p, i) =>
|
||||
`${i === 0 ? 'M' : 'L'} ${scaleX(p.Q)} ${scaleY(p.Po)}`
|
||||
).join(' ');
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto p-6 bg-white rounded-xl shadow-lg">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800 flex items-center gap-3">
|
||||
<Calculator className="w-8 h-8 text-purple-600" />
|
||||
Simulador de Precios Intervenidos
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Experimenta con precios máximos y mínimos para ver cómo afectan el equilibrio de mercado.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showInfo && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg flex items-start gap-3"
|
||||
>
|
||||
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-blue-800 mb-1">Cómo usar:</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>• Ajusta los sliders para establecer un precio máximo o mínimo</li>
|
||||
<li>• Observa cómo cambian las cantidades demandadas y ofrecidas</li>
|
||||
<li>• Identifica escasez (exceso de demanda) o superávit (exceso de oferta)</li>
|
||||
<li>• La pérdida de peso muerto representa la ineficiencia creada</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowInfo(false)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-semibold text-gray-700">Gráfico de Mercado</h3>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="p-2 text-gray-500 hover:text-purple-600 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<svg width="500" height="400" className="w-full">
|
||||
{/* Grid */}
|
||||
{Array.from({ length: 11 }).map((_, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={50 + (i * 400) / 10}
|
||||
y1={50}
|
||||
x2={50 + (i * 400) / 10}
|
||||
y2={350}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1={50}
|
||||
y1={50 + (i * 300) / 10}
|
||||
x2={450}
|
||||
y2={50 + (i * 300) / 10}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Ejes */}
|
||||
<line x1="50" y1="350" x2="450" y2="350" stroke="#374151" strokeWidth="2" />
|
||||
<line x1="50" y1="50" x2="50" y2="350" stroke="#374151" strokeWidth="2" />
|
||||
|
||||
{/* Labels */}
|
||||
<text x="250" y="385" textAnchor="middle" className="text-sm fill-gray-600">Cantidad</text>
|
||||
<text x="15" y="200" textAnchor="middle" transform="rotate(-90, 15, 200)" className="text-sm fill-gray-600">Precio</text>
|
||||
|
||||
{/* Curva de Demanda */}
|
||||
<path d={demandaPath} fill="none" stroke="#3b82f6" strokeWidth="3" />
|
||||
<text x="420" y={scaleY(15)} className="text-sm fill-blue-600 font-medium">D</text>
|
||||
|
||||
{/* Curva de Oferta */}
|
||||
<path d={ofertaPath} fill="none" stroke="#22c55e" strokeWidth="3" />
|
||||
<text x="420" y={scaleY(80)} className="text-sm fill-green-600 font-medium">S</text>
|
||||
|
||||
{/* Punto de equilibrio */}
|
||||
<circle
|
||||
cx={scaleX(equilibrio.Q)}
|
||||
cy={scaleY(equilibrio.P)}
|
||||
r="6"
|
||||
fill="#8b5cf6"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text x={scaleX(equilibrio.Q) + 10} y={scaleY(equilibrio.P)} className="text-xs fill-purple-600">
|
||||
E
|
||||
</text>
|
||||
|
||||
{/* Línea de precio máximo */}
|
||||
{precioMaximo !== null && (
|
||||
<g>
|
||||
<line
|
||||
x1="50"
|
||||
y1={scaleY(precioMaximo)}
|
||||
x2="450"
|
||||
y2={scaleY(precioMaximo)}
|
||||
stroke="#ef4444"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5,5"
|
||||
/>
|
||||
<text x="460" y={scaleY(precioMaximo)} className="text-xs fill-red-500">Pmáx</text>
|
||||
|
||||
{/* Zona de escasez */}
|
||||
{analisis.excesoDemanda > 0 && (
|
||||
<polygon
|
||||
points={`
|
||||
${scaleX(analisis.cantidadTransada)} ${scaleY(precioMaximo)}
|
||||
${scaleX(analisis.cantidadTransada + analisis.excesoDemanda)} ${scaleY(precioMaximo)}
|
||||
`}
|
||||
fill="#fef3c7"
|
||||
opacity="0.5"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Línea de precio mínimo */}
|
||||
{precioMinimo !== null && (
|
||||
<g>
|
||||
<line
|
||||
x1="50"
|
||||
y1={scaleY(precioMinimo)}
|
||||
x2="450"
|
||||
y2={scaleY(precioMinimo)}
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5,5"
|
||||
/>
|
||||
<text x="460" y={scaleY(precioMinimo)} className="text-xs fill-amber-500">Pmín</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Indicador de pérdida de peso muerto */}
|
||||
{analisis.deadweightLoss > 0 && (
|
||||
<motion.text
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
x={scaleX(equilibrio.Q / 2)}
|
||||
y={scaleY((equilibrio.P + (precioMaximo || precioMinimo || 0)) / 2)}
|
||||
textAnchor="middle"
|
||||
className="text-xs fill-red-600 font-medium"
|
||||
>
|
||||
Pérdida de peso muerto
|
||||
</motion.text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-gray-700 mb-4">Controles de Precio</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Precio Máximo (Pmáx)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={Math.round(equilibrio.P)}
|
||||
value={precioMaximo ?? Math.round(equilibrio.P)}
|
||||
onChange={(e) => {
|
||||
setPrecioMaximo(Number(e.target.value) || null);
|
||||
setPrecioMinimo(null);
|
||||
setHasInteracted(true);
|
||||
}}
|
||||
className="w-full accent-red-500"
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-gray-500 mt-1">
|
||||
<span>$0</span>
|
||||
<span className="font-medium text-red-600">
|
||||
{precioMaximo !== null ? `$${precioMaximo}` : 'Desactivado'}
|
||||
</span>
|
||||
<span>${Math.round(equilibrio.P)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Precio Mínimo (Pmín)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={Math.round(equilibrio.P)}
|
||||
max="100"
|
||||
value={precioMinimo ?? Math.round(equilibrio.P)}
|
||||
onChange={(e) => {
|
||||
setPrecioMinimo(Number(e.target.value) || null);
|
||||
setPrecioMaximo(null);
|
||||
setHasInteracted(true);
|
||||
}}
|
||||
className="w-full accent-amber-500"
|
||||
/>
|
||||
<div className="flex justify-between text-sm text-gray-500 mt-1">
|
||||
<span>${Math.round(equilibrio.P)}</span>
|
||||
<span className="font-medium text-amber-600">
|
||||
{precioMinimo !== null ? `$${precioMinimo}` : 'Desactivado'}
|
||||
</span>
|
||||
<span>$100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={analisis.tipo}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className={`p-4 rounded-lg border ${
|
||||
analisis.tipo === 'equilibrio'
|
||||
? 'bg-green-50 border-green-200'
|
||||
: analisis.tipo === 'precio-maximo'
|
||||
? 'bg-red-50 border-red-200'
|
||||
: 'bg-amber-50 border-amber-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{analisis.tipo === 'equilibrio' ? (
|
||||
<TrendingUp className="w-5 h-5 text-green-600" />
|
||||
) : analisis.tipo === 'precio-maximo' ? (
|
||||
<TrendingDown className="w-5 h-5 text-red-600" />
|
||||
) : (
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
||||
)}
|
||||
<h4 className={`font-semibold ${
|
||||
analisis.tipo === 'equilibrio'
|
||||
? 'text-green-800'
|
||||
: analisis.tipo === 'precio-maximo'
|
||||
? 'text-red-800'
|
||||
: 'text-amber-800'
|
||||
}`}>
|
||||
{analisis.mensaje}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="bg-white rounded p-2">
|
||||
<span className="text-gray-500">Precio de equilibrio:</span>
|
||||
<p className="font-semibold text-gray-800">${equilibrio.P.toFixed(1)}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded p-2">
|
||||
<span className="text-gray-500">Cantidad de equilibrio:</span>
|
||||
<p className="font-semibold text-gray-800">{equilibrio.Q.toFixed(1)} unidades</p>
|
||||
</div>
|
||||
{precioMaximo !== null && (
|
||||
<div className="bg-white rounded p-2">
|
||||
<span className="text-red-500">Precio máximo:</span>
|
||||
<p className="font-semibold text-gray-800">${precioMaximo}</p>
|
||||
</div>
|
||||
)}
|
||||
{precioMinimo !== null && (
|
||||
<div className="bg-white rounded p-2">
|
||||
<span className="text-amber-500">Precio mínimo:</span>
|
||||
<p className="font-semibold text-gray-800">${precioMinimo}</p>
|
||||
</div>
|
||||
)}
|
||||
{analisis.excesoDemanda > 0 && (
|
||||
<div className="bg-red-100 rounded p-2 col-span-2">
|
||||
<span className="text-red-700">Exceso de demanda (escasez):</span>
|
||||
<p className="font-semibold text-red-800">{analisis.excesoDemanda.toFixed(1)} unidades</p>
|
||||
</div>
|
||||
)}
|
||||
{analisis.excesoOferta > 0 && (
|
||||
<div className="bg-amber-100 rounded p-2 col-span-2">
|
||||
<span className="text-amber-700">Exceso de oferta (superávit):</span>
|
||||
<p className="font-semibold text-amber-800">{analisis.excesoOferta.toFixed(1)} unidades</p>
|
||||
</div>
|
||||
)}
|
||||
{analisis.deadweightLoss > 0 && (
|
||||
<div className="bg-red-100 rounded p-2 col-span-2">
|
||||
<span className="text-red-700">Pérdida de peso muerto:</span>
|
||||
<p className="font-semibold text-red-800">${analisis.deadweightLoss.toFixed(1)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="p-4 bg-purple-50 rounded-lg">
|
||||
<h4 className="font-semibold text-purple-800 mb-2">Resultado</h4>
|
||||
<p className="text-sm text-purple-700">
|
||||
Cantidad transada: <span className="font-bold">{analisis.cantidadTransada.toFixed(1)} unidades</span>
|
||||
</p>
|
||||
<p className="text-xs text-purple-600 mt-1">
|
||||
{analisis.tipo === 'precio-maximo'
|
||||
? 'Con precio máximo, los vendedores quieren vender menos cantidad.'
|
||||
: analisis.tipo === 'precio-minimo'
|
||||
? 'Con precio mínimo, los compradores quieren comprar menos cantidad.'
|
||||
: 'En equilibrio, la cantidad demandada = cantidad ofrecida.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimuladorPrecios;
|
||||
3
frontend/src/components/exercises/modulo2/index.ts
Normal file
3
frontend/src/components/exercises/modulo2/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ConstructorCurvas } from './ConstructorCurvas';
|
||||
export { SimuladorPrecios } from './SimuladorPrecios';
|
||||
export { IdentificarShocks } from './IdentificarShocks';
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Input } from '../../ui/Input';
|
||||
import { Card, CardHeader } from '../../ui/Card';
|
||||
|
||||
type ElasticidadTipo = 'precio' | 'ingreso' | 'cruzada';
|
||||
|
||||
interface CalculadoraElasticidadProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
export function CalculadoraElasticidad({ ejercicioId: _ejercicioId, onComplete }: CalculadoraElasticidadProps) {
|
||||
const [tipo, setTipo] = useState<ElasticidadTipo>('precio');
|
||||
const [p1, setP1] = useState('');
|
||||
const [p2, setP2] = useState('');
|
||||
const [q1, setQ1] = useState('');
|
||||
const [q2, setQ2] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [resultado, setResultado] = useState<{
|
||||
deltaQ: number;
|
||||
deltaP: number;
|
||||
qPromedio: number;
|
||||
pPromedio: number;
|
||||
porcentajeQ: number;
|
||||
porcentajeP: number;
|
||||
elasticidad: number;
|
||||
interpretacion: string;
|
||||
} | null>(null);
|
||||
|
||||
const calcular = useCallback(() => {
|
||||
const numP1 = parseFloat(p1);
|
||||
const numP2 = parseFloat(p2);
|
||||
const numQ1 = parseFloat(q1);
|
||||
const numQ2 = parseFloat(q2);
|
||||
|
||||
if (isNaN(numP1) || isNaN(numP2) || isNaN(numQ1) || isNaN(numQ2)) {
|
||||
setError('Todos los valores deben ser numéricos');
|
||||
setResultado(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (numP1 === numP2 && tipo === 'precio') {
|
||||
setError('P1 y P2 no pueden ser iguales para calcular elasticidad');
|
||||
setResultado(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
|
||||
// Método del punto medio
|
||||
const deltaQ = numQ2 - numQ1;
|
||||
const deltaP = numP2 - numP1;
|
||||
const qPromedio = (numQ1 + numQ2) / 2;
|
||||
const pPromedio = (numP1 + numP2) / 2;
|
||||
|
||||
const porcentajeQ = (deltaQ / qPromedio) * 100;
|
||||
const porcentajeP = (deltaP / pPromedio) * 100;
|
||||
const elasticidad = porcentajeQ / porcentajeP;
|
||||
|
||||
let interpretacion = '';
|
||||
const absE = Math.abs(elasticidad);
|
||||
|
||||
if (tipo === 'precio') {
|
||||
if (absE > 1) interpretacion = 'Demanda ELÁSTICA: |E| > 1. El consumo responde más que proporcionalmente al cambio de precio.';
|
||||
else if (absE < 1) interpretacion = 'Demanda INELÁSTICA: |E| < 1. El consumo responde menos que proporcionalmente al cambio de precio.';
|
||||
else interpretacion = 'Demanda UNITARIA: |E| = 1. El consumo responde exactamente proporcional al cambio de precio.';
|
||||
} else if (tipo === 'ingreso') {
|
||||
if (elasticidad > 1) interpretacion = 'Bien de LUJO: Ei > 1. El gasto en el bien aumenta más que proporcionalmente al ingreso.';
|
||||
else if (elasticidad > 0 && elasticidad < 1) interpretacion = 'Bien NECESARIO: 0 < Ei < 1. El gasto aumenta menos que proporcionalmente al ingreso.';
|
||||
else if (elasticidad < 0) interpretacion = 'Bien INFERIOR: Ei < 0. El consumo disminuye cuando aumenta el ingreso.';
|
||||
else interpretacion = 'Bien NEUTRO: Ei = 0. El consumo no cambia con el ingreso.';
|
||||
} else {
|
||||
if (elasticidad > 0) interpretacion = 'BIENES SUSTITUTOS: Ecr > 0. El aumento del precio de Y aumenta la demanda de X.';
|
||||
else if (elasticidad < 0) interpretacion = 'BIENES COMPLEMENTARIOS: Ecr < 0. El aumento del precio de Y disminuye la demanda de X.';
|
||||
else interpretacion = 'BIENES INDEPENDIENTES: Ecr = 0. No existe relación entre los bienes.';
|
||||
}
|
||||
|
||||
setResultado({
|
||||
deltaQ,
|
||||
deltaP,
|
||||
qPromedio,
|
||||
pPromedio,
|
||||
porcentajeQ,
|
||||
porcentajeP,
|
||||
elasticidad,
|
||||
interpretacion
|
||||
});
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(100);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [p1, p2, q1, q2, tipo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (p1 && p2 && q1 && q2) {
|
||||
calcular();
|
||||
}
|
||||
}, [p1, p2, q1, q2, tipo, calcular]);
|
||||
|
||||
const getLabelPrecio = () => {
|
||||
if (tipo === 'cruzada') return 'P1/Py1 (Precio del otro bien)';
|
||||
return 'P1 (Precio inicial)';
|
||||
};
|
||||
|
||||
const getLabelCantidad = () => {
|
||||
if (tipo === 'ingreso') return 'Q1 (Cantidad con ingreso I1)';
|
||||
return 'Q1 (Cantidad inicial)';
|
||||
};
|
||||
|
||||
const tipoLabels: Record<ElasticidadTipo, string> = {
|
||||
precio: 'Elasticidad Precio de la Demanda',
|
||||
ingreso: 'Elasticidad Ingreso',
|
||||
cruzada: 'Elasticidad Cruzada'
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader
|
||||
title="Calculadora de Elasticidad"
|
||||
subtitle="Método del punto medio (Arco)"
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['precio', 'ingreso', 'cruzada'] as ElasticidadTipo[]).map((t) => (
|
||||
<Button
|
||||
key={t}
|
||||
variant={tipo === t ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTipo(t)}
|
||||
>
|
||||
{tipoLabels[t]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-blue-800 font-medium">{tipoLabels[tipo]}</p>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
{tipo === 'precio' && 'Mide la sensibilidad de la cantidad demandada ante cambios en el precio del propio bien'}
|
||||
{tipo === 'ingreso' && 'Mide la sensibilidad de la cantidad demandada ante cambios en el ingreso del consumidor'}
|
||||
{tipo === 'cruzada' && 'Mide la sensibilidad de la cantidad demandada de X ante cambios en el precio de Y'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={getLabelCantidad()}
|
||||
type="number"
|
||||
value={q1}
|
||||
onChange={(e) => setQ1(e.target.value)}
|
||||
placeholder="Ej: 100"
|
||||
/>
|
||||
<Input
|
||||
label={tipo === 'ingreso' ? 'Q2 (Cantidad con ingreso I2)' : 'Q2 (Cantidad final)'}
|
||||
type="number"
|
||||
value={q2}
|
||||
onChange={(e) => setQ2(e.target.value)}
|
||||
placeholder="Ej: 80"
|
||||
/>
|
||||
<Input
|
||||
label={getLabelPrecio()}
|
||||
type="number"
|
||||
value={p1}
|
||||
onChange={(e) => setP1(e.target.value)}
|
||||
placeholder="Ej: 10"
|
||||
/>
|
||||
<Input
|
||||
label={tipo === 'cruzada' ? 'P2/Py2 (Precio final del otro bien)' : 'P2 (Precio final)'}
|
||||
type="number"
|
||||
value={p2}
|
||||
onChange={(e) => setP2(e.target.value)}
|
||||
placeholder="Ej: 12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-500 text-sm bg-red-50 p-3 rounded-lg">{error}</p>
|
||||
)}
|
||||
|
||||
{resultado && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 p-4 rounded-lg space-y-3">
|
||||
<h4 className="font-semibold text-gray-700">Desarrollo paso a paso:</h4>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="bg-white p-3 rounded border">
|
||||
<p className="font-medium text-gray-600">Paso 1: Calcular cambios</p>
|
||||
<p className="font-mono text-gray-800">
|
||||
ΔQ = Q2 - Q1 = {q2} - {q1} = {resultado.deltaQ.toFixed(2)}
|
||||
</p>
|
||||
<p className="font-mono text-gray-800">
|
||||
ΔP = P2 - P1 = {p2} - {p1} = {resultado.deltaP.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-3 rounded border">
|
||||
<p className="font-medium text-gray-600">Paso 2: Calcular promedios</p>
|
||||
<p className="font-mono text-gray-800">
|
||||
Q̄ = (Q1 + Q2) / 2 = ({q1} + {q2}) / 2 = {resultado.qPromedio.toFixed(2)}
|
||||
</p>
|
||||
<p className="font-mono text-gray-800">
|
||||
P̄ = (P1 + P2) / 2 = ({p1} + {p2}) / 2 = {resultado.pPromedio.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-3 rounded border">
|
||||
<p className="font-medium text-gray-600">Paso 3: Calcular variaciones porcentuales</p>
|
||||
<p className="font-mono text-gray-800">
|
||||
%ΔQ = (ΔQ / Q̄) × 100 = ({resultado.deltaQ.toFixed(2)} / {resultado.qPromedio.toFixed(2)}) × 100 = {resultado.porcentajeQ.toFixed(2)}%
|
||||
</p>
|
||||
<p className="font-mono text-gray-800">
|
||||
%ΔP = (ΔP / P̄) × 100 = ({resultado.deltaP.toFixed(2)} / {resultado.pPromedio.toFixed(2)}) × 100 = {resultado.porcentajeP.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-100 p-3 rounded border border-blue-300">
|
||||
<p className="font-medium text-blue-800">Paso 4: Calcular elasticidad</p>
|
||||
<p className="font-mono text-blue-900 text-lg">
|
||||
E = %ΔQ / %ΔP = {resultado.porcentajeQ.toFixed(2)} / {resultado.porcentajeP.toFixed(2)} = <strong>{resultado.elasticidad.toFixed(4)}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-4 rounded-lg ${
|
||||
tipo === 'precio'
|
||||
? (Math.abs(resultado.elasticidad) > 1
|
||||
? 'bg-green-100 border border-green-300'
|
||||
: Math.abs(resultado.elasticidad) < 1
|
||||
? 'bg-orange-100 border border-orange-300'
|
||||
: 'bg-blue-100 border border-blue-300')
|
||||
: tipo === 'ingreso'
|
||||
? (resultado.elasticidad > 1
|
||||
? 'bg-purple-100 border border-purple-300'
|
||||
: resultado.elasticidad > 0
|
||||
? 'bg-yellow-100 border border-yellow-300'
|
||||
: 'bg-red-100 border border-red-300')
|
||||
: (resultado.elasticidad > 0
|
||||
? 'bg-green-100 border border-green-300'
|
||||
: 'bg-red-100 border border-red-300')
|
||||
}`}>
|
||||
<p className="font-semibold text-gray-800">Interpretación:</p>
|
||||
<p className="text-gray-700 mt-1">{resultado.interpretacion}</p>
|
||||
{tipo === 'precio' && Math.abs(resultado.elasticidad) > 1 && (
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
El ingreso total aumentará si se reduce el precio (efecto cantidad domina).
|
||||
</p>
|
||||
)}
|
||||
{tipo === 'precio' && Math.abs(resultado.elasticidad) < 1 && (
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
El ingreso total aumentará si se aumenta el precio (efecto precio domina).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalculadoraElasticidad;
|
||||
228
frontend/src/components/exercises/modulo3/ClasificadorBienes.tsx
Normal file
228
frontend/src/components/exercises/modulo3/ClasificadorBienes.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Card, CardHeader } from '../../ui/Card';
|
||||
|
||||
interface Bien {
|
||||
id: string;
|
||||
nombre: string;
|
||||
descripcion: string;
|
||||
elasticidad: number;
|
||||
categoriaCorrecta: Categoria;
|
||||
}
|
||||
|
||||
type Categoria = 'lujo' | 'necesario' | 'inferior';
|
||||
|
||||
interface ClasificadorBienesProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
const bienes: Bien[] = [
|
||||
{ id: '1', nombre: 'Caviar', descripcion: 'Alimento de lujo', elasticidad: 3.5, categoriaCorrecta: 'lujo' },
|
||||
{ id: '2', nombre: 'Arroz', descripcion: 'Grano básico de consumo', elasticidad: 0.3, categoriaCorrecta: 'necesario' },
|
||||
{ id: '3', nombre: 'Viajes en primera clase', descripcion: 'Transporte de lujo', elasticidad: 2.8, categoriaCorrecta: 'lujo' },
|
||||
{ id: '4', nombre: 'Pasta de dientes', descripcion: 'Higiene personal básica', elasticidad: 0.15, categoriaCorrecta: 'necesario' },
|
||||
{ id: '5', nombre: 'Autobuses', descripcion: 'Transporte público urbano', elasticidad: -0.5, categoriaCorrecta: 'inferior' },
|
||||
{ id: '6', nombre: 'Frijoles', descripcion: 'Proteína básica', elasticidad: 0.4, categoriaCorrecta: 'necesario' },
|
||||
{ id: '7', nombre: 'Yates privados', descripcion: 'Embarcaciones recreativas', elasticidad: 4.2, categoriaCorrecta: 'lujo' },
|
||||
{ id: '8', nombre: 'Pan de bagazo', descripcion: 'Pan económico de baja calidad', elasticidad: -0.8, categoriaCorrecta: 'inferior' },
|
||||
{ id: '9', nombre: 'Sal', descripcion: 'Condimento esencial', elasticidad: 0.05, categoriaCorrecta: 'necesario' },
|
||||
{ id: '10', nombre: 'Joyería fina', descripcion: 'Accesorios de oro/plata', elasticidad: 2.2, categoriaCorrecta: 'lujo' },
|
||||
{ id: '11', nombre: 'Comida rápida barata', descripcion: 'Hamburguesas de bajo costo', elasticidad: -0.3, categoriaCorrecta: 'inferior' },
|
||||
{ id: '12', nombre: 'Medicinas genéricas', descripcion: 'Productos farmacéuticos básicos', elasticidad: 0.2, categoriaCorrecta: 'necesario' }
|
||||
];
|
||||
|
||||
const categorias: { id: Categoria; nombre: string; descripcion: string; rango: string; color: string }[] = [
|
||||
{
|
||||
id: 'lujo',
|
||||
nombre: 'Bienes de Lujo',
|
||||
descripcion: 'El gasto aumenta más que proporcionalmente al ingreso',
|
||||
rango: 'Ei > 1',
|
||||
color: 'bg-purple-100 border-purple-300 text-purple-800'
|
||||
},
|
||||
{
|
||||
id: 'necesario',
|
||||
nombre: 'Bienes Necesarios',
|
||||
descripcion: 'El gasto aumenta menos que proporcionalmente al ingreso',
|
||||
rango: '0 < Ei < 1',
|
||||
color: 'bg-blue-100 border-blue-300 text-blue-800'
|
||||
},
|
||||
{
|
||||
id: 'inferior',
|
||||
nombre: 'Bienes Inferiores',
|
||||
descripcion: 'El consumo disminuye cuando aumenta el ingreso',
|
||||
rango: 'Ei < 0',
|
||||
color: 'bg-red-100 border-red-300 text-red-800'
|
||||
}
|
||||
];
|
||||
|
||||
export function ClasificadorBienes({ ejercicioId: _ejercicioId, onComplete }: ClasificadorBienesProps) {
|
||||
const [clasificaciones, setClasificaciones] = useState<Record<string, Categoria | null>>({});
|
||||
const [mostrarResultados, setMostrarResultados] = useState(false);
|
||||
const [bienesActuales] = useState(() =>
|
||||
[...bienes].sort(() => Math.random() - 0.5).slice(0, 8)
|
||||
);
|
||||
|
||||
const seleccionarCategoria = (bienId: string, categoria: Categoria) => {
|
||||
setClasificaciones(prev => ({
|
||||
...prev,
|
||||
[bienId]: categoria
|
||||
}));
|
||||
};
|
||||
|
||||
const verificarResultados = () => {
|
||||
setMostrarResultados(true);
|
||||
|
||||
const correctas = bienesActuales.filter(
|
||||
bien => clasificaciones[bien.id] === bien.categoriaCorrecta
|
||||
).length;
|
||||
|
||||
const score = Math.round((correctas / bienesActuales.length) * 100);
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(score);
|
||||
}
|
||||
|
||||
return score;
|
||||
};
|
||||
|
||||
const reiniciar = () => {
|
||||
setClasificaciones({});
|
||||
setMostrarResultados(false);
|
||||
};
|
||||
|
||||
const getEstadoBien = (bien: Bien) => {
|
||||
if (!mostrarResultados || !clasificaciones[bien.id]) {
|
||||
return 'bg-white border-gray-200';
|
||||
}
|
||||
|
||||
const esCorrecto = clasificaciones[bien.id] === bien.categoriaCorrecta;
|
||||
return esCorrecto
|
||||
? 'bg-green-50 border-green-400'
|
||||
: 'bg-red-50 border-red-400';
|
||||
};
|
||||
|
||||
const getIconoEstado = (bien: Bien) => {
|
||||
if (!mostrarResultados || !clasificaciones[bien.id]) return null;
|
||||
|
||||
const esCorrecto = clasificaciones[bien.id] === bien.categoriaCorrecta;
|
||||
return esCorrecto ? (
|
||||
<span className="text-green-600 text-xl">✓</span>
|
||||
) : (
|
||||
<span className="text-red-600 text-xl">✗</span>
|
||||
);
|
||||
};
|
||||
|
||||
const puntuacion = mostrarResultados
|
||||
? bienesActuales.filter(bien => clasificaciones[bien.id] === bien.categoriaCorrecta).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card className="max-w-4xl mx-auto">
|
||||
<CardHeader
|
||||
title="Clasificador de Bienes"
|
||||
subtitle="Clasifica cada bien según su elasticidad ingreso"
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
{categorias.map((cat) => (
|
||||
<div key={cat.id} className={`p-4 rounded-lg border-2 ${cat.color}`}>
|
||||
<h4 className="font-bold text-lg">{cat.nombre}</h4>
|
||||
<p className="text-sm font-mono font-semibold">{cat.rango}</p>
|
||||
<p className="text-xs mt-1 opacity-80">{cat.descripcion}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{bienesActuales.map((bien) => (
|
||||
<div
|
||||
key={bien.id}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${getEstadoBien(bien)}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold text-gray-800">{bien.nombre}</h4>
|
||||
{getIconoEstado(bien)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{bien.descripcion}</p>
|
||||
{mostrarResultados && (
|
||||
<p className="text-xs font-mono mt-1 text-gray-600">
|
||||
Ei = {bien.elasticidad} → {categorias.find(c => c.id === bien.categoriaCorrecta)?.nombre}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{categorias.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => !mostrarResultados && seleccionarCategoria(bien.id, cat.id)}
|
||||
disabled={mostrarResultados}
|
||||
className={`px-3 py-2 rounded text-xs font-medium transition-all ${
|
||||
clasificaciones[bien.id] === cat.id
|
||||
? cat.color + ' border-2'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 border-2 border-transparent'
|
||||
} ${mostrarResultados ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
{cat.nombre.split(' ')[1]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<div className="text-sm text-gray-600">
|
||||
Progreso: {Object.keys(clasificaciones).length} / {bienesActuales.length}
|
||||
</div>
|
||||
|
||||
{!mostrarResultados ? (
|
||||
<Button
|
||||
onClick={verificarResultados}
|
||||
disabled={Object.keys(clasificaciones).length < bienesActuales.length}
|
||||
>
|
||||
Verificar Resultados
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-600">Puntuación</p>
|
||||
<p className="text-2xl font-bold text-gray-800">
|
||||
{puntuacion} / {bienesActuales.length}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={reiniciar}>
|
||||
Reiniciar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mostrarResultados && (
|
||||
<div className={`p-4 rounded-lg ${
|
||||
puntuacion === bienesActuales.length
|
||||
? 'bg-green-100 border border-green-300'
|
||||
: puntuacion >= bienesActuales.length / 2
|
||||
? 'bg-yellow-100 border border-yellow-300'
|
||||
: 'bg-red-100 border border-red-300'
|
||||
}`}>
|
||||
<p className="font-semibold text-gray-800">
|
||||
{puntuacion === bienesActuales.length
|
||||
? '¡Perfecto! Has clasificado todos los bienes correctamente.'
|
||||
: puntuacion >= bienesActuales.length / 2
|
||||
? '¡Buen trabajo! Sigue practicando para mejorar.'
|
||||
: 'Necesitas más práctica. Revisa las categorías y vuelve a intentar.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClasificadorBienes;
|
||||
404
frontend/src/components/exercises/modulo3/EjerciciosExamen.tsx
Normal file
404
frontend/src/components/exercises/modulo3/EjerciciosExamen.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Input } from '../../ui/Input';
|
||||
import { Card, CardHeader } from '../../ui/Card';
|
||||
|
||||
interface Problema {
|
||||
id: number;
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
datos: { etiqueta: string; valor: string }[];
|
||||
preguntas: {
|
||||
id: string;
|
||||
texto: string;
|
||||
tipo: 'numero' | 'seleccion';
|
||||
opciones?: string[];
|
||||
respuestaCorrecta: number | string;
|
||||
tolerancia?: number;
|
||||
solucion: string[];
|
||||
}[];
|
||||
}
|
||||
|
||||
const problemas: Problema[] = [
|
||||
{
|
||||
id: 1,
|
||||
titulo: "Elasticidad Precio de la Demanda",
|
||||
descripcion: "Una tienda de electrónica observa que cuando el precio de un modelo de laptop aumenta de $800 a $900, la cantidad demandada disminuye de 500 a 400 unidades por mes.",
|
||||
datos: [
|
||||
{ etiqueta: "P1", valor: "$800" },
|
||||
{ etiqueta: "P2", valor: "$900" },
|
||||
{ etiqueta: "Q1", valor: "500 unidades" },
|
||||
{ etiqueta: "Q2", valor: "400 unidades" }
|
||||
],
|
||||
preguntas: [
|
||||
{
|
||||
id: "p1_a",
|
||||
texto: "Calcule la elasticidad precio de la demanda usando el método del punto medio.",
|
||||
tipo: "numero",
|
||||
respuestaCorrecta: -1.89,
|
||||
tolerancia: 0.05,
|
||||
solucion: [
|
||||
"Paso 1: ΔQ = 400 - 500 = -100",
|
||||
"Paso 2: ΔP = 900 - 800 = 100",
|
||||
"Paso 3: Q̄ = (500 + 400) / 2 = 450",
|
||||
"Paso 4: P̄ = (800 + 900) / 2 = 850",
|
||||
"Paso 5: %ΔQ = (-100 / 450) × 100 = -22.22%",
|
||||
"Paso 6: %ΔP = (100 / 850) × 100 = 11.76%",
|
||||
"Paso 7: Ed = -22.22% / 11.76% = -1.89"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "p1_b",
|
||||
texto: "¿Qué tipo de demanda presenta este producto?",
|
||||
tipo: "seleccion",
|
||||
opciones: [
|
||||
"Elástica (|Ed| > 1)",
|
||||
"Inelástica (|Ed| < 1)",
|
||||
"Unitaria (|Ed| = 1)"
|
||||
],
|
||||
respuestaCorrecta: "Elástica (|Ed| > 1)",
|
||||
solucion: [
|
||||
"Como |Ed| = 1.89 > 1, la demanda es ELÁSTICA.",
|
||||
"Esto significa que los consumidores son sensibles al cambio de precio.",
|
||||
"Un aumento de precio del 1% reduce la cantidad demandada en aproximadamente 1.89%"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
titulo: "Elasticidad Ingreso",
|
||||
descripcion: "En una economía, cuando el ingreso promedio de los hogares aumenta de $2,000 a $2,500 mensuales, el consumo de restaurantes de alta categoría aumenta de 2 a 4 visitas mensuales por hogar.",
|
||||
datos: [
|
||||
{ etiqueta: "I1", valor: "$2,000" },
|
||||
{ etiqueta: "I2", valor: "$2,500" },
|
||||
{ etiqueta: "Q1", valor: "2 visitas" },
|
||||
{ etiqueta: "Q2", valor: "4 visitas" }
|
||||
],
|
||||
preguntas: [
|
||||
{
|
||||
id: "p2_a",
|
||||
texto: "Calcule la elasticidad ingreso.",
|
||||
tipo: "numero",
|
||||
respuestaCorrecta: 2.33,
|
||||
tolerancia: 0.05,
|
||||
solucion: [
|
||||
"Paso 1: ΔQ = 4 - 2 = 2",
|
||||
"Paso 2: ΔI = 2500 - 2000 = 500",
|
||||
"Paso 3: Q̄ = (2 + 4) / 2 = 3",
|
||||
"Paso 4: Ī = (2000 + 2500) / 2 = 2250",
|
||||
"Paso 5: %ΔQ = (2 / 3) × 100 = 66.67%",
|
||||
"Paso 6: %ΔI = (500 / 2250) × 100 = 22.22%",
|
||||
"Paso 7: Ei = 66.67% / 22.22% = 3.00"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "p2_b",
|
||||
texto: "¿Qué tipo de bien representa los restaurantes de alta categoría?",
|
||||
tipo: "seleccion",
|
||||
opciones: [
|
||||
"Bien necesario (0 < Ei < 1)",
|
||||
"Bien de lujo (Ei > 1)",
|
||||
"Bien inferior (Ei < 0)"
|
||||
],
|
||||
respuestaCorrecta: "Bien de lujo (Ei > 1)",
|
||||
solucion: [
|
||||
"Como Ei = 3.00 > 1, se trata de un BIEN DE LUJO.",
|
||||
"El gasto en este bien aumenta más que proporcionalmente al ingreso.",
|
||||
"Cuando el ingreso crece 10%, el consumo de restaurantes crece 30%"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
titulo: "Elasticidad Cruzada",
|
||||
descripcion: "Cuando el precio del café aumenta de $4 a $6 por libra, la cantidad demandada de té aumenta de 100 a 150 libras mensuales en el mismo mercado.",
|
||||
datos: [
|
||||
{ etiqueta: "Pcafé1", valor: "$4/libra" },
|
||||
{ etiqueta: "Pcafé2", valor: "$6/libra" },
|
||||
{ etiqueta: "Qté1", valor: "100 libras" },
|
||||
{ etiqueta: "Qté2", valor: "150 libras" }
|
||||
],
|
||||
preguntas: [
|
||||
{
|
||||
id: "p3_a",
|
||||
texto: "Calcule la elasticidad cruzada entre café y té.",
|
||||
tipo: "numero",
|
||||
respuestaCorrecta: 1.0,
|
||||
tolerancia: 0.05,
|
||||
solucion: [
|
||||
"Paso 1: ΔQté = 150 - 100 = 50",
|
||||
"Paso 2: ΔPcafé = 6 - 4 = 2",
|
||||
"Paso 3: Qté̄ = (100 + 150) / 2 = 125",
|
||||
"Paso 4: Pcafé̄ = (4 + 6) / 2 = 5",
|
||||
"Paso 5: %ΔQté = (50 / 125) × 100 = 40%",
|
||||
"Paso 6: %ΔPcafé = (2 / 5) × 100 = 40%",
|
||||
"Paso 7: Ecr = 40% / 40% = 1.0"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "p3_b",
|
||||
texto: "¿Qué relación existe entre café y té?",
|
||||
tipo: "seleccion",
|
||||
opciones: [
|
||||
"Son bienes sustitutos (Ecr > 0)",
|
||||
"Son bienes complementarios (Ecr < 0)",
|
||||
"Son bienes independientes (Ecr = 0)"
|
||||
],
|
||||
respuestaCorrecta: "Son bienes sustitutos (Ecr > 0)",
|
||||
solucion: [
|
||||
"Como Ecr = 1.0 > 0, café y té son BIENES SUSTITUTOS.",
|
||||
"Cuando sube el precio del café, los consumidores compran más té.",
|
||||
"Los consumidores pueden sustituir uno por otro según los precios"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
interface Respuesta {
|
||||
valor: string;
|
||||
esCorrecta: boolean | null;
|
||||
}
|
||||
|
||||
interface EjerciciosExamenProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
export function EjerciciosExamen({ ejercicioId: _ejercicioId, onComplete }: EjerciciosExamenProps) {
|
||||
const [respuestas, setRespuestas] = useState<Record<string, Respuesta>>({});
|
||||
const [mostrarSolucion, setMostrarSolucion] = useState<Record<number, boolean>>({});
|
||||
const [problemaActual, setProblemaActual] = useState(0);
|
||||
|
||||
const handleRespuesta = (preguntaId: string, valor: string) => {
|
||||
setRespuestas(prev => ({
|
||||
...prev,
|
||||
[preguntaId]: { valor, esCorrecta: null }
|
||||
}));
|
||||
};
|
||||
|
||||
const verificarRespuesta = (pregunta: Problema['preguntas'][0]) => {
|
||||
const respuesta = respuestas[pregunta.id];
|
||||
if (!respuesta) return;
|
||||
|
||||
let esCorrecta = false;
|
||||
|
||||
if (pregunta.tipo === 'numero') {
|
||||
const valorNum = parseFloat(respuesta.valor);
|
||||
const tolerancia = pregunta.tolerancia || 0.05;
|
||||
esCorrecta = Math.abs(valorNum - (pregunta.respuestaCorrecta as number)) <= tolerancia;
|
||||
} else {
|
||||
esCorrecta = respuesta.valor === pregunta.respuestaCorrecta;
|
||||
}
|
||||
|
||||
setRespuestas(prev => ({
|
||||
...prev,
|
||||
[pregunta.id]: { ...respuesta, esCorrecta }
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleSolucion = (problemaId: number) => {
|
||||
setMostrarSolucion(prev => ({
|
||||
...prev,
|
||||
[problemaId]: !prev[problemaId]
|
||||
}));
|
||||
};
|
||||
|
||||
const calcularPuntuacion = () => {
|
||||
let correctas = 0;
|
||||
let total = 0;
|
||||
|
||||
problemas.forEach(problema => {
|
||||
problema.preguntas.forEach(pregunta => {
|
||||
total++;
|
||||
if (respuestas[pregunta.id]?.esCorrecta) {
|
||||
correctas++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Math.round((correctas / total) * 100);
|
||||
};
|
||||
|
||||
const finalizarExamen = () => {
|
||||
const score = calcularPuntuacion();
|
||||
if (onComplete) {
|
||||
onComplete(score);
|
||||
}
|
||||
return score;
|
||||
};
|
||||
|
||||
const problema = problemas[problemaActual];
|
||||
const progreso = ((problemaActual + 1) / problemas.length) * 100;
|
||||
|
||||
return (
|
||||
<Card className="max-w-3xl mx-auto">
|
||||
<CardHeader
|
||||
title="Ejercicios Tipo Examen"
|
||||
subtitle={`Problema ${problemaActual + 1} de ${problemas.length}`}
|
||||
/>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${progreso}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<h3 className="font-bold text-lg text-blue-900">{problema.titulo}</h3>
|
||||
<p className="text-gray-700 mt-2">{problema.descripcion}</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mt-4">
|
||||
{problema.datos.map((dato, idx) => (
|
||||
<div key={idx} className="bg-white p-2 rounded text-center">
|
||||
<span className="font-mono text-sm font-bold">{dato.etiqueta}</span>
|
||||
<p className="text-xs text-gray-600">{dato.valor}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{problema.preguntas.map((pregunta, idx) => {
|
||||
const respuesta = respuestas[pregunta.id];
|
||||
const estado = respuesta?.esCorrecta;
|
||||
|
||||
return (
|
||||
<div key={pregunta.id} className="border rounded-lg p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="bg-primary text-white rounded-full w-6 h-6 flex items-center justify-center text-sm flex-shrink-0">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<p className="text-gray-800 font-medium">{pregunta.texto}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 ml-8">
|
||||
{pregunta.tipo === 'numero' ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={respuesta?.valor || ''}
|
||||
onChange={(e) => handleRespuesta(pregunta.id, e.target.value)}
|
||||
className="w-48"
|
||||
placeholder="Respuesta numérica"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => verificarRespuesta(pregunta)}
|
||||
disabled={!respuesta?.valor}
|
||||
>
|
||||
Verificar
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pregunta.opciones?.map((opcion) => (
|
||||
<label
|
||||
key={opcion}
|
||||
className={`flex items-center gap-2 p-2 rounded cursor-pointer transition-colors ${
|
||||
respuesta?.valor === opcion
|
||||
? 'bg-blue-100 border-blue-300 border'
|
||||
: 'hover:bg-gray-50 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={pregunta.id}
|
||||
value={opcion}
|
||||
checked={respuesta?.valor === opcion}
|
||||
onChange={(e) => handleRespuesta(pregunta.id, e.target.value)}
|
||||
className="text-primary"
|
||||
/>
|
||||
<span className="text-sm">{opcion}</span>
|
||||
</label>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => verificarRespuesta(pregunta)}
|
||||
disabled={!respuesta?.valor}
|
||||
>
|
||||
Verificar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{estado !== null && (
|
||||
<div className={`mt-3 p-3 rounded ${
|
||||
estado
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{estado ? '¡Correcto!' : 'Incorrecto. Intenta de nuevo.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleSolucion(parseInt(pregunta.id.split('_')[0]))}
|
||||
className="mt-2"
|
||||
>
|
||||
{mostrarSolucion[parseInt(pregunta.id.split('_')[0])] ? 'Ocultar' : 'Ver'} solución
|
||||
</Button>
|
||||
|
||||
{mostrarSolucion[parseInt(pregunta.id.split('_')[0])] && (
|
||||
<div className="mt-2 bg-gray-50 p-3 rounded text-sm">
|
||||
<p className="font-semibold mb-2">Solución paso a paso:</p>
|
||||
<ul className="space-y-1 text-gray-700">
|
||||
{pregunta.solucion.map((paso, i) => (
|
||||
<li key={i} className="font-mono text-xs">{paso}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setProblemaActual(Math.max(0, problemaActual - 1))}
|
||||
disabled={problemaActual === 0}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
|
||||
{problemaActual < problemas.length - 1 ? (
|
||||
<Button
|
||||
onClick={() => setProblemaActual(problemaActual + 1)}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={finalizarExamen}
|
||||
variant="primary"
|
||||
>
|
||||
Finalizar Examen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{problemaActual === problemas.length - 1 && (
|
||||
<div className="bg-gray-50 p-4 rounded-lg text-center">
|
||||
<p className="text-gray-600">Puntuación actual: <strong>{calcularPuntuacion()}%</strong></p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default EjerciciosExamen;
|
||||
3
frontend/src/components/exercises/modulo3/index.ts
Normal file
3
frontend/src/components/exercises/modulo3/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CalculadoraElasticidad } from './CalculadoraElasticidad';
|
||||
export { ClasificadorBienes } from './ClasificadorBienes';
|
||||
export { EjerciciosExamen } from './EjerciciosExamen';
|
||||
328
frontend/src/components/exercises/modulo4/CalculadoraCostos.tsx
Normal file
328
frontend/src/components/exercises/modulo4/CalculadoraCostos.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Card, CardHeader } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { CheckCircle, RotateCcw, Calculator } from 'lucide-react';
|
||||
|
||||
interface FilaCostos {
|
||||
q: number;
|
||||
cv: number;
|
||||
}
|
||||
|
||||
interface FilaCalculada extends FilaCostos {
|
||||
cf: number;
|
||||
ct: number;
|
||||
cfme: number;
|
||||
cvme: number;
|
||||
cme: number;
|
||||
cmg: number | null;
|
||||
}
|
||||
|
||||
interface CalculadoraCostosProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
export function CalculadoraCostos({ ejercicioId: _ejercicioId, onComplete }: CalculadoraCostosProps) {
|
||||
const CF_BASE = 200;
|
||||
|
||||
const [filas, setFilas] = useState<FilaCostos[]>([
|
||||
{ q: 0, cv: 0 },
|
||||
{ q: 1, cv: 50 },
|
||||
{ q: 2, cv: 90 },
|
||||
{ q: 3, cv: 120 },
|
||||
{ q: 4, cv: 160 },
|
||||
{ q: 5, cv: 220 },
|
||||
{ q: 6, cv: 300 },
|
||||
{ q: 7, cv: 400 },
|
||||
{ q: 8, cv: 520 },
|
||||
]);
|
||||
|
||||
const [validado, setValidado] = useState(false);
|
||||
const [errores, setErrores] = useState<string[]>([]);
|
||||
|
||||
const datosCalculados: FilaCalculada[] = useMemo(() => {
|
||||
return filas.map((fila, index) => {
|
||||
const ct = CF_BASE + fila.cv;
|
||||
const cfme = fila.q > 0 ? CF_BASE / fila.q : 0;
|
||||
const cvme = fila.q > 0 ? fila.cv / fila.q : 0;
|
||||
const cme = fila.q > 0 ? ct / fila.q : 0;
|
||||
const cmg = index > 0 ? ct - (CF_BASE + filas[index - 1].cv) : null;
|
||||
|
||||
return {
|
||||
...fila,
|
||||
cf: CF_BASE,
|
||||
ct,
|
||||
cfme,
|
||||
cvme,
|
||||
cme,
|
||||
cmg,
|
||||
};
|
||||
});
|
||||
}, [filas]);
|
||||
|
||||
const handleCvChange = (index: number, valor: string) => {
|
||||
const numValor = parseFloat(valor) || 0;
|
||||
const nuevasFilas = [...filas];
|
||||
nuevasFilas[index] = { ...nuevasFilas[index], cv: numValor };
|
||||
setFilas(nuevasFilas);
|
||||
setValidado(false);
|
||||
};
|
||||
|
||||
const validarCalculos = () => {
|
||||
const nuevosErrores: string[] = [];
|
||||
|
||||
datosCalculados.forEach((fila, index) => {
|
||||
if (fila.ct !== fila.cf + fila.cv) {
|
||||
nuevosErrores.push(`Fila ${index + 1}: CT no coincide con CF + CV`);
|
||||
}
|
||||
if (fila.q > 0 && Math.abs(fila.cme - fila.ct / fila.q) > 0.01) {
|
||||
nuevosErrores.push(`Fila ${index + 1}: CMe calculado incorrectamente`);
|
||||
}
|
||||
});
|
||||
|
||||
setErrores(nuevosErrores);
|
||||
setValidado(true);
|
||||
|
||||
if (nuevosErrores.length === 0) {
|
||||
if (onComplete) {
|
||||
onComplete(100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reiniciar = () => {
|
||||
setFilas([
|
||||
{ q: 0, cv: 0 },
|
||||
{ q: 1, cv: 50 },
|
||||
{ q: 2, cv: 90 },
|
||||
{ q: 3, cv: 120 },
|
||||
{ q: 4, cv: 160 },
|
||||
{ q: 5, cv: 220 },
|
||||
{ q: 6, cv: 300 },
|
||||
{ q: 7, cv: 400 },
|
||||
{ q: 8, cv: 520 },
|
||||
]);
|
||||
setValidado(false);
|
||||
setErrores([]);
|
||||
};
|
||||
|
||||
const maxCT = Math.max(...datosCalculados.map(d => d.ct));
|
||||
const maxCMe = Math.max(...datosCalculados.filter(d => d.q > 0).map(d => d.cme));
|
||||
const maxCMg = Math.max(...datosCalculados.filter(d => d.cmg !== null).map(d => d.cmg || 0));
|
||||
const escalaCT = maxCT > 0 ? 150 / maxCT : 1;
|
||||
const escalaCMe = maxCMe > 0 ? 150 / maxCMe : 1;
|
||||
const escalaCMg = maxCMg > 0 ? 150 / maxCMg : 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Calculadora de Costos"
|
||||
subtitle="Ingresa los Costos Variables (CV) y observa los cálculos automáticos"
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b">
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700">Q</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700">CF</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700 bg-blue-50">CV</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700">CT</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700">CFMe</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700">CVMe</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700">CMe</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700">CMg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{datosCalculados.map((fila, index) => (
|
||||
<tr key={index} className="border-b hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-medium">{fila.q}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{fila.cf}</td>
|
||||
<td className="px-3 py-2 bg-blue-50">
|
||||
<input
|
||||
type="number"
|
||||
value={fila.cv}
|
||||
onChange={(e) => handleCvChange(index, e.target.value)}
|
||||
className="w-20 px-2 py-1 border rounded text-sm focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
min="0"
|
||||
disabled={fila.q === 0}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium text-primary">{fila.ct}</td>
|
||||
<td className="px-3 py-2 text-gray-600">
|
||||
{fila.q > 0 ? fila.cfme.toFixed(2) : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600">
|
||||
{fila.q > 0 ? fila.cvme.toFixed(2) : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium text-secondary">
|
||||
{fila.q > 0 ? fila.cme.toFixed(2) : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium text-success">
|
||||
{fila.cmg !== null ? fila.cmg : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-3">
|
||||
<Button onClick={validarCalculos} variant="primary">
|
||||
<Calculator className="w-4 h-4 mr-2" />
|
||||
Validar Cálculos
|
||||
</Button>
|
||||
<Button onClick={reiniciar} variant="outline">
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Reiniciar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{validado && errores.length === 0 && (
|
||||
<div className="mt-4 p-4 bg-success/10 border border-success rounded-lg">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<span className="font-medium">¡Todos los cálculos son correctos!</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validado && errores.length > 0 && (
|
||||
<div className="mt-4 p-4 bg-error/10 border border-error rounded-lg">
|
||||
<p className="font-medium text-error mb-2">Se encontraron errores:</p>
|
||||
<ul className="list-disc list-inside text-sm text-error">
|
||||
{errores.map((error, i) => (
|
||||
<li key={i}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Visualización de Curvas de Costos"
|
||||
subtitle="Gráfico de CT, CMe y CMg"
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Costo Total (CT)</h4>
|
||||
<div className="h-40 bg-gray-50 rounded-lg p-4 relative">
|
||||
<svg className="w-full h-full" viewBox="0 0 400 160">
|
||||
<line x1="30" y1="140" x2="380" y2="140" stroke="#374151" strokeWidth="1" />
|
||||
<line x1="30" y1="140" x2="30" y2="10" stroke="#374151" strokeWidth="1" />
|
||||
<text x="200" y="155" textAnchor="middle" className="text-xs fill-gray-500">Cantidad (Q)</text>
|
||||
<text x="10" y="75" textAnchor="middle" className="text-xs fill-gray-500" transform="rotate(-90 10 75)">CT</text>
|
||||
|
||||
{datosCalculados.map((d, i) => (
|
||||
<text key={i} x={30 + i * 40} y="150" textAnchor="middle" className="text-xs fill-gray-500">
|
||||
{d.q}
|
||||
</text>
|
||||
))}
|
||||
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#2563eb"
|
||||
strokeWidth="2"
|
||||
points={datosCalculados.map((d, i) => `${30 + i * 40},${140 - d.ct * escalaCT}`).join(' ')}
|
||||
/>
|
||||
|
||||
{datosCalculados.map((d, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={30 + i * 40}
|
||||
cy={140 - d.ct * escalaCT}
|
||||
r="4"
|
||||
fill="#2563eb"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Costo Medio (CMe) vs Costo Marginal (CMg)</h4>
|
||||
<div className="h-40 bg-gray-50 rounded-lg p-4 relative">
|
||||
<svg className="w-full h-full" viewBox="0 0 400 160">
|
||||
<line x1="30" y1="140" x2="380" y2="140" stroke="#374151" strokeWidth="1" />
|
||||
<line x1="30" y1="140" x2="30" y2="10" stroke="#374151" strokeWidth="1" />
|
||||
<text x="200" y="155" textAnchor="middle" className="text-xs fill-gray-500">Cantidad (Q)</text>
|
||||
<text x="10" y="75" textAnchor="middle" className="text-xs fill-gray-500" transform="rotate(-90 10 75)">Costo</text>
|
||||
|
||||
{datosCalculados.filter(d => d.q > 0).map((d, i) => (
|
||||
<text key={i} x={70 + i * 40} y="150" textAnchor="middle" className="text-xs fill-gray-500">
|
||||
{d.q}
|
||||
</text>
|
||||
))}
|
||||
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#7c3aed"
|
||||
strokeWidth="2"
|
||||
points={datosCalculados
|
||||
.filter(d => d.q > 0)
|
||||
.map((d, i) => `${70 + i * 40},${140 - d.cme * escalaCMe}`)
|
||||
.join(' ')}
|
||||
/>
|
||||
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#16a34a"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="4"
|
||||
points={datosCalculados
|
||||
.filter(d => d.cmg !== null)
|
||||
.map((d, i) => `${70 + i * 40},${140 - (d.cmg || 0) * escalaCMg}`)
|
||||
.join(' ')}
|
||||
/>
|
||||
|
||||
{datosCalculados.filter(d => d.q > 0).map((d, i) => (
|
||||
<circle
|
||||
key={`cme-${i}`}
|
||||
cx={70 + i * 40}
|
||||
cy={140 - d.cme * escalaCMe}
|
||||
r="4"
|
||||
fill="#7c3aed"
|
||||
/>
|
||||
))}
|
||||
|
||||
{datosCalculados.filter(d => d.cmg !== null).map((d, i) => (
|
||||
<circle
|
||||
key={`cmg-${i}`}
|
||||
cx={70 + i * 40}
|
||||
cy={140 - (d.cmg || 0) * escalaCMg}
|
||||
r="4"
|
||||
fill="#16a34a"
|
||||
/>
|
||||
))}
|
||||
|
||||
<g transform="translate(280, 30)">
|
||||
<line x1="0" y1="0" x2="20" y2="0" stroke="#7c3aed" strokeWidth="2" />
|
||||
<text x="25" y="4" className="text-xs fill-gray-600">CMe</text>
|
||||
<line x1="0" y1="15" x2="20" y2="15" stroke="#16a34a" strokeWidth="2" strokeDasharray="4" />
|
||||
<text x="25" y="19" className="text-xs fill-gray-600">CMg</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<h4 className="font-semibold text-blue-900 mb-2">Fórmulas utilizadas:</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800">
|
||||
<li><strong>CT</strong> = CF + CV (Costo Total)</li>
|
||||
<li><strong>CFMe</strong> = CF / Q (Costo Fijo Medio)</li>
|
||||
<li><strong>CVMe</strong> = CV / Q (Costo Variable Medio)</li>
|
||||
<li><strong>CMe</strong> = CT / Q (Costo Medio)</li>
|
||||
<li><strong>CMg</strong> = ΔCT / ΔQ (Costo Marginal)</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalculadoraCostos;
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Card, CardHeader } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Input } from '../../ui/Input';
|
||||
import { CheckCircle, Target, TrendingUp, DollarSign } from 'lucide-react';
|
||||
|
||||
interface FilaProduccion {
|
||||
q: number;
|
||||
ct: number;
|
||||
}
|
||||
|
||||
interface FilaCalculada {
|
||||
q: number;
|
||||
precio: number;
|
||||
it: number;
|
||||
ct: number;
|
||||
bt: number;
|
||||
img: number | null;
|
||||
cmg: number | null;
|
||||
}
|
||||
|
||||
interface SimuladorProduccionProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
export function SimuladorProduccion({ ejercicioId: _ejercicioId, onComplete }: SimuladorProduccionProps) {
|
||||
const [precio, setPrecio] = useState(80);
|
||||
|
||||
const datosBase: FilaProduccion[] = [
|
||||
{ q: 0, ct: 200 },
|
||||
{ q: 1, ct: 250 },
|
||||
{ q: 2, ct: 290 },
|
||||
{ q: 3, ct: 320 },
|
||||
{ q: 4, ct: 360 },
|
||||
{ q: 5, ct: 420 },
|
||||
{ q: 6, ct: 500 },
|
||||
{ q: 7, ct: 600 },
|
||||
{ q: 8, ct: 720 },
|
||||
];
|
||||
|
||||
const datosCalculados: FilaCalculada[] = useMemo(() => {
|
||||
return datosBase.map((fila, index) => {
|
||||
const it = precio * fila.q;
|
||||
const bt = it - fila.ct;
|
||||
const img = index > 0 ? precio : null;
|
||||
const cmg = index > 0 ? fila.ct - datosBase[index - 1].ct : null;
|
||||
|
||||
return {
|
||||
q: fila.q,
|
||||
precio,
|
||||
it,
|
||||
ct: fila.ct,
|
||||
bt,
|
||||
img,
|
||||
cmg,
|
||||
};
|
||||
});
|
||||
}, [precio]);
|
||||
|
||||
const qOptima = useMemo(() => {
|
||||
let maxBT = -Infinity;
|
||||
let qOpt = 0;
|
||||
|
||||
datosCalculados.forEach((fila) => {
|
||||
if (fila.bt > maxBT) {
|
||||
maxBT = fila.bt;
|
||||
qOpt = fila.q;
|
||||
}
|
||||
});
|
||||
|
||||
return qOpt;
|
||||
}, [datosCalculados]);
|
||||
|
||||
const verificacionIMgCMg = useMemo(() => {
|
||||
const filasValidas = datosCalculados.filter(f => f.img !== null && f.cmg !== null);
|
||||
const filaOptima = filasValidas.find(f => f.q === qOptima);
|
||||
|
||||
if (!filaOptima) return null;
|
||||
|
||||
return {
|
||||
img: filaOptima.img,
|
||||
cmg: filaOptima.cmg,
|
||||
diferencia: Math.abs((filaOptima.img || 0) - (filaOptima.cmg || 0)),
|
||||
cumple: Math.abs((filaOptima.img || 0) - (filaOptima.cmg || 0)) < 5,
|
||||
};
|
||||
}, [datosCalculados, qOptima]);
|
||||
|
||||
const maxValor = Math.max(
|
||||
...datosCalculados.map(d => Math.max(d.it, d.ct, d.bt > 0 ? d.bt : 0))
|
||||
);
|
||||
const escala = maxValor > 0 ? 140 / maxValor : 1;
|
||||
|
||||
const handleCompletar = () => {
|
||||
if (onComplete) {
|
||||
onComplete(100);
|
||||
}
|
||||
return 100;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Simulador de Decisión de Producción"
|
||||
subtitle="Encuentra la cantidad óptima que maximiza el beneficio"
|
||||
/>
|
||||
|
||||
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Precio de Mercado (P)
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<DollarSign className="w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
type="number"
|
||||
value={precio}
|
||||
onChange={(e) => setPrecio(parseFloat(e.target.value) || 0)}
|
||||
className="w-32"
|
||||
min="0"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
Ajusta el precio para ver cómo cambia la decisión óptima
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b">
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700">Q</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700">Precio (P)</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-primary">IT = P × Q</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-700">CT</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-success">BT = IT - CT</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">IMg</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">CMg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{datosCalculados.map((fila) => (
|
||||
<tr
|
||||
key={fila.q}
|
||||
className={`border-b hover:bg-gray-50 ${
|
||||
fila.q === qOptima ? 'bg-green-50' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{fila.q}</td>
|
||||
<td className="px-3 py-2">{fila.precio}</td>
|
||||
<td className="px-3 py-2 font-medium text-primary">{fila.it}</td>
|
||||
<td className="px-3 py-2">{fila.ct}</td>
|
||||
<td className={`px-3 py-2 font-bold ${fila.bt >= 0 ? 'text-success' : 'text-error'}`}>
|
||||
{fila.bt}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600">
|
||||
{fila.img !== null ? fila.img : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600">
|
||||
{fila.cmg !== null ? fila.cmg : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Target className="w-6 h-6 text-green-600" />
|
||||
<div>
|
||||
<p className="font-semibold text-green-800">
|
||||
Cantidad Óptima: Q = {qOptima}
|
||||
</p>
|
||||
<p className="text-sm text-green-700">
|
||||
Beneficio Máximo: BT = {datosCalculados.find(d => d.q === qOptima)?.bt}
|
||||
{' '}(${precio} × {qOptima} - {datosCalculados.find(d => d.q === qOptima)?.ct})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{verificacionIMgCMg && (
|
||||
<div className={`mt-4 p-4 rounded-lg ${
|
||||
verificacionIMgCMg.cumple
|
||||
? 'bg-success/10 border border-success'
|
||||
: 'bg-yellow-50 border border-yellow-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{verificacionIMgCMg.cumple ? (
|
||||
<CheckCircle className="w-5 h-5 text-success" />
|
||||
) : (
|
||||
<TrendingUp className="w-5 h-5 text-yellow-600" />
|
||||
)}
|
||||
<span className={`font-medium ${
|
||||
verificacionIMgCMg.cumple ? 'text-success' : 'text-yellow-800'
|
||||
}`}>
|
||||
Verificación IMg ≈ CMg:
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
IMg = {verificacionIMgCMg.img}, CMg = {verificacionIMgCMg.cmg}
|
||||
{' '}(Diferencia: {verificacionIMgCMg.diferencia.toFixed(1)})
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{verificacionIMgCMg.cumple
|
||||
? '✓ La condición de optimalidad se cumple: IMg ≈ CMg'
|
||||
: 'La diferencia es significativa, pero el beneficio sigue siendo máximo en Q = ' + qOptima}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Gráfico de IT y CT"
|
||||
subtitle="Visualiza el punto donde la distancia entre IT y CT es máxima"
|
||||
/>
|
||||
|
||||
<div className="h-64 bg-gray-50 rounded-lg p-4">
|
||||
<svg className="w-full h-full" viewBox="0 0 500 220">
|
||||
<line x1="50" y1="190" x2="480" y2="190" stroke="#374151" strokeWidth="1" />
|
||||
<line x1="50" y1="190" x2="50" y2="20" stroke="#374151" strokeWidth="1" />
|
||||
<text x="265" y="210" textAnchor="middle" className="text-sm fill-gray-600">Cantidad (Q)</text>
|
||||
<text x="20" y="105" textAnchor="middle" className="text-sm fill-gray-600" transform="rotate(-90 20 105)">$</text>
|
||||
|
||||
{datosCalculados.map((d, i) => (
|
||||
<text key={i} x={80 + i * 45} y="205" textAnchor="middle" className="text-xs fill-gray-500">
|
||||
{d.q}
|
||||
</text>
|
||||
))}
|
||||
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#2563eb"
|
||||
strokeWidth="3"
|
||||
points={datosCalculados.map((d, i) => `${80 + i * 45},${190 - d.it * escala}`).join(' ')}
|
||||
/>
|
||||
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#dc2626"
|
||||
strokeWidth="3"
|
||||
points={datosCalculados.map((d, i) => `${80 + i * 45},${190 - d.ct * escala}`).join(' ')}
|
||||
/>
|
||||
|
||||
{datosCalculados.map((d, i) => (
|
||||
<g key={i}>
|
||||
<circle
|
||||
cx={80 + i * 45}
|
||||
cy={190 - d.it * escala}
|
||||
r="5"
|
||||
fill="#2563eb"
|
||||
/>
|
||||
<circle
|
||||
cx={80 + i * 45}
|
||||
cy={190 - d.ct * escala}
|
||||
r="5"
|
||||
fill="#dc2626"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
|
||||
<g transform={`translate(${80 + datosCalculados.findIndex(d => d.q === qOptima) * 45}, ${
|
||||
190 - (datosCalculados.find(d => d.q === qOptima)?.it || 0) * escala - 20
|
||||
})`}>
|
||||
<polygon points="0,0 -8,-15 8,-15" fill="#16a34a" />
|
||||
<text x="0" y="-20" textAnchor="middle" className="text-xs fill-green-600 font-bold">
|
||||
Óptimo Q={qOptima}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(380, 40)">
|
||||
<line x1="0" y1="0" x2="30" y2="0" stroke="#2563eb" strokeWidth="3" />
|
||||
<text x="40" y="5" className="text-sm fill-gray-700">IT (Ingreso Total)</text>
|
||||
<line x1="0" y1="20" x2="30" y2="20" stroke="#dc2626" strokeWidth="3" />
|
||||
<text x="40" y="25" className="text-sm fill-gray-700">CT (Costo Total)</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-r from-green-50 to-blue-50">
|
||||
<h4 className="font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
Conceptos Clave
|
||||
</h4>
|
||||
<div className="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 mb-1">Ingreso Total (IT)</p>
|
||||
<p className="text-gray-600">IT = P × Q</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 mb-1">Beneficio Total (BT)</p>
|
||||
<p className="text-gray-600">BT = IT - CT</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 mb-1">Ingreso Marginal (IMg)</p>
|
||||
<p className="text-gray-600">IMg = ΔIT / ΔQ = P (en competencia perfecta)</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 mb-1">Condición de Optimalidad</p>
|
||||
<p className="text-gray-600">IMg = CMg (producir hasta que el ingreso marginal iguale al costo marginal)</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleCompletar} size="lg">
|
||||
<CheckCircle className="w-5 h-5 mr-2" />
|
||||
Marcar como Completado
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SimuladorProduccion;
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Card, CardHeader } from '../../ui/Card';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Input } from '../../ui/Input';
|
||||
import { CheckCircle, Info, TrendingUp } from 'lucide-react';
|
||||
|
||||
interface VisualizadorExcedentesProps {
|
||||
ejercicioId: string;
|
||||
onComplete?: (puntuacion: number) => void;
|
||||
}
|
||||
|
||||
export function VisualizadorExcedentes({ ejercicioId: _ejercicioId, onComplete }: VisualizadorExcedentesProps) {
|
||||
const [precio, setPrecio] = useState(50);
|
||||
|
||||
const demandaParams = { a: 100, b: 1 };
|
||||
const ofertaParams = { c: 10, d: 0.8 };
|
||||
|
||||
const puntoEquilibrio = useMemo(() => {
|
||||
const { a, b } = demandaParams;
|
||||
const { c, d } = ofertaParams;
|
||||
const pEq = (a - c) / (b + d);
|
||||
const qEq = a - b * pEq;
|
||||
return { pEq, qEq };
|
||||
}, []);
|
||||
|
||||
const datosCurvas = useMemo(() => {
|
||||
const puntos = [];
|
||||
const { a, b } = demandaParams;
|
||||
const { c, d } = ofertaParams;
|
||||
|
||||
for (let q = 0; q <= 100; q += 5) {
|
||||
const pDemanda = (a - q) / b;
|
||||
const pOferta = q > 0 ? (q - c) / d : 0;
|
||||
puntos.push({ q, pDemanda: Math.max(0, pDemanda), pOferta: Math.max(0, pOferta) });
|
||||
}
|
||||
|
||||
return puntos;
|
||||
}, []);
|
||||
|
||||
const excedentes = useMemo(() => {
|
||||
const { a } = demandaParams;
|
||||
const { c } = ofertaParams;
|
||||
|
||||
const qAlPrecio = Math.max(0, a - demandaParams.b * precio);
|
||||
const qOfrecida = Math.max(0, c + ofertaParams.d * precio);
|
||||
|
||||
const excedenteConsumidor = 0.5 * qAlPrecio * (a - precio);
|
||||
const excedenteProductor = 0.5 * qOfrecida * (precio - c);
|
||||
|
||||
return {
|
||||
ec: excedenteConsumidor,
|
||||
ep: excedenteProductor,
|
||||
total: excedenteConsumidor + excedenteProductor,
|
||||
qAlPrecio,
|
||||
qOfrecida,
|
||||
};
|
||||
}, [precio]);
|
||||
|
||||
const excedentesEquilibrio = useMemo(() => {
|
||||
const { pEq, qEq } = puntoEquilibrio;
|
||||
const { a } = demandaParams;
|
||||
const { c } = ofertaParams;
|
||||
|
||||
const ec = 0.5 * qEq * (a - pEq);
|
||||
const ep = 0.5 * qEq * (pEq - c);
|
||||
|
||||
return { ec, ep, total: ec + ep };
|
||||
}, [puntoEquilibrio]);
|
||||
|
||||
const maxP = 100;
|
||||
const maxQ = 100;
|
||||
const escalaX = 350 / maxQ;
|
||||
const escalaY = 180 / maxP;
|
||||
|
||||
const handleCompletar = () => {
|
||||
if (onComplete) {
|
||||
onComplete(100);
|
||||
}
|
||||
return 100;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Visualizador de Excedentes"
|
||||
subtitle="Ajusta el precio para ver cómo cambian los excedentes del consumidor y productor"
|
||||
/>
|
||||
|
||||
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Precio de Mercado (P)
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
type="range"
|
||||
min="20"
|
||||
max="90"
|
||||
value={precio}
|
||||
onChange={(e) => setPrecio(parseFloat(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-2xl font-bold text-primary min-w-[80px]">${precio}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>$20</span>
|
||||
<span>Precio de equilibrio: ${puntoEquilibrio.pEq.toFixed(1)}</span>
|
||||
<span>$90</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-80 bg-gray-50 rounded-lg p-4 overflow-hidden">
|
||||
<svg className="w-full h-full" viewBox="0 0 400 220" preserveAspectRatio="xMidYMid meet">
|
||||
<line x1="50" y1="200" x2="380" y2="200" stroke="#374151" strokeWidth="2" />
|
||||
<line x1="50" y1="200" x2="50" y2="20" stroke="#374151" strokeWidth="2" />
|
||||
|
||||
<text x="215" y="215" textAnchor="middle" className="text-sm fill-gray-700 font-medium">Cantidad (Q)</text>
|
||||
<text x="15" y="110" textAnchor="middle" className="text-sm fill-gray-700 font-medium" transform="rotate(-90 15 110)">Precio (P)</text>
|
||||
|
||||
{[0, 25, 50, 75, 100].map((q) => (
|
||||
<g key={q}>
|
||||
<line
|
||||
x1={50 + q * escalaX}
|
||||
y1="200"
|
||||
x2={50 + q * escalaX}
|
||||
y2="205"
|
||||
stroke="#374151"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={50 + q * escalaX}
|
||||
y="215"
|
||||
textAnchor="middle"
|
||||
className="text-xs fill-gray-500"
|
||||
>
|
||||
{q}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{[0, 25, 50, 75, 100].map((p) => (
|
||||
<g key={p}>
|
||||
<line
|
||||
x1="45"
|
||||
y1={200 - p * escalaY}
|
||||
x2="50"
|
||||
y2={200 - p * escalaY}
|
||||
stroke="#374151"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x="40"
|
||||
y={200 - p * escalaY + 4}
|
||||
textAnchor="end"
|
||||
className="text-xs fill-gray-500"
|
||||
>
|
||||
{p}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
<line
|
||||
x1="50"
|
||||
y1={200 - precio * escalaY}
|
||||
x2="380"
|
||||
y2={200 - precio * escalaY}
|
||||
stroke="#7c3aed"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5,5"
|
||||
opacity="0.7"
|
||||
/>
|
||||
<text x="385" y={200 - precio * escalaY + 4} className="text-xs fill-purple-600 font-medium">
|
||||
P = {precio}
|
||||
</text>
|
||||
|
||||
{precio > puntoEquilibrio.pEq && (
|
||||
<polygon
|
||||
points={`
|
||||
50,${200 - precio * escalaY}
|
||||
${50 + excedentes.qAlPrecio * escalaX},${200 - precio * escalaY}
|
||||
${50 + excedentes.qAlPrecio * escalaX},${200 - ((100 - excedentes.qAlPrecio)) * escalaY}
|
||||
`}
|
||||
fill="rgba(37, 99, 235, 0.3)"
|
||||
stroke="#2563eb"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{precio < puntoEquilibrio.pEq && (
|
||||
<polygon
|
||||
points={`
|
||||
50,${200 - 12.5 * escalaY}
|
||||
${50 + excedentes.qOfrecida * escalaX},${200 - precio * escalaY}
|
||||
50,${200 - precio * escalaY}
|
||||
`}
|
||||
fill="rgba(22, 163, 74, 0.3)"
|
||||
stroke="#16a34a"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{Math.abs(precio - puntoEquilibrio.pEq) < 2 && (
|
||||
<>
|
||||
<polygon
|
||||
points={`
|
||||
50,${200 - puntoEquilibrio.pEq * escalaY}
|
||||
${50 + puntoEquilibrio.qEq * escalaX},${200 - puntoEquilibrio.pEq * escalaY}
|
||||
${50 + puntoEquilibrio.qEq * escalaX},${200 - 100 * escalaY}
|
||||
`}
|
||||
fill="rgba(37, 99, 235, 0.3)"
|
||||
stroke="#2563eb"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<polygon
|
||||
points={`
|
||||
50,${200 - 12.5 * escalaY}
|
||||
${50 + puntoEquilibrio.qEq * escalaX},${200 - puntoEquilibrio.pEq * escalaY}
|
||||
50,${200 - puntoEquilibrio.pEq * escalaY}
|
||||
`}
|
||||
fill="rgba(22, 163, 74, 0.3)"
|
||||
stroke="#16a34a"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#2563eb"
|
||||
strokeWidth="3"
|
||||
points={datosCurvas.map(d => `${50 + d.q * escalaX},${200 - d.pDemanda * escalaY}`).join(' ')}
|
||||
/>
|
||||
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#16a34a"
|
||||
strokeWidth="3"
|
||||
points={datosCurvas.filter(d => d.pOferta >= 0).map(d => `${50 + d.q * escalaX},${200 - d.pOferta * escalaY}`).join(' ')}
|
||||
/>
|
||||
|
||||
<circle
|
||||
cx={50 + puntoEquilibrio.qEq * escalaX}
|
||||
cy={200 - puntoEquilibrio.pEq * escalaY}
|
||||
r="6"
|
||||
fill="#dc2626"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
x={50 + puntoEquilibrio.qEq * escalaX}
|
||||
y={200 - puntoEquilibrio.pEq * escalaY - 12}
|
||||
textAnchor="middle"
|
||||
className="text-xs fill-red-600 font-bold"
|
||||
>
|
||||
E
|
||||
</text>
|
||||
|
||||
<g transform="translate(320, 40)">
|
||||
<line x1="0" y1="0" x2="25" y2="0" stroke="#2563eb" strokeWidth="3" />
|
||||
<text x="30" y="5" className="text-xs fill-gray-700">Demanda</text>
|
||||
<line x1="0" y1="15" x2="25" y2="15" stroke="#16a34a" strokeWidth="3" />
|
||||
<text x="30" y="20" className="text-xs fill-gray-700">Oferta</text>
|
||||
<rect x="0" y="30" width="15" height="15" fill="rgba(37, 99, 235, 0.3)" stroke="#2563eb" />
|
||||
<text x="20" y="42" className="text-xs fill-gray-700">EC</text>
|
||||
<rect x="0" y="50" width="15" height="15" fill="rgba(22, 163, 74, 0.3)" stroke="#16a34a" />
|
||||
<text x="20" y="62" className="text-xs fill-gray-700">EP</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-4 h-4 bg-blue-500/30 border border-blue-500 rounded" />
|
||||
<h4 className="font-semibold text-blue-900">Excedente del Consumidor</h4>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-blue-700">${excedentes.ec.toFixed(0)}</p>
|
||||
<p className="text-sm text-blue-600 mt-1">
|
||||
Área bajo la curva de demanda y sobre el precio
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-green-50 border-green-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-4 h-4 bg-green-500/30 border border-green-500 rounded" />
|
||||
<h4 className="font-semibold text-green-900">Excedente del Productor</h4>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-green-700">${excedentes.ep.toFixed(0)}</p>
|
||||
<p className="text-sm text-green-600 mt-1">
|
||||
Área sobre la curva de oferta y bajo el precio
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-purple-50 border-purple-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="w-5 h-5 text-purple-600" />
|
||||
<h4 className="font-semibold text-purple-900">Excedente Total</h4>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-purple-700">${excedentes.total.toFixed(0)}</p>
|
||||
<p className="text-sm text-purple-600 mt-1">
|
||||
EC + EP = Bienestar social total
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="bg-yellow-50 border-yellow-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-yellow-900 mb-2">En el Equilibrio de Mercado:</h4>
|
||||
<div className="grid md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-yellow-800">Precio: </span>
|
||||
<span className="font-bold">${puntoEquilibrio.pEq.toFixed(1)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-yellow-800">Cantidad: </span>
|
||||
<span className="font-bold">{puntoEquilibrio.qEq.toFixed(1)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-yellow-800">Excedente Total: </span>
|
||||
<span className="font-bold">${excedentesEquilibrio.total.toFixed(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-yellow-800 text-sm">
|
||||
El equilibrio de mercado maximiza el bienestar social (excedente total).
|
||||
Cualquier desviación del precio de equilibrio genera pérdida de eficiencia.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleCompletar} size="lg">
|
||||
<CheckCircle className="w-5 h-5 mr-2" />
|
||||
Marcar como Completado
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VisualizadorExcedentes;
|
||||
3
frontend/src/components/exercises/modulo4/index.ts
Normal file
3
frontend/src/components/exercises/modulo4/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CalculadoraCostos } from './CalculadoraCostos';
|
||||
export { SimuladorProduccion } from './SimuladorProduccion';
|
||||
export { VisualizadorExcedentes } from './VisualizadorExcedentes';
|
||||
Reference in New Issue
Block a user