Initial commit: Plataforma de Economía
Features: - React 18 + TypeScript frontend with Vite - Go + Gin backend API - PostgreSQL database - JWT authentication with refresh tokens - User management (admin panel) - Docker containerization - Progress tracking system - 4 economic modules structure Fixed: - Login with username or email - User creation without required email - Database nullable timestamps - API response field naming
This commit is contained in:
28
frontend/Dockerfile
Normal file
28
frontend/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
|
||||
# Final stage - nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Plataforma de Economía</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
40
frontend/nginx.conf
Normal file
40
frontend/nginx.conf
Normal file
@@ -0,0 +1,40 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Handle SPA routing - try files first, then fall back to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "ok";
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://econ-backend:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
3010
frontend/package-lock.json
generated
Normal file
3010
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "econ-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
70
frontend/src/App.tsx
Normal file
70
frontend/src/App.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { Login } from './pages/Login';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { Modulos } from './pages/Modulos';
|
||||
import { Modulo } from './pages/Modulo';
|
||||
import { AdminPanel } from './pages/admin/AdminPanel';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/modulos"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Modulos />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/modulo/:numero"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Modulo />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminPanel />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
152
frontend/src/components/admin/UserForm.tsx
Normal file
152
frontend/src/components/admin/UserForm.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState } from 'react';
|
||||
import { usuarioService } from '../../services/api';
|
||||
import type { Usuario } from '../../types';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Input } from '../ui/Input';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface UserFormProps {
|
||||
usuario: Usuario | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function UserForm({ usuario, onClose }: UserFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
nombre: usuario?.nombre || '',
|
||||
username: usuario?.username || '',
|
||||
email: usuario?.email || '',
|
||||
password: '',
|
||||
rol: usuario?.rol || 'estudiante' as 'admin' | 'estudiante',
|
||||
activo: usuario?.activo ?? true,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Validaciones
|
||||
if (!formData.username.trim()) {
|
||||
setError('El nombre de usuario es requerido');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.nombre.trim()) {
|
||||
setError('El nombre completo es requerido');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (usuario) {
|
||||
await usuarioService.updateUsuario(usuario.id, formData);
|
||||
} else {
|
||||
if (!formData.password) {
|
||||
setError('La contraseña es requerida para nuevos usuarios');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
await usuarioService.createUsuario({
|
||||
...formData,
|
||||
password: formData.password,
|
||||
} as Omit<Usuario, 'id'> & { password: string });
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError('Error al guardar el usuario');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{usuario ? 'Editar usuario' : 'Nuevo usuario'}
|
||||
</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Nombre de usuario *"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
placeholder="usuario123"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Nombre completo *"
|
||||
value={formData.nombre}
|
||||
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
|
||||
placeholder="Nombre completo"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Email (opcional)"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder="email@ejemplo.com"
|
||||
/>
|
||||
|
||||
{!usuario && (
|
||||
<Input
|
||||
label="Contraseña"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rol</label>
|
||||
<select
|
||||
value={formData.rol}
|
||||
onChange={(e) => setFormData({ ...formData, rol: e.target.value as 'admin' | 'estudiante' })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="estudiante">Estudiante</option>
|
||||
<option value="admin">Administrador</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="activo"
|
||||
checked={formData.activo}
|
||||
onChange={(e) => setFormData({ ...formData, activo: e.target.checked })}
|
||||
className="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary"
|
||||
/>
|
||||
<label htmlFor="activo" className="text-sm text-gray-700">
|
||||
Usuario activo
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="submit" isLoading={loading} className="flex-1">
|
||||
{usuario ? 'Guardar cambios' : 'Crear usuario'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
179
frontend/src/components/admin/UserList.tsx
Normal file
179
frontend/src/components/admin/UserList.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usuarioService } from '../../services/api';
|
||||
import type { Usuario } from '../../types';
|
||||
import { Card, CardHeader } from '../ui/Card';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Users, UserPlus, Edit, Trash2, Search } from 'lucide-react';
|
||||
import { UserForm } from './UserForm';
|
||||
|
||||
export function UserList() {
|
||||
const [usuarios, setUsuarios] = useState<Usuario[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<Usuario | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsuarios();
|
||||
}, []);
|
||||
|
||||
const loadUsuarios = async () => {
|
||||
try {
|
||||
const data = await usuarioService.getUsuarios();
|
||||
setUsuarios(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading usuarios:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('¿Estás seguro de que deseas eliminar este usuario?')) return;
|
||||
|
||||
try {
|
||||
await usuarioService.deleteUsuario(id);
|
||||
setUsuarios(usuarios.filter((u) => u.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Error deleting usuario:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (usuario: Usuario) => {
|
||||
setEditingUser(usuario);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleFormClose = () => {
|
||||
setShowForm(false);
|
||||
setEditingUser(null);
|
||||
loadUsuarios();
|
||||
};
|
||||
|
||||
const filteredUsuarios = usuarios.filter(
|
||||
(u) =>
|
||||
u.nombre.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Usuarios"
|
||||
subtitle={`${usuarios.length} usuarios registrados`}
|
||||
action={
|
||||
<Button size="sm" onClick={() => setShowForm(true)}>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Nuevo usuario
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuarios..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<UserForm
|
||||
usuario={editingUser}
|
||||
onClose={handleFormClose}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Nombre</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Email</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Rol</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-500">Estado</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-500">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsuarios.map((usuario) => (
|
||||
<tr key={usuario.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-primary rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||
{usuario.nombre.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="ml-3 font-medium text-gray-900">{usuario.nombre}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-600">{usuario.email}</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
usuario.rol === 'admin'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{usuario.rol === 'admin' ? 'Admin' : 'Estudiante'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
usuario.activo
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{usuario.activo ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(usuario)}
|
||||
className="mr-2"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(usuario.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredUsuarios.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Users className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p>No se encontraron usuarios</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
73
frontend/src/components/auth/LoginForm.tsx
Normal file
73
frontend/src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Input } from '../ui/Input';
|
||||
import { Mail, Lock, LogIn } from 'lucide-react';
|
||||
|
||||
export function LoginForm() {
|
||||
const navigate = useNavigate();
|
||||
const { login, isLoading, error, clearError } = useAuthStore();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [validationError, setValidationError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
clearError();
|
||||
setValidationError('');
|
||||
|
||||
if (!email || !password) {
|
||||
setValidationError('Por favor, completa todos los campos');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Determinar si es email o username
|
||||
const isEmail = email.includes('@');
|
||||
if (isEmail) {
|
||||
await login({ email, password });
|
||||
} else {
|
||||
await login({ username: email, password });
|
||||
}
|
||||
navigate('/');
|
||||
} catch {
|
||||
// Error handled by store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{(error || validationError) && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
||||
{error || validationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
label="Usuario o correo electrónico"
|
||||
placeholder="usuario o tu@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
icon={<Mail className="w-5 h-5 text-gray-400" />}
|
||||
autoComplete="username"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label="Contraseña"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
icon={<Lock className="w-5 h-5 text-gray-400" />}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||
<LogIn className="w-5 h-5 mr-2" />
|
||||
Iniciar sesión
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
45
frontend/src/components/ui/Button.tsx
Normal file
45
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className = '', variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
|
||||
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-primary text-white hover:bg-blue-700 focus:ring-blue-500',
|
||||
secondary: 'bg-secondary text-white hover:bg-violet-700 focus:ring-violet-500',
|
||||
outline: 'border-2 border-primary text-primary hover:bg-primary hover:text-white focus:ring-primary',
|
||||
ghost: 'text-gray-600 hover:bg-gray-100 focus:ring-gray-500',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-base',
|
||||
lg: 'px-6 py-3 text-lg',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && (
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
32
frontend/src/components/ui/Card.tsx
Normal file
32
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({ children, className = '' }: CardProps) {
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-md p-6 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function CardHeader({ title, subtitle, action }: CardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
{subtitle && <p className="text-sm text-gray-500">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/ui/Input.tsx
Normal file
48
frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { InputHTMLAttributes, forwardRef, ReactNode } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className = '', label, error, icon, id, ...props }, ref) => {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{icon && (
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`
|
||||
w-full px-4 py-2 border rounded-lg transition-colors
|
||||
focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
disabled:bg-gray-100 disabled:cursor-not-allowed
|
||||
${error ? 'border-error focus:ring-error' : 'border-gray-300'}
|
||||
${icon ? 'pl-10' : ''}
|
||||
${className}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-error">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
54
frontend/src/index.css
Normal file
54
frontend/src/index.css
Normal file
@@ -0,0 +1,54 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-primary {
|
||||
@apply text-blue-600;
|
||||
}
|
||||
.bg-primary {
|
||||
@apply bg-blue-600;
|
||||
}
|
||||
.hover\:bg-primary:hover {
|
||||
@apply bg-blue-700;
|
||||
}
|
||||
.focus\:ring-primary:focus {
|
||||
--tw-ring-color: rgb(37 99 235);
|
||||
}
|
||||
.bg-success {
|
||||
@apply bg-emerald-500;
|
||||
}
|
||||
.text-success {
|
||||
@apply text-emerald-500;
|
||||
}
|
||||
.bg-secondary {
|
||||
@apply bg-violet-600;
|
||||
}
|
||||
.text-secondary {
|
||||
@apply text-violet-600;
|
||||
}
|
||||
.bg-warning {
|
||||
@apply bg-amber-500;
|
||||
}
|
||||
.text-warning {
|
||||
@apply text-amber-500;
|
||||
}
|
||||
.bg-error {
|
||||
@apply bg-red-500;
|
||||
}
|
||||
.text-error {
|
||||
@apply text-red-500;
|
||||
}
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
176
frontend/src/pages/Dashboard.tsx
Normal file
176
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { progresoService } from '../services/api';
|
||||
import type { ModuloProgreso } from '../types';
|
||||
import { BookOpen, TrendingUp, User, LogOut, LayoutGrid } from 'lucide-react';
|
||||
|
||||
const MODULOS_DEFAULT = [
|
||||
{ numero: 1, titulo: 'Fundamentos de Economía', descripcion: 'Introducción a los conceptos básicos' },
|
||||
{ numero: 2, titulo: 'Oferta, Demanda y Equilibrio', descripcion: 'Curvas de mercado' },
|
||||
{ numero: 3, titulo: 'Utilidad y Elasticidad', descripcion: 'Teoría del consumidor' },
|
||||
{ numero: 4, titulo: 'Teoría del Productor', descripcion: 'Costos y producción' },
|
||||
];
|
||||
|
||||
export function Dashboard() {
|
||||
const { usuario, logout } = useAuthStore();
|
||||
const [modulosProgreso, setModulosProgreso] = useState<ModuloProgreso[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadProgreso();
|
||||
}, []);
|
||||
|
||||
const loadProgreso = async () => {
|
||||
try {
|
||||
const progresos = await progresoService.getProgreso();
|
||||
const modulos = MODULOS_DEFAULT.map((mod) => {
|
||||
const modProgresos = progresos.filter((p) => p.modulo_numero === mod.numero);
|
||||
const completados = modProgresos.filter((p) => p.completado).length;
|
||||
const total = 5; // Asumiendo 5 ejercicios por módulo
|
||||
return {
|
||||
numero: mod.numero,
|
||||
titulo: mod.titulo,
|
||||
porcentaje: Math.round((completados / total) * 100),
|
||||
ejerciciosCompletados: completados,
|
||||
totalEjercicios: total,
|
||||
};
|
||||
});
|
||||
setModulosProgreso(modulos);
|
||||
} catch {
|
||||
// Si hay error, mostrar progreso vacío
|
||||
setModulosProgreso(
|
||||
MODULOS_DEFAULT.map((mod) => ({
|
||||
numero: mod.numero,
|
||||
titulo: mod.titulo,
|
||||
porcentaje: 0,
|
||||
ejerciciosCompletados: 0,
|
||||
totalEjercicios: 5,
|
||||
}))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
const totalProgreso = Math.round(
|
||||
modulosProgreso.reduce((acc, mod) => acc + mod.porcentaje, 0) / modulosProgreso.length
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-primary rounded-lg flex items-center justify-center">
|
||||
<BookOpen className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900">Economía</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<User className="w-5 h-5" />
|
||||
<span className="font-medium">{usuario?.nombre}</span>
|
||||
{usuario?.rol === 'admin' && (
|
||||
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Tu progreso</h2>
|
||||
<p className="text-gray-600">Continúa donde lo dejaste</p>
|
||||
</div>
|
||||
|
||||
<Card className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Progreso total</h3>
|
||||
<p className="text-sm text-gray-500">{totalProgreso}% completado</p>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-primary">{totalProgreso}%</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-primary h-3 rounded-full transition-all duration-500"
|
||||
style={{ width: `${totalProgreso}%` }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Módulos</h2>
|
||||
{usuario?.rol === 'admin' && (
|
||||
<Link to="/admin">
|
||||
<Button variant="outline" size="sm">
|
||||
Panel de Admin
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{modulosProgreso.map((modulo) => (
|
||||
<Link key={modulo.numero} to={`/modulo/${modulo.numero}`}>
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-primary font-bold">{modulo.numero}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{modulo.titulo}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{modulo.ejerciciosCompletados}/{modulo.totalEjercicios} ejercicios
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
modulo.porcentaje === 100 ? 'bg-success' : 'bg-primary'
|
||||
}`}
|
||||
style={{ width: `${modulo.porcentaje}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">{modulo.porcentaje}% completado</span>
|
||||
{modulo.porcentaje === 100 && (
|
||||
<span className="text-success flex items-center gap-1">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Completado
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<Link to="/modulos">
|
||||
<Button variant="outline" size="lg">
|
||||
<LayoutGrid className="w-5 h-5 mr-2" />
|
||||
Ver todos los módulos
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
frontend/src/pages/Login.tsx
Normal file
34
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { LoginForm } from '../components/auth/LoginForm';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
|
||||
export function Login() {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-2xl mb-4">
|
||||
<BookOpen className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Plataforma de Economía</h1>
|
||||
<p className="text-gray-600 mt-2">Inicia sesión para continuar</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||
<LoginForm />
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
Sistema de aprendizaje interactivo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
frontend/src/pages/Modulo.tsx
Normal file
151
frontend/src/pages/Modulo.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { progresoService } from '../services/api';
|
||||
import type { Progreso } from '../types';
|
||||
import { ArrowLeft, CheckCircle, Play } from 'lucide-react';
|
||||
|
||||
const MODULOS_INFO: Record<number, { titulo: string; descripcion: string }> = {
|
||||
1: { titulo: 'Fundamentos de Economía', descripcion: 'Introducción a los conceptos básicos de economía' },
|
||||
2: { titulo: 'Oferta, Demanda y Equilibrio', descripcion: 'Curvas de oferta y demanda en el mercado' },
|
||||
3: { titulo: 'Utilidad y Elasticidad', descripcion: 'Teoría del consumidor y elasticidades' },
|
||||
4: { titulo: 'Teoría del Productor', descripcion: 'Costos de producción y competencia perfecta' },
|
||||
};
|
||||
|
||||
const EJERCICIOS_MOCK = [
|
||||
{ id: 'e1', titulo: 'Conceptos básicos', descripcion: 'Repasa los fundamentos de la economía' },
|
||||
{ id: 'e2', titulo: 'Agentes económicos', descripcion: 'Identifica los diferentes agentes en la economía' },
|
||||
{ id: 'e3', titulo: 'Factores de producción', descripcion: 'Aprende sobre tierra, trabajo y capital' },
|
||||
{ id: 'e4', titulo: 'Flujo circular', descripcion: 'Comprende el flujo de bienes y dinero' },
|
||||
{ id: 'e5', titulo: 'Evaluación final', descripcion: 'Pon a prueba todo lo aprendido' },
|
||||
];
|
||||
|
||||
export function Modulo() {
|
||||
const { numero } = useParams<{ numero: string }>();
|
||||
const num = parseInt(numero || '1', 10);
|
||||
const [progresos, setProgresos] = useState<Progreso[]>([]);
|
||||
|
||||
const moduloInfo = MODULOS_INFO[num] || MODULOS_INFO[1];
|
||||
const ejercicios = EJERCICIOS_MOCK;
|
||||
|
||||
useEffect(() => {
|
||||
loadProgreso();
|
||||
}, [num]);
|
||||
|
||||
const loadProgreso = async () => {
|
||||
try {
|
||||
const data = await progresoService.getProgreso();
|
||||
setProgresos(data);
|
||||
} catch {
|
||||
// Silencio
|
||||
}
|
||||
};
|
||||
|
||||
const getProgresoForEjercicio = (ejercicioId: string) => {
|
||||
return progresos.find(
|
||||
(p) => p.modulo_numero === num && p.ejercicio_id === ejercicioId
|
||||
);
|
||||
};
|
||||
|
||||
const completados = ejercicios.filter(
|
||||
(e) => getProgresoForEjercicio(e.id)?.completado
|
||||
).length;
|
||||
const porcentaje = Math.round((completados / ejercicios.length) * 100);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<Link to="/" className="inline-flex items-center text-primary hover:underline">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Volver al Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-primary to-blue-600 rounded-xl flex items-center justify-center text-white text-2xl font-bold shadow-lg">
|
||||
{num}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{moduloInfo.titulo}</h1>
|
||||
<p className="text-gray-600">{moduloInfo.descripcion}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gradient-to-r from-primary to-blue-600 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-blue-100">Tu progreso en este módulo</p>
|
||||
<p className="text-3xl font-bold mt-1">{porcentaje}%</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-blue-100">{completados}/{ejercicios.length} ejercicios</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 w-full bg-white/20 rounded-full h-2">
|
||||
<div
|
||||
className="bg-white h-2 rounded-full transition-all"
|
||||
style={{ width: `${porcentaje}%` }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Ejercicios</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{ejercicios.map((ejercicio, index) => {
|
||||
const progreso = getProgresoForEjercicio(ejercicio.id);
|
||||
const completado = progreso?.completado || false;
|
||||
|
||||
return (
|
||||
<Card key={ejercicio.id} className="hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
completado ? 'bg-success text-white' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{completado ? (
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
) : (
|
||||
<span className="font-medium">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900">{ejercicio.titulo}</h3>
|
||||
<p className="text-sm text-gray-500">{ejercicio.descripcion}</p>
|
||||
</div>
|
||||
|
||||
<Button size="sm">
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{completado ? 'Repetir' : 'Comenzar'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{porcentaje === 100 && (
|
||||
<Card className="mt-6 bg-success/10 border border-success">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-success rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-success">¡Felicitaciones!</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Has completado todos los ejercicios de este módulo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
frontend/src/pages/Modulos.tsx
Normal file
95
frontend/src/pages/Modulos.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { ArrowRight, ArrowLeft } from 'lucide-react';
|
||||
|
||||
const MODULOS = [
|
||||
{
|
||||
numero: 1,
|
||||
titulo: 'Fundamentos de Economía',
|
||||
descripcion: 'Aprende los conceptos básicos: definición de economía, agentes económicos, factores de producción y el flujo circular de la economía.',
|
||||
temas: ['Definición de economía', 'Agentes económicos', 'Factores de producción', 'Flujo circular'],
|
||||
},
|
||||
{
|
||||
numero: 2,
|
||||
titulo: 'Oferta, Demanda y Equilibrio',
|
||||
descripcion: 'Domina las curvas de oferta y demanda, aprende cómo se determinan los precios y entiende los controles de mercado.',
|
||||
temas: ['Curva de demanda', 'Curva de oferta', 'Equilibrio de mercado', 'Controles de precios'],
|
||||
},
|
||||
{
|
||||
numero: 3,
|
||||
titulo: 'Utilidad y Elasticidad',
|
||||
descripcion: 'Explora la teoría del consumidor, aprende a calcular elasticidades y clasifica diferentes tipos de bienes.',
|
||||
temas: ['Utilidad marginal', 'Elasticidad precio', 'Elasticidad ingreso', 'Clasificación de bienes'],
|
||||
},
|
||||
{
|
||||
numero: 4,
|
||||
titulo: 'Teoría del Productor',
|
||||
descripcion: 'Comprende los costos de producción, la toma de decisiones del productor y los fundamentos de la competencia perfecta.',
|
||||
temas: ['Costos de producción', 'Producción y costos', 'Competencia perfecta', 'Maximización de beneficios'],
|
||||
},
|
||||
];
|
||||
|
||||
export function Modulos() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<Link to="/" className="inline-flex items-center text-primary hover:underline">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Volver al Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">Módulos Educativos</h1>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Explora los 4 módulos de economía. Cada uno contiene ejercicios interactivos
|
||||
para fortalecer tu comprensión de los conceptos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{MODULOS.map((modulo) => (
|
||||
<Card key={modulo.numero} className="hover:shadow-lg transition-shadow">
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-6">
|
||||
<div className="flex items-center gap-4 md:w-32">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-primary to-blue-600 rounded-2xl flex items-center justify-center text-white text-2xl font-bold shadow-lg">
|
||||
{modulo.numero}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">{modulo.titulo}</h2>
|
||||
<p className="text-gray-600 mb-4">{modulo.descripcion}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{modulo.temas.map((tema) => (
|
||||
<span
|
||||
key={tema}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full"
|
||||
>
|
||||
{tema}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:text-right">
|
||||
<Link to={`/modulo/${modulo.numero}`}>
|
||||
<Button>
|
||||
Entrar
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
frontend/src/pages/admin/AdminPanel.tsx
Normal file
39
frontend/src/pages/admin/AdminPanel.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { UserList } from '../../components/admin/UserList';
|
||||
import { ArrowLeft, Settings } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function AdminPanel() {
|
||||
const { usuario } = useAuthStore();
|
||||
|
||||
if (!usuario || usuario.rol !== 'admin') {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/" className="inline-flex items-center text-primary hover:underline">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Volver
|
||||
</Link>
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-secondary rounded-lg flex items-center justify-center">
|
||||
<Settings className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900">Panel de Administración</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<UserList />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
frontend/src/services/api.ts
Normal file
143
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import type { LoginRequest, LoginResponse, Usuario, Progreso, Modulo } from '../types';
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
let refreshPromise: Promise<string> | null = null;
|
||||
|
||||
const getStoredToken = () => localStorage.getItem('token');
|
||||
const getStoredRefreshToken = () => localStorage.getItem('refresh_token');
|
||||
|
||||
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = getStoredToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = getStoredRefreshToken();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token');
|
||||
}
|
||||
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshAccessToken(refreshToken);
|
||||
}
|
||||
|
||||
const newToken = await refreshPromise;
|
||||
refreshPromise = null;
|
||||
|
||||
localStorage.setItem('token', newToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
async function refreshAccessToken(refreshToken: string): Promise<string> {
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
return response.data.access_token;
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
const response = await api.post<LoginResponse>('/auth/login', credentials);
|
||||
localStorage.setItem('token', response.data.access_token);
|
||||
localStorage.setItem('refresh_token', response.data.refresh_token);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} finally {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
},
|
||||
|
||||
async getCurrentUser(): Promise<Usuario> {
|
||||
const response = await api.get<Usuario>('/auth/me');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const progresoService = {
|
||||
async getProgreso(): Promise<Progreso[]> {
|
||||
const response = await api.get<Progreso[]>('/progreso');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async saveProgreso(progreso: Progreso): Promise<Progreso> {
|
||||
const response = await api.post<Progreso>('/progreso', progreso);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getProgresoByUser(userId: string): Promise<Progreso[]> {
|
||||
const response = await api.get<Progreso[]>(`/admin/usuarios/${userId}/progreso`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const moduloService = {
|
||||
async getModulos(): Promise<Modulo[]> {
|
||||
const response = await api.get<Modulo[]>('/modulos');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getModulo(numero: number): Promise<Modulo> {
|
||||
const response = await api.get<Modulo>(`/modulos/${numero}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const usuarioService = {
|
||||
async getUsuarios(): Promise<Usuario[]> {
|
||||
const response = await api.get<Usuario[]>('/admin/usuarios');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createUsuario(usuario: Omit<Usuario, 'id'> & { password: string }): Promise<Usuario> {
|
||||
const response = await api.post<Usuario>('/admin/usuarios', usuario);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateUsuario(id: string, usuario: Partial<Usuario>): Promise<Usuario> {
|
||||
const response = await api.put<Usuario>(`/admin/usuarios/${id}`, usuario);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteUsuario(id: string): Promise<void> {
|
||||
await api.delete(`/admin/usuarios/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
72
frontend/src/stores/authStore.ts
Normal file
72
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Usuario, LoginRequest, LoginResponse } from '../types';
|
||||
import { authService } from '../services/api';
|
||||
|
||||
interface AuthState {
|
||||
usuario: Usuario | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
login: (credentials: LoginRequest) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
usuario: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
login: async (credentials: LoginRequest) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response: LoginResponse = await authService.login(credentials);
|
||||
set({
|
||||
usuario: response.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Error al iniciar sesión';
|
||||
set({ error: message, isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await authService.logout();
|
||||
} finally {
|
||||
set({ usuario: null, isAuthenticated: false });
|
||||
}
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
set({ isAuthenticated: false, usuario: null });
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const usuario = await authService.getCurrentUser();
|
||||
set({ usuario, isAuthenticated: true, isLoading: false });
|
||||
} catch {
|
||||
set({ usuario: null, isAuthenticated: false, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({ isAuthenticated: state.isAuthenticated }),
|
||||
}
|
||||
)
|
||||
);
|
||||
54
frontend/src/types/index.ts
Normal file
54
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export interface Usuario {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
nombre: string;
|
||||
rol: 'admin' | 'estudiante';
|
||||
activo: boolean;
|
||||
}
|
||||
|
||||
export interface Progreso {
|
||||
modulo_numero: number;
|
||||
ejercicio_id: string;
|
||||
completado: boolean;
|
||||
puntuacion: number;
|
||||
}
|
||||
|
||||
export interface Ejercicio {
|
||||
id: string;
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
tipo: 'quiz' | 'simulador' | 'ejercicio';
|
||||
}
|
||||
|
||||
export interface Modulo {
|
||||
numero: number;
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
ejercicios: Ejercicio[];
|
||||
}
|
||||
|
||||
export interface ModuloProgreso {
|
||||
numero: number;
|
||||
titulo: string;
|
||||
porcentaje: number;
|
||||
ejerciciosCompletados: number;
|
||||
totalEjercicios: number;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email?: string;
|
||||
username?: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
user: Usuario;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
23
frontend/tailwind.config.js
Normal file
23
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
secondary: '#7c3aed',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user