Merge pull request #28 from AlexisLopez-Dev/develop

Develop
This commit is contained in:
Mireya Cueto Garrido
2026-04-15 12:06:59 +02:00
committed by GitHub
72 changed files with 7034 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Caché de Python
__pycache__/
*.py[cod]
*$py.class
# Variables de entorno
.env
.env*
# Configuraciones del editor
.vscode/
.idea/
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
+9
View File
@@ -0,0 +1,9 @@
from motor.motor_asyncio import AsyncIOMotorClient
MONGO_URL = "mongodb://mongo:27017"
DB_NAME = "deckofcards"
client = AsyncIOMotorClient(MONGO_URL)
db = client[DB_NAME]
users_collection = db["users"]
+43
View File
@@ -0,0 +1,43 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from api.database.mongodb import db
# Routers
from api.routers.test_mongo import router as test_mongo_router
from api.routers.value_function import router as value_router
from api.routers.docmf_build import router as docmf_build_router
from api.routers.docmf_evaluate import router as docmf_eval_router
from api.routers.docmf_simple_validation import router as simple_validation_router
from api.routers.docmf_validation import router as validation_router
from api.routers.auth import router as auth_router
from api.routers.history import router as history_router
from api.routers.test_mongo import router as test_mongo_router
from api.routers.docit2mf_build import router as docit2mf_router
from api.routers.google_auth import router as google_auth_router
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(test_mongo_router, prefix="/api")
app.include_router(value_router, prefix="/api/criteria/doc")
app.include_router(docmf_build_router, prefix="/api/criteria/doc-mf")
app.include_router(docmf_eval_router, prefix="/api/criteria/doc-mf")
app.include_router(simple_validation_router, prefix="/api/criteria/doc-mf")
app.include_router(validation_router, prefix="/api/criteria/doc-mf")
app.include_router(test_mongo_router, prefix="/api")
app.include_router(auth_router, prefix="/api")
app.include_router(history_router, prefix="/api")
app.include_router(docit2mf_router, prefix="/api")
app.include_router(google_auth_router, prefix="/api")
+65
View File
@@ -0,0 +1,65 @@
from pydantic import BaseModel, Field, field_validator, ValidationInfo
from typing import List, Tuple, Union
BlankCardInput = Union[int, Tuple[int, int], List[int]]
class DoCIT2MFRequest(BaseModel):
term: str
core: tuple[float, float] = Field(..., description="Núcleo del conjunto difuso: [a, b]")
support: tuple[float, float] = Field(..., description="Soporte del conjunto difuso: [c, d]")
left_nodes_x: List[float]
left_blank_cards: List[BlankCardInput]
right_nodes_x: List[float]
right_blank_cards: List[BlankCardInput]
@field_validator("term")
def term_not_empty(cls, v):
if not v.strip():
raise ValueError("El término no puede estar vacío.")
return v
@field_validator("core")
def core_valid(cls, v):
a, b = v
if a > b:
raise ValueError("el 'Inicio del Núcleo' debe ser menor o igual al 'Fin del Núcleo'.")
return v
@field_validator("support")
def support_valid(cls, v, info: ValidationInfo):
c, d = v
if c > d:
raise ValueError("el 'Inicio del Soporte' debe ser menor o igual al 'Fin del Soporte'.")
core = info.data.get("core")
if core:
a, b = core
if not (c <= a and b <= d):
raise ValueError("los valores del 'Núcleo' deben estar estrictamente dentro del 'Soporte'.")
return v
@field_validator("left_blank_cards", "right_blank_cards")
def validate_cards(cls, v):
for item in v:
# Caso 1: entero
if isinstance(item, int):
if item < 0:
raise ValueError("Las cartas no pueden ser negativas.")
# Caso 2: lista o tupla [min,max]
elif isinstance(item, (list, tuple)):
if len(item) != 2:
raise ValueError("Los intervalos deben ser [min, max].")
lo, hi = item
if lo < 0 or hi < 0:
raise ValueError("Las cartas no pueden ser negativas.")
if lo > hi:
raise ValueError("Debe cumplirse min <= max.")
else:
raise ValueError("Formato inválido para cartas blancas.")
return v
class DoCIT2MFMultiRequest(BaseModel):
levels: List[DoCIT2MFRequest]
+48
View File
@@ -0,0 +1,48 @@
from pydantic import BaseModel, field_validator
from typing import List, Tuple
class DoCMFRequest(BaseModel):
term: str
core: Tuple[float, float]
support: Tuple[float, float]
left_nodes_x: List[float]
left_blank_cards: List[int]
right_nodes_x: List[float]
right_blank_cards: List[int]
@field_validator("term")
def term_not_empty(cls, v):
if not v.strip():
raise ValueError("El término no puede estar vacío.")
return v
@field_validator("core")
def core_valid(cls, v):
a, b = v
if a > b:
raise ValueError("El núcleo debe cumplir a <= b.")
return v
@field_validator("support")
def support_valid(cls, v, info):
c, d = v
if c >= d:
raise ValueError("El soporte debe cumplir c < d.")
core = info.data.get("core")
if core:
a, b = core
if not (c <= a <= b <= d):
raise ValueError("El núcleo debe estar dentro del soporte.")
return v
@field_validator("left_blank_cards", "right_blank_cards")
def cards_valid(cls, v):
if any(c < 0 for c in v):
raise ValueError("Las cartas no pueden ser negativas.")
return v
class DoCMFMultiRequest(BaseModel):
levels: List[DoCMFRequest]
@@ -0,0 +1,24 @@
from pydantic import BaseModel, field_validator
from typing import List, Tuple
class SimpleLevelDefinition(BaseModel):
core: Tuple[float, float]
support: Tuple[float, float]
@field_validator("core")
def validate_core(cls, v):
a, b = v
if a >= b:
raise ValueError("El núcleo debe cumplir a < b.")
return v
@field_validator("support")
def validate_support(cls, v):
c, d = v
if c >= d:
raise ValueError("El soporte debe cumplir c < d.")
return v
class SimpleValidationRequest(BaseModel):
levels: List[SimpleLevelDefinition]
@@ -0,0 +1,37 @@
from pydantic import BaseModel, field_validator
from typing import List, Tuple
class LevelDefinition(BaseModel):
core: Tuple[float, float]
support: Tuple[float, float]
left_nodes: List[Tuple[float, float]]
right_nodes: List[Tuple[float, float]]
@field_validator("core")
def validate_core(cls, v):
a, b = v
if a >= b:
raise ValueError("El núcleo debe cumplir a < b.")
return v
@field_validator("support")
def validate_support(cls, v):
c, d = v
if c >= d:
raise ValueError("El soporte debe cumplir c < d.")
return v
@field_validator("left_nodes", "right_nodes")
def validate_nodes(cls, v):
if len(v) < 2:
raise ValueError("Debe haber al menos 2 nodos.")
xs = [p[0] for p in v]
if xs != sorted(xs):
raise ValueError("Los nodos deben estar ordenados por x.")
if len(xs) != len(set(xs)):
raise ValueError("Los nodos no pueden tener valores x duplicados.")
return v
class ValidationRequest(BaseModel):
levels: List[LevelDefinition]
+35
View File
@@ -0,0 +1,35 @@
from pydantic import BaseModel, field_validator
from typing import List, Tuple
class EvaluationRequest(BaseModel):
x: float
core: Tuple[float, float]
support: Tuple[float, float]
left_nodes: List[Tuple[float, float]]
right_nodes: List[Tuple[float, float]]
@field_validator("core")
def core_valid(cls, v):
a, b = v
if a > b:
raise ValueError("El núcleo debe cumplir a <= b.")
return v
@field_validator("support")
def support_valid(cls, v, info):
c, d = v
if c >= d:
raise ValueError("El soporte debe cumplir c < d.")
core = info.data.get("core")
if core:
a, b = core
if not (c <= a < b <= d):
raise ValueError("El núcleo debe estar dentro del soporte.")
return v
@field_validator("left_nodes", "right_nodes")
def nodes_valid(cls, v):
if len(v) < 2:
raise ValueError("Debe haber al menos 2 nodos.")
return v
+54
View File
@@ -0,0 +1,54 @@
from typing import List, Optional, Union
from pydantic import BaseModel, EmailStr, Field
from datetime import datetime
# MODELOS DE FUNCIONES DIFUSAS
class FuzzyTerm(BaseModel):
term: str
core: List[float]
support: List[float]
left_nodes: List[List[float]]
right_nodes: List[List[float]]
class IT2FuzzyTerm(BaseModel):
term: str
lower: FuzzyTerm
upper: FuzzyTerm
# HISTORIAL
class HistoryItem(BaseModel):
id: Optional[str] = Field(default=None, alias="_id")
name: str
created_at: datetime
results: List[Union[FuzzyTerm, IT2FuzzyTerm]]
class HistoryCreateRequest(BaseModel):
name: str
results: List[Union[FuzzyTerm, IT2FuzzyTerm]]
# USUARIOS
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserInDB(BaseModel):
id: Optional[str] = Field(default=None, alias="_id")
username: str
email: EmailStr
password_hash: str
token: Optional[str] = None
history: List[HistoryItem] = []
@@ -0,0 +1,36 @@
from pydantic import BaseModel, field_validator
from typing import List, Dict
class ValueFunctionRequest(BaseModel):
criterion_name: str
levels: List[str]
blank_cards: List[int]
references: Dict[str, float]
@field_validator("criterion_name")
def name_not_empty(cls, v):
if not v.strip():
raise ValueError("El nombre no puede estar vacío.")
return v
@field_validator("levels")
def levels_not_empty(cls, v):
if len(v) < 2:
raise ValueError("Debe haber al menos 2 niveles.")
return v
@field_validator("blank_cards")
def cards_valid(cls, v, info):
if any(c < 0 for c in v):
raise ValueError("Las cartas no pueden ser negativas.")
levels = info.data.get("levels")
if levels and len(v) != len(levels) - 1:
raise ValueError("Debe haber uno menos de número de cartas blancas que de niveles.")
return v
@field_validator("references")
def refs_valid(cls, v):
if len(v) != 2:
raise ValueError("Debe haber 2 referencias.")
return v
+116
View File
@@ -0,0 +1,116 @@
from fastapi import APIRouter, HTTPException, status
from api.database.mongodb import users_collection
from api.models.user_models import UserCreate, UserLogin
from api.utils.security import hash_password, verify_password, generate_token
from bson import ObjectId
from fastapi import APIRouter, HTTPException, status, Depends
from api.utils.security import (
hash_password,
verify_password,
generate_token,
get_current_user,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register")
async def register_user(user: UserCreate):
existing_username = await users_collection.find_one({"username": user.username})
if existing_username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El nombre de usuario ya está en uso",
)
existing_email = await users_collection.find_one({"email": user.email})
if existing_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El email ya está registrado",
)
token = generate_token()
user_doc = {
"username": user.username,
"email": user.email,
"password_hash": hash_password(user.password),
"token": token,
"history": [],
}
result = await users_collection.insert_one(user_doc)
return {
"message": "Usuario registrado correctamente",
"user_id": str(result.inserted_id),
"token": token,
}
@router.post("/login")
async def login_user(credentials: UserLogin):
user = await users_collection.find_one({"email": credentials.email})
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales inválidas",
)
if not verify_password(credentials.password, user["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales inválidas",
)
new_token = generate_token()
await users_collection.update_one(
{"_id": user["_id"]},
{"$set": {"token": new_token}}
)
return {
"message": "Login correcto",
"user_id": str(user["_id"]),
"username": user["username"],
"token": new_token,
}
@router.post("/logout/{user_id}")
async def logout_user(user_id: str):
user = await users_collection.find_one({"_id": ObjectId(user_id)})
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Usuario no encontrado",
)
await users_collection.update_one(
{"_id": ObjectId(user_id)},
{"$set": {"token": None}}
)
return {"message": "Sesión cerrada correctamente"}
@router.post("/logout")
async def logout_user(current_user: dict = Depends(get_current_user)):
user_id = current_user["_id"]
await users_collection.update_one(
{"_id": user_id},
{"$set": {"token": None}},
)
return {"message": "Sesión cerrada correctamente"}
@router.get("/me")
async def get_me(current_user: dict = Depends(get_current_user)):
return {
"user_id": str(current_user["_id"]),
"username": current_user["username"],
"email": current_user["email"],
}
+28
View File
@@ -0,0 +1,28 @@
import logging
from fastapi import APIRouter, HTTPException, Request
from slowapi import Limiter
from slowapi.util import get_remote_address
from api.models.docit2mf_models import DoCIT2MFMultiRequest
from api.services.docit2mf_build_service import build_it2mf_from_level
router = APIRouter(prefix="/criteria", tags=["criteria"])
limiter = Limiter(key_func=get_remote_address)
logger = logging.getLogger(__name__)
@router.post("/doc-it2mf/build")
@limiter.limit("10/minute")
async def build_doc_it2mf(request: Request, body: DoCIT2MFMultiRequest):
results = []
try:
for level in body.levels:
results.append(build_it2mf_from_level(level))
except ValueError as e:
logger.warning(f"Validation error in doc-it2mf/build: {str(e)}")
raise HTTPException(status_code=400, detail="Invalid input data")
except Exception as e:
logger.error(f"Unexpected error in doc-it2mf/build: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
return {"levels": results}
+12
View File
@@ -0,0 +1,12 @@
from fastapi import APIRouter, HTTPException
from api.models.docmf_models import DoCMFMultiRequest
from api.services.docmf_build_service import build_docmf_multi
router = APIRouter()
@router.post("/build")
def build(request: DoCMFMultiRequest):
try:
return build_docmf_multi(request)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
+12
View File
@@ -0,0 +1,12 @@
from fastapi import APIRouter, HTTPException
from api.models.evaluation_models import EvaluationRequest
from api.services.docmf_evaluate_service import evaluate_docmf
router = APIRouter()
@router.post("/evaluate")
def evaluate(request: EvaluationRequest):
try:
return evaluate_docmf(request)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,36 @@
from fastapi import APIRouter, HTTPException
from api.models.docmf_simple_validation_models import SimpleValidationRequest
from api.services.docmf_simple_validation_service import validate_simple_levels
router = APIRouter()
@router.post("/validate-simple")
def validate_simple_docmf(request: SimpleValidationRequest):
results = validate_simple_levels(request.levels)
invalid = [r for r in results if not r["valid"]]
if len(request.levels) == 1:
if invalid:
raise HTTPException(
status_code=400,
detail={
"message": "El nivel es incorrecto.",
"errors": invalid[0]["errors"]
}
)
return {
"message": "El nivel es correcto.",
"details": results[0]
}
if invalid:
return {
"message": "Validación completada.",
"valid_levels": [r for r in results if r["valid"]],
"invalid_levels": invalid
}
return {
"message": "Todos los niveles son correctos.",
"results": results
}
+39
View File
@@ -0,0 +1,39 @@
from fastapi import APIRouter, HTTPException
from api.models.docmf_validation_models import ValidationRequest
from api.services.docmf_validation_service import validate_levels
router = APIRouter()
@router.post("/validate")
def validate_docmf_levels(request: ValidationRequest):
results = validate_levels(request.levels)
invalid = [r for r in results if not r["valid"]]
if len(request.levels) == 1:
# Caso de un solo nivel
if invalid:
raise HTTPException(
status_code=400,
detail={
"message": "El nivel es incorrecto.",
"errors": invalid[0]["errors"]
}
)
return {
"message": "El nivel es correcto.",
"details": results[0]
}
# Caso de varios niveles
if invalid:
return {
"message": "Validación completada.",
"valid_levels": [r for r in results if r["valid"]],
"invalid_levels": invalid
}
return {
"message": "Todos los niveles son correctos.",
"results": results
}
+100
View File
@@ -0,0 +1,100 @@
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from datetime import datetime, timedelta
import httpx
import os
import jwt
from api.database.mongodb import users_collection
router = APIRouter(prefix="/auth/google", tags=["auth"])
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI")
SECRET_KEY = os.getenv("SECRET_KEY")
@router.get("/login")
async def google_login():
google_auth_url = (
"https://accounts.google.com/o/oauth2/auth"
"?response_type=code"
f"&client_id={GOOGLE_CLIENT_ID}"
f"&redirect_uri={REDIRECT_URI}"
"&scope=openid%20email%20profile"
"&access_type=offline"
"&prompt=consent"
)
return RedirectResponse(google_auth_url)
@router.get("/callback")
async def google_callback(request: Request):
code = request.query_params.get("code")
if not code:
raise HTTPException(status_code=400, detail="Missing code parameter")
token_url = "https://oauth2.googleapis.com/token"
data = {
"code": code,
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"redirect_uri": REDIRECT_URI,
"grant_type": "authorization_code",
}
async with httpx.AsyncClient() as client:
token_response = await client.post(token_url, data=data)
token_json = token_response.json()
if "access_token" not in token_json:
raise HTTPException(status_code=400, detail=token_json)
access_token = token_json["access_token"]
async with httpx.AsyncClient() as client:
userinfo = await client.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"}
)
user_data = userinfo.json()
google_id = user_data["id"]
email = user_data["email"]
name = user_data.get("name", "Usuario")
user = await users_collection.find_one({"email": email})
if not user:
new_user = {
"username": name,
"email": email,
"password_hash": None,
"google_id": google_id,
"history": [],
}
result = await users_collection.insert_one(new_user)
user_id = result.inserted_id
else:
user_id = user["_id"]
token = jwt.encode(
{
"sub": str(user_id),
"email": email,
"name": name,
"exp": datetime.utcnow() + timedelta(hours=24)
},
SECRET_KEY,
algorithm="HS256"
)
await users_collection.update_one(
{"_id": user_id},
{"$set": {"token": token}}
)
return RedirectResponse(f"http://localhost:5173/login?token={token}")
+119
View File
@@ -0,0 +1,119 @@
# api/routers/history.py
from fastapi import APIRouter, HTTPException, status, Depends
from datetime import datetime
from bson import ObjectId
from api.database.mongodb import users_collection
from api.models.user_models import FuzzyTerm, IT2FuzzyTerm, HistoryCreateRequest
from api.utils.security import get_current_user
router = APIRouter(prefix="/history", tags=["history"])
@router.post("/add")
async def add_history_item(
data: HistoryCreateRequest,
current_user: dict = Depends(get_current_user),
):
user_id = current_user["_id"]
history_item_id = ObjectId()
history_item = {
"_id": history_item_id,
"name": data.name,
"created_at": datetime.utcnow(),
"results": [r.dict() for r in data.results],
}
await users_collection.update_one(
{"_id": user_id},
{"$push": {"history": history_item}},
)
return {
"message": "Elemento añadido al historial",
"history_item_id": str(history_item_id),
}
@router.delete("/delete/{history_item_id}")
async def delete_history_item(
history_item_id: str,
current_user: dict = Depends(get_current_user),
):
user_id = current_user["_id"]
result = await users_collection.update_one(
{"_id": user_id},
{"$pull": {"history": {"_id": ObjectId(history_item_id)}}},
)
if result.modified_count == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Elemento de historial no encontrado",
)
return {"message": "Elemento eliminado del historial"}
@router.get("/all")
async def get_all_histories():
users = await users_collection.find().to_list(None)
all_histories = []
for user in users:
user_id = str(user["_id"])
username = user.get("username", "unknown")
history = user.get("history", [])
for item in history:
all_histories.append({
"user_id": user_id,
"username": username,
"history_item": {
"_id": str(item["_id"]),
"name": item["name"],
"created_at": item["created_at"],
"results": item["results"]
}
})
all_histories_sorted = sorted(
all_histories,
key=lambda h: h["history_item"]["created_at"]
)
return {"count": len(all_histories_sorted), "histories": all_histories_sorted}
@router.get("/user")
async def get_user_histories(current_user: dict = Depends(get_current_user)):
user_id = current_user["_id"]
user = await users_collection.find_one({"_id": user_id})
if not user:
raise HTTPException(
status_code=404,
detail="Usuario no encontrado."
)
history = user.get("history", [])
history_sorted = sorted(history, key=lambda h: h["created_at"])
formatted_history = []
for item in history_sorted:
formatted_history.append({
"_id": str(item["_id"]),
"name": item["name"],
"created_at": item["created_at"],
"results": item["results"]
})
return {
"user_id": str(user_id),
"username": user.get("username", "unknown"),
"count": len(formatted_history),
"history": formatted_history
}
+12
View File
@@ -0,0 +1,12 @@
from fastapi import APIRouter
from api.database.mongodb import db
router = APIRouter()
@router.get("/test-mongo")
async def test_mongo():
try:
await db.command("ping")
return {"status": "ok", "message": "Conexión a MongoDB correcta"}
except Exception as e:
return {"status": "error", "message": str(e)}
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter, HTTPException
from api.models.value_function_models import ValueFunctionRequest
from api.services.value_function_service import compute_value_function, compute_points
router = APIRouter()
@router.post("/value-function")
def value_function(request: ValueFunctionRequest):
try:
return compute_value_function(request)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/value-function/points")
def value_function_points(request: ValueFunctionRequest):
try:
return compute_points(request)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,103 @@
from typing import List, Union
from api.models.docit2mf_models import DoCIT2MFRequest
from api.models.docmf_models import DoCMFRequest
from api.services.docmf_build_service import build_doc_mf_level
def _extract_bounds(values: List[Union[int, List[int], tuple]], mode: str) -> List[int]:
"""
Devuelve una lista de enteros:
- Si el valor es un entero → se usa tal cual para LMF y UMF
- Si es un intervalo [min,max] → se usa min o max según mode
"""
result = []
for item in values:
if isinstance(item, int):
result.append(item)
else:
lo, hi = item
result.append(lo if mode == "min" else hi)
return result
def _sort_nodes(nodes):
"""Ordena los nodos por su coordenada X."""
return sorted([list(p) for p in nodes], key=lambda p: p[0])
def _enforce_upper_ge_lower(lower, upper):
"""
Garantiza que la UMF (upper) nunca quede por debajo de la LMF (lower).
Ajusta los valores de pertenencia si es necesario.
"""
for i in range(len(lower["left_nodes"])):
ly = lower["left_nodes"][i][1]
uy = upper["left_nodes"][i][1]
upper["left_nodes"][i][1] = max(uy, ly)
for i in range(len(lower["right_nodes"])):
ly = lower["right_nodes"][i][1]
uy = upper["right_nodes"][i][1]
upper["right_nodes"][i][1] = max(uy, ly)
return upper
def build_it2mf_from_level(level: DoCIT2MFRequest):
"""
Construye una función IT2MF a partir de un nivel con intervalos de cartas blancas.
Devuelve:
{
"term": ...,
"lower": {...},
"upper": {...}
}
"""
left_min = _extract_bounds(level.left_blank_cards, "min")
right_min = _extract_bounds(level.right_blank_cards, "min")
lower_level = DoCMFRequest(
term=level.term,
core=level.core,
support=level.support,
left_nodes_x=level.left_nodes_x,
left_blank_cards=left_min,
right_nodes_x=level.right_nodes_x,
right_blank_cards=right_min,
)
lower = build_doc_mf_level(lower_level)
if hasattr(lower, "model_dump"):
lower = lower.model_dump()
lower["left_nodes"] = _sort_nodes(lower["left_nodes"])
lower["right_nodes"] = _sort_nodes(lower["right_nodes"])
left_max = _extract_bounds(level.left_blank_cards, "max")
right_max = _extract_bounds(level.right_blank_cards, "max")
upper_level = DoCMFRequest(
term=level.term,
core=level.core,
support=level.support,
left_nodes_x=level.left_nodes_x,
left_blank_cards=left_max,
right_nodes_x=level.right_nodes_x,
right_blank_cards=right_max,
)
upper = build_doc_mf_level(upper_level)
if hasattr(upper, "model_dump"):
upper = upper.model_dump()
upper["left_nodes"] = _sort_nodes(upper["left_nodes"])
upper["right_nodes"] = _sort_nodes(upper["right_nodes"])
upper = _enforce_upper_ge_lower(lower, upper)
return {
"term": level.term,
"lower": lower,
"upper": upper
}
@@ -0,0 +1,63 @@
from api.models.docmf_models import DoCMFRequest
from api.models.user_models import FuzzyTerm
def build_single_docmf(request: DoCMFRequest):
a, b = request.core
c, d = request.support
TL = sum(e + 1 for e in request.left_blank_cards)
YL = 1 / TL
left_nodes = []
acc = 0
for i, x in enumerate(request.left_nodes_x):
if i == 0:
left_nodes.append((x, 0.0))
else:
acc += request.left_blank_cards[i - 1] + 1
left_nodes.append((x, round(acc * YL, 4)))
TR = sum(e + 1 for e in request.right_blank_cards)
YR = 1 / TR
right_nodes = []
acc = 0
for i, x in enumerate(request.right_nodes_x):
if i == 0:
right_nodes.append((x, 1.0))
else:
acc += request.right_blank_cards[i - 1] + 1
right_nodes.append((x, round(1 - acc * YR, 4)))
return {
"term": request.term,
"core": request.core,
"support": request.support,
"left_nodes": left_nodes,
"right_nodes": right_nodes
}
def build_docmf_multi(request):
results = []
for level in request.levels:
result = build_single_docmf(level)
results.append(result)
return {"results": results}
def build_doc_mf_level(level: DoCMFRequest) -> FuzzyTerm:
"""
Adaptador para reutilizar build_single_docmf con el modelo DoCMFRequest.
Devuelve un FuzzyTerm, que es lo que espera el sistema IT2MF.
"""
result = build_single_docmf(level)
return FuzzyTerm(
term=result["term"],
core=list(result["core"]),
support=list(result["support"]),
left_nodes=[list(p) for p in result["left_nodes"]],
right_nodes=[list(p) for p in result["right_nodes"]],
)
@@ -0,0 +1,22 @@
from api.utils.interpolation import linear_interpolation
def evaluate_docmf(request):
x = request.x
a, b = request.core
c, d = request.support
if x < c or x > d:
return {"membership": 0.0, "explanation": "Fuera del soporte."}
if a <= x <= b:
return {"membership": 1.0, "explanation": "Dentro del núcleo."}
if c <= x < a:
mu = linear_interpolation(x, request.left_nodes)
return {"membership": mu, "explanation": f"El valor x={x} se interpola entre los nodos {request.left_nodes} del lado izquierdo."}
if b < x <= d:
mu = linear_interpolation(x, request.right_nodes)
return {"membership": mu, "explanation": f"El valor x={x} se interpola entre los nodos {request.right_nodes} del lado derecho."}
raise ValueError("No se pudo evaluar el valor.")
@@ -0,0 +1,24 @@
def validate_simple_level(level: dict):
errors = []
a, b = level["core"]
c, d = level["support"]
if not (c <= a < b <= d):
errors.append("El núcleo debe estar completamente dentro del soporte.")
return errors
def validate_simple_levels(levels):
results = []
for idx, level in enumerate(levels):
errors = validate_simple_level(level.dict())
results.append({
"level_index": idx,
"valid": len(errors) == 0,
"errors": errors
})
return results
@@ -0,0 +1,37 @@
def validate_single_level(level: dict):
errors = []
a, b = level["core"]
c, d = level["support"]
if not (c <= a < b <= d):
errors.append("El núcleo debe estar completamente dentro del soporte.")
left = level["left_nodes"]
right = level["right_nodes"]
if left[0][0] != c:
errors.append("El primer nodo izquierdo debe coincidir con el inicio del soporte.")
if left[-1][0] != a:
errors.append("El último nodo izquierdo debe coincidir con el inicio del núcleo.")
if right[0][0] != b:
errors.append("El primer nodo derecho debe coincidir con el final del núcleo.")
if right[-1][0] != d:
errors.append("El último nodo derecho debe coincidir con el final del soporte.")
return errors
def validate_levels(levels):
results = []
for idx, level in enumerate(levels):
errors = validate_single_level(level.dict())
results.append({
"level_index": idx,
"valid": len(errors) == 0,
"errors": errors
})
return results
@@ -0,0 +1,38 @@
def compute_value_function(request):
levels = request.levels
cards = request.blank_cards
refs = request.references
p, q = sorted(int(k) for k in refs)
up, uq = refs[str(p)], refs[str(q)]
if len(levels) < 3:
raise ValueError("Mínimo debe haber 3 niveles para esta funcionalidad.")
total_units = sum(cards[i] + 1 for i in range(p, q))
if total_units == 0:
raise ValueError("Las cartas no pueden generar 0 unidades.")
alpha = (uq - up) / total_units
values = [0] * len(levels)
values[p] = up
for i in range(p + 1, len(levels)):
units = sum(cards[r] + 1 for r in range(p, i))
values[i] = up + alpha * units
for i in range(p - 1, -1, -1):
units = sum(cards[r] + 1 for r in range(i, p))
values[i] = up - alpha * units
return {
"criterion_name": request.criterion_name,
"values": {levels[i]: round(values[i], 4) for i in range(len(levels))}
}
def compute_points(request):
result = compute_value_function(request)
values = list(result["values"].values())
return {"points": [{"x": i, "y": values[i]} for i in range(len(values))]}
+8
View File
@@ -0,0 +1,8 @@
def linear_interpolation(x, nodes):
for i in range(len(nodes) - 1):
x0, y0 = nodes[i]
x1, y1 = nodes[i+1]
if x0 <= x <= x1:
t = (x - x0) / (x1 - x0)
return y0 + t * (y1 - y0)
return 0.0
+46
View File
@@ -0,0 +1,46 @@
import secrets
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from api.database.mongodb import users_collection
from bson import ObjectId
import os
import jwt
SECRET_KEY = os.getenv("SECRET_KEY")
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def generate_token() -> str:
return secrets.token_hex(32)
security_scheme = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
):
token = credentials.credentials
user = await users_collection.find_one({"token": token})
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token inválido o usuario no autenticado",
)
user["id"] = str(user["_id"])
return user
def create_access_token(data: dict):
return jwt.encode(data, SECRET_KEY, algorithm="HS256")
+10
View File
@@ -0,0 +1,10 @@
fastapi
uvicorn
motor
pydantic
passlib
bcrypt==4.0.1
email-validator
slowapi
httpx
PyJWT
+35
View File
@@ -0,0 +1,35 @@
services:
backend:
build:
context: ./backend
container_name: backend
ports:
- "8000:8000"
volumes:
- ./backend:/app
depends_on:
- db
env_file:
- backend\.env
frontend:
build:
context: ./frontend
container_name: frontend
ports:
- "5173:5173"
volumes:
- ./frontend:/app
- /app/node_modules
db:
image: mongo:6
container_name: mongo
restart: always
ports:
- "27018:27017"
volumes:
- mongo_data:/data/db
volumes:
mongo_data:
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+5
View File
@@ -0,0 +1,5 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
CMD ["npm", "run", "dev", "--", "--host"]
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Deck of Cards</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+3618
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
"axios": "^1.13.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-icons": "^5.6.0",
"react-router-dom": "^7.13.2",
"recharts": "^3.8.0",
"tailwindcss": "^4.2.2"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"vite": "^8.0.1"
}
}
+1
View File
@@ -0,0 +1 @@
<svg id="Capa_1" data-name="Capa 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 511 511"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:#115dfc;}</style></defs><title>logo doc</title><rect class="cls-1" y="120.63" width="511" height="308.28"/><path class="cls-2" d="M263.31,284a22.24,22.24,0,0,0-14.47-5,22.75,22.75,0,0,0-14.7,5q-6.27,5.06-9.59,14.63t-3.34,23.73q0,13.82,3.34,23.65T234.14,361a22.5,22.5,0,0,0,14.7,5.12A22,22,0,0,0,263.31,361q6.17-5.12,9.51-14.95t3.33-23.65q0-14.14-3.33-23.73T263.31,284Z" transform="translate(0 -1)"/><path class="cls-2" d="M11.39,356q18.54,0,31.29-6t19.26-20.24q6.5-14.22,6.5-38.76,0-24.7-6.66-38.92T42,231.78q-13.17-6.09-32.51-6.09H0V356Z" transform="translate(0 -1)"/><path class="cls-2" d="M497.26,226.34a50.28,50.28,0,0,0-16.17-2.44q-16.41,0-27.87,8t-17.31,23q-5.85,15-5.85,36.16,0,21.94,6,36.81t17.31,22.43q11.3,7.56,27.39,7.56A50.14,50.14,0,0,0,497,355.38a37.14,37.14,0,0,0,12.51-7.07c.51-.44,1-.92,1.47-1.39V234.59c-.41-.39-.8-.81-1.22-1.18A36.34,36.34,0,0,0,497.26,226.34Z" transform="translate(0 -1)"/><path class="cls-2" d="M419.66,401q-26.51-14.39-41.94-42.09T362.29,291q0-40.47,15.6-68.26t42.17-42.09q26.58-14.31,59.24-14.3A141.18,141.18,0,0,1,511,169.8V97.09A96.09,96.09,0,0,0,414.91,1H96.09A96.09,96.09,0,0,0,0,97.09v72.69H13.18q37.38,0,64.69,14.63A102,102,0,0,1,120,226.18Q134.9,253.32,134.91,291q0,37.55-14.79,64.69A101.47,101.47,0,0,1,78.27,397.4q-27.06,14.53-64.11,14.54H0v4A96.09,96.09,0,0,0,96.09,512H414.91A96.09,96.09,0,0,0,511,415.91v-4.13a133.8,133.8,0,0,1-31.7,3.58Q446.15,415.36,419.66,401Zm-88.33-29.66a80.22,80.22,0,0,1-32.27,32.43q-21,11.61-50.22,11.62t-50.38-11.62A79.85,79.85,0,0,1,166,371.31q-11.31-20.81-11.3-48.76,0-27.78,11.3-48.59a79.85,79.85,0,0,1,32.42-32.43q21.14-11.61,50.38-11.62t50.22,11.62A80.22,80.22,0,0,1,331.33,274q11.28,20.81,11.29,48.59Q342.62,350.51,331.33,371.31Z" transform="translate(0 -1)"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+9
View File
@@ -0,0 +1,9 @@
import AppRouter from './routers/AppRouter';
function App() {
return (
<AppRouter />
);
}
export default App;
@@ -0,0 +1,11 @@
export default function AddLevelButton({ handleAddLevel }) {
return (
<div className="flex flex-col items-center mx-2 my-2">
<button onClick={handleAddLevel} className="w-40 h-52 border-4 border-dashed border-slate-300 rounded-2xl flex flex-col items-center justify-center gap-2 text-slate-400 font-semibold hover:bg-blue-50 hover:border-blue-400 hover:text-blue-500 transition-all active:scale-[0.98] group">
<span className="text-4xl font-light leading-none group-hover:scale-110 transition-transform">+</span>
<span className="text-xs uppercase tracking-widest font-bold text-center px-4">Añadir Carta</span>
</button>
<div className="h-6 mt-2"></div>
</div>
);
}
@@ -0,0 +1,51 @@
import React from 'react';
export default function BlankCardsCounter({ index, blankCardsCount, handleBlankCardChange }) {
const maxCardsPerRow = 7;
const rows = [];
for (let i = 0; i < blankCardsCount; i += maxCardsPerRow) {
rows.push(Array.from({ length: Math.min(maxCardsPerRow, blankCardsCount - i) }));
}
return (
<div className="flex flex-col items-center w-full">
{/* Bloque de botones */}
<div className="flex items-center gap-1 bg-white px-2 py-1.5 rounded-full shadow-sm border border-slate-200 z-10 relative shrink-0">
<button
onClick={() => handleBlankCardChange(index, -1)}
className="w-7 h-7 flex items-center justify-center rounded-full bg-slate-50 hover:bg-slate-200 text-slate-600 font-bold transition-colors"
>-</button>
<div className="flex flex-col items-center leading-none min-w-[3rem]">
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-1">Blancas</span>
<span className="text-base font-black text-blue-600 leading-none">{blankCardsCount}</span>
</div>
<button
onClick={() => handleBlankCardChange(index, 1)}
className="w-7 h-7 flex items-center justify-center rounded-full bg-slate-50 hover:bg-slate-200 text-slate-600 font-bold transition-colors"
>+</button>
</div>
{/* Cartas blancas */}
{blankCardsCount > 0 && (
<div className="flex flex-col items-center gap-y-2 w-full mt-3 relative z-0">
{rows.map((row, rowIndex) => (
<div key={rowIndex} className="flex flex-row items-center justify-center -space-x-4">
{row.map((_, colIndex) => (
<div
key={`${rowIndex}-${colIndex}`}
className="w-8 h-12 bg-white border-2 border-dashed border-slate-300 rounded shadow-sm opacity-90 transition-all hover:-translate-y-1"
style={{ zIndex: colIndex }}
></div>
))}
</div>
))}
</div>
)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
export default function CardEditor({ index, level, handleLevelChange, handleRemoveLevel, totalLevels, error }) {
return (
<div className="flex flex-col items-center mx-2 my-2">
<div className={`relative w-40 h-52 bg-white border-2 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.08)] flex flex-col items-center justify-center transition-transform hover:-translate-y-2 hover:shadow-[0_12px_40px_rgb(0,0,0,0.12)] group ${
error ? 'border-red-400 shadow-red-100' : 'border-slate-200'
}`}>
{totalLevels > 3 && (
<button onClick={() => handleRemoveLevel(index)} className="absolute -top-3 -right-3 w-8 h-8 bg-white text-slate-400 rounded-full border border-slate-200 flex items-center justify-center font-bold hover:bg-red-500 hover:text-white hover:border-red-500 transition-colors z-10 opacity-0 group-hover:opacity-100 shadow-sm" title="Eliminar carta">×</button>
)}
<span className="absolute top-3 left-4 text-sm font-black text-slate-300">{index + 1}</span>
<span className="absolute bottom-3 right-4 text-sm font-black text-slate-300 rotate-180">{index + 1}</span>
<input type="text" placeholder="Término..." value={level} onChange={(e) => handleLevelChange(index, e.target.value)} className={`w-10/12 text-center text-lg font-bold text-slate-700 bg-transparent border-b-2 border-dashed outline-none pb-1 ${error ? 'border-red-300 focus:border-red-500 placeholder:text-red-200' : 'border-slate-300 focus:border-blue-500'}`} />
</div>
<div className="h-6 mt-2">{error && <p className="text-red-500 text-xs font-semibold">Escribe un término</p>}</div>
</div>
);
}
@@ -0,0 +1,29 @@
export default function CriterionInput({ criterionName, setCriterionName, error }) {
return (
<div className="flex flex-row items-center justify-center gap-3 w-full z-30 relative mt-4">
<label className="text-sm font-bold text-slate-600 uppercase tracking-wide whitespace-nowrap">
Nombre del Criterio:
</label>
<div className="relative w-72">
<input
type="text"
placeholder="Ej: Calidad del código"
value={criterionName}
onChange={(e) => setCriterionName(e.target.value)}
className={`w-full px-4 py-1.5 rounded-lg border-2 font-bold outline-none transition-all ${
error
? 'border-red-400 focus:border-red-500 bg-red-50 text-red-700 placeholder:text-red-300'
: 'border-slate-200 focus:border-blue-400 bg-slate-50 text-slate-800'
}`}
/>
{error && (
<span className="absolute top-1/2 -right-18 -translate-y-1/2 text-red-500 text-xs font-semibold">
Obligatorio
</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
export default function ValueFunctionChart({ result }) {
if (!result) return null;
return (
<div className="w-full max-w-4xl mt-12 p-8 bg-white rounded-2xl shadow-xl border border-slate-100">
<h3 className="text-2xl font-bold text-slate-800 mb-8 text-center">
Función de Valor: {result.criterion_name}
</h3>
<div className="w-full mt-4">
<ResponsiveContainer width="100%" height={400}>
<LineChart
data={Object.entries(result.values).map(([label, value]) => ({
nombre: label,
valor: value
}))}
margin={{ top: 20, right: 30, left: 20, bottom: 20 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="nombre" tick={{ fill: '#475569', fontWeight: 600 }} dy={10} />
<YAxis domain={[0, 1]} tick={{ fill: '#475569' }} dx={-10} />
<Tooltip
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
formatter={(value) => [value.toFixed(4), 'Valor DoC']}
labelStyle={{ fontWeight: 'bold', color: '#1e293b', marginBottom: '4px' }}
/>
<Line
type="monotone"
dataKey="valor"
stroke="#2563eb"
strokeWidth={4}
activeDot={{ r: 8, strokeWidth: 0 }}
dot={{ r: 6, fill: '#2563eb', stroke: '#ffffff', strokeWidth: 2 }}
animationDuration={1500}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}
@@ -0,0 +1,111 @@
import React, { useState, useEffect, useRef } from 'react';
import CriterionInput from '../CriterionInput';
import CardEditor from '../CardEditor';
import BlankCardsCounter from '../BlankCardsCounter';
import AddLevelButton from '../AddLevelButton';
import { FiZoomIn, FiMaximize } from 'react-icons/fi';
export default function Step1BaseScale({
criterionName, handleCriterionChange,
levels, handleLevelChange, handleAddLevel, handleRemoveLevel,
blankCards, handleBlankCardChange,
errors, handleGenerateBaseScale, isLoading
}) {
const [isZoomActive, setIsZoomActive] = useState(true);
const containerRef = useRef(null);
const tableRef = useRef(null);
const [dimensions, setDimensions] = useState({ container: 1000, table: 0 });
useEffect(() => {
const updateMeasurements = () => {
if (containerRef.current && tableRef.current) {
setDimensions({
container: containerRef.current.offsetWidth,
table: tableRef.current.scrollWidth
});
}
};
const timeoutId = setTimeout(updateMeasurements, 50);
window.addEventListener('resize', updateMeasurements);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', updateMeasurements);
};
}, [levels, blankCards]);
const needsZoom = dimensions.table > dimensions.container;
const dynamicScale = needsZoom ? (dimensions.container / dimensions.table) * 0.95 : 1;
const currentScale = isZoomActive && needsZoom ? dynamicScale : 1;
return (
<div className="w-full bg-white p-6 rounded-2xl shadow-sm border border-slate-200 flex flex-col items-center animate-fade-in relative overflow-visible">
<div className="flex justify-between items-center w-full mb-4 border-b pb-3 relative z-30">
<h2 className="text-xl font-bold text-slate-800">
Paso 1: Escala de Referencia
</h2>
{needsZoom && (
<button
onClick={() => {
if (containerRef.current) containerRef.current.scrollLeft = 0;
setIsZoomActive(!isZoomActive);
}}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg font-bold transition-all shadow-sm border text-sm ${isZoomActive ? 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-white border-slate-200 text-slate-600'}`}
>
<span>{isZoomActive ? <FiZoomIn className="w-4 h-4" strokeWidth={2.5} /> : <FiMaximize className="w-4 h-4" strokeWidth={2.5}></FiMaximize> }</span>
{isZoomActive ? 'Ver de cerca' : 'Ajustar mesa'}
</button>
)}
</div>
<CriterionInput criterionName={criterionName} setCriterionName={handleCriterionChange} error={errors.criterion} />
<div ref={containerRef} className={`w-full mt-2 transition-all relative ${!isZoomActive && needsZoom ? 'overflow-x-auto flex justify-start pt-4 px-4 custom-scrollbar' : 'overflow-visible flex justify-center pt-4'}`}>
<div className={`flex flex-row items-start min-w-max transition-transform duration-500 ease-out px-4 origin-top`} style={{ transform: `scale(${currentScale})`, marginBottom: isZoomActive && currentScale < 1 ? `-${(1 - currentScale) * 300}px` : '0px' }}>
<div ref={tableRef} className="flex flex-row items-start relative px-10 overflow-visible">
{levels.map((level, index) => (
<React.Fragment key={index}>
{/* CARTA DE NIVEL */}
<div className="flex flex-col items-center mx-2 relative z-20">
<CardEditor index={index} level={level} handleLevelChange={handleLevelChange} handleRemoveLevel={handleRemoveLevel} totalLevels={levels.length} error={errors.levels[index]} canRemove={levels.length > 3} />
</div>
{/* HUECO ENTRE CARTAS Y CONTADOR */}
{index < levels.length - 1 && (
<div className="flex flex-col items-center justify-start mx-1 relative min-w-[120px]">
<div className="absolute w-[calc(100%+2rem)] h-1 bg-slate-200 top-[80px] -translate-y-1/2 z-0"></div>
<div className="mt-[60px] flex flex-col items-center relative z-10 w-full">
<BlankCardsCounter index={index} blankCardsCount={blankCards[index]} handleBlankCardChange={handleBlankCardChange} />
</div>
</div>
)}
</React.Fragment>
))}
{/* LÍNEA HACIA EL BOTÓN DE AÑADIR */}
<div className="flex flex-col items-center justify-start relative min-w-[40px]">
<div className="absolute w-full h-1 bg-slate-200 top-[80px] -translate-y-1/2 z-0 rounded"></div>
</div>
{/* BOTÓN AÑADIR NIVEL */}
<div className="flex flex-col items-center mx-2 relative z-20">
<AddLevelButton handleAddLevel={handleAddLevel} />
</div>
</div>
</div>
</div>
{/* Generar Gráfica Continua */}
<div className="w-full max-w-lg mt-6 mb-2 pt-6 border-t border-slate-200 flex flex-col items-center z-20 relative bg-white">
<button onClick={handleGenerateBaseScale} disabled={isLoading} className={`w-full py-3 text-white text-lg font-bold rounded-xl shadow-md transition-all active:scale-[0.98] ${isLoading ? 'bg-slate-400 cursor-not-allowed' : 'bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700'}`}>
{isLoading ? 'Calculando...' : 'Generar Gráfica Continua'}
</button>
</div>
</div>
);
}
@@ -0,0 +1,87 @@
import Chart from '../membershipFunction/Chart';
import Controls from '../membershipFunction/Controls';
import { CHART_COLORS } from '../../config';
export default function Step2FuzzyModeling({
baseScale,
mfDefinitions,
selectedTerm,
setSelectedTerm,
updateCurrentMf,
handleFinalSubmit,
onBack,
subscales,
onOpenSubscale,
submitError
}) {
const scaleKeys = Object.keys(baseScale);
const selectedColor = CHART_COLORS[scaleKeys.indexOf(selectedTerm) % CHART_COLORS.length] || '#2563eb';
return (
<div className="w-full bg-white p-6 rounded-2xl shadow-sm border border-slate-200 animate-fade-in relative overflow-visible">
<div className="flex justify-between items-center mb-6 border-b pb-3">
<h2 className="text-xl font-bold text-slate-800">Paso 2: Modelar Conceptos Difusos</h2>
<button onClick={onBack} className="text-slate-500 hover:text-blue-600 text-sm font-semibold underline"> Volver a las cartas</button>
</div>
<div className="flex flex-wrap justify-center gap-3 mb-6">
{scaleKeys.map((name, index) => {
const isSelected = selectedTerm === name;
const color = CHART_COLORS[index % CHART_COLORS.length];
return (
<button
key={name}
onClick={() => setSelectedTerm(name)}
style={isSelected ? { backgroundColor: color, borderColor: color, color: '#fff' } : { borderColor: color, color: '#475569' }}
className={`px-5 py-2 rounded-lg font-bold border-2 transition-all duration-300 flex flex-col items-center shadow-sm hover:shadow-md ${isSelected ? 'transform scale-105' : 'bg-white opacity-80 hover:opacity-100'}`}
>
<span>{name}</span>
<span className="text-[10px] font-normal opacity-80">(X: {baseScale[name].toFixed(2)})</span>
</button>
);
})}
</div>
<Chart
baseScale={baseScale}
mfDefinitions={mfDefinitions}
selectedTerm={selectedTerm}
colors={CHART_COLORS}
/>
{submitError && (
<div className="bg-red-50 mb-6 border-red-500 p-4 rounded-xl shadow-sm animate-fade-in mx-2">
<div className="flex items-center">
<span className="text-red-500 text-xl mr-3"></span>
<div>
<h3 className="text-sm font-bold text-red-800">Error de validación al generar la gráfica</h3>
<div className="mt-1 text-sm text-red-700 whitespace-pre-line font-medium">
{submitError}
</div>
</div>
</div>
</div>
)}
<Controls
selectedTerm={selectedTerm}
currentMf={mfDefinitions[selectedTerm]}
selectedColor={selectedColor}
baseScale={baseScale}
mfDefinitions={mfDefinitions}
updateCurrentMf={updateCurrentMf}
subscales={subscales}
onOpenSubscale={onOpenSubscale}
/>
<div className="w-full mt-8 flex justify-center">
<button onClick={handleFinalSubmit} className="px-10 py-3 bg-slate-900 text-white text-lg font-bold rounded-xl shadow-md hover:bg-slate-800 transition-colors">
Generar el Espectro Difuso
</button>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
import React, { useState, useEffect, memo } from 'react';
import { ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { useGraphData } from './finalGraph/useGraphData';
import { GraphTooltip } from './finalGraph/GraphTooltip';
const Step3FinalGraph = memo(({ data, criterionName }) => {
const { sortedResults, denseData } = useGraphData(data);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setIsReady(true);
}, 400);
return () => clearTimeout(timer);
}, []);
if (!data || (!data.levels && !data.results)) {
return <p className="text-center mt-10 text-slate-500">Cargando datos...</p>;
}
return (
<div className="w-full h-[550px] bg-white p-6 rounded-2xl shadow-sm border border-slate-200 flex flex-col final-graph-container relative">
<style>{`.final-graph-container svg * { clip-path: none !important; }`}</style>
<h3 className="text-xl font-bold mb-4 text-center text-slate-800 uppercase">
{criterionName ? `Criterio: ${criterionName}` : 'Espectro Difuso Final'}
</h3>
<div className="flex-1 w-full min-h-[400px] relative">
{!isReady && (
<div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="w-8 h-8 border-4 border-slate-200 border-t-blue-500 rounded-full animate-spin mb-3"></div>
<span className="text-sm font-semibold text-slate-400">Generando gráfica...</span>
</div>
)}
{/* Gráfica */}
<div className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${isReady ? 'opacity-100' : 'opacity-0'}`}>
{isReady && (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={denseData} margin={{ top: 15, right: 50, left: 10, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" opacity={0.5} vertical={false} />
<XAxis
dataKey="x" type="number" domain={[0, 1]} allowDataOverflow={true}
ticks={[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]}
tick={{ fill: '#475569', fontWeight: 600, fontSize: 14 }}
/>
<YAxis
domain={[0, 1]} tickCount={6} tickFormatter={(val) => Number(val.toFixed(2))}
tick={{ fill: '#475569', fontSize: 14 }}
/>
<Tooltip
content={<GraphTooltip sortedResults={sortedResults} />}
cursor={{ stroke: '#cbd5e1', strokeWidth: 1, strokeDasharray: '5 5' }}
isAnimationActive={false}
/>
{sortedResults.map((item) => (
<React.Fragment key={item.term}>
{item.isType2 ? (
<>
<Area type="linear" dataKey={`${item.term}_range`} fill={item.color} fillOpacity={0.25} stroke="none" isAnimationActive={false} />
<Line type="linear" dataKey={`${item.term}_upper`} stroke={item.color} strokeWidth={2} strokeDasharray="5 5" dot={false} isAnimationActive={false} />
<Line type="linear" dataKey={`${item.term}_lower`} stroke={item.color} strokeWidth={3} dot={false} isAnimationActive={false} />
</>
) : (
<Line type="linear" dataKey={item.term} stroke={item.color} strokeWidth={4} dot={false} isAnimationActive={false} />
)}
</React.Fragment>
))}
</ComposedChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Leyenda */}
<div className="flex flex-wrap justify-center gap-x-8 gap-y-3 mt-6 pb-2 relative z-10">
{sortedResults.map((item) => (
<div key={`legend-${item.term}`} className="flex items-center gap-2">
<span className="w-3.5 h-3.5 rounded-full shadow-sm" style={{ backgroundColor: item.color }} />
<span className="text-sm font-medium uppercase tracking-wide" style={{ color: item.color }}>{item.term}</span>
</div>
))}
</div>
</div>
);
});
export default Step3FinalGraph;
@@ -0,0 +1,193 @@
import React, { useState } from 'react';
import BlankCardsCounter from '../BlankCardsCounter';
export default function SubscaleModal({ onClose, onSave, targetInfo }) {
const initialCount = Math.max(3, targetInfo?.initialData?.cardsCount || 3);
const [cardsCount, setCardsCount] = useState(initialCount);
const [blankCards, setBlankCards] = useState(() => {
let initialBlanks = targetInfo?.initialData?.blankCards;
if (!initialBlanks || initialBlanks.length === 0) {
initialBlanks = [0, 0];
} else if (initialBlanks.length < initialCount - 1) {
const padding = Array(initialCount - 1 - initialBlanks.length).fill(0);
initialBlanks = [...initialBlanks, ...padding];
}
return initialBlanks.map(b => {
if (Array.isArray(b)) {
return { min: b[0], max: b[1], isRange: true };
}
return { min: b, max: b, isRange: false };
});
});
const handleAddCard = () => {
setCardsCount(prev => prev + 1);
setBlankCards([...blankCards, { min: 0, max: 0, isRange: false }]);
};
const handleRemoveCard = () => {
if (cardsCount <= 3) return;
setCardsCount(prev => prev - 1);
setBlankCards(blankCards.slice(0, -1));
};
const handleExactChange = (index, delta) => {
const newBlanks = [...blankCards];
const newVal = newBlanks[index].min + delta;
if (newVal >= 0) {
newBlanks[index].min = newVal;
newBlanks[index].max = newVal;
setBlankCards(newBlanks);
}
};
const handleMinChange = (index, delta) => {
const newBlanks = [...blankCards];
const newVal = newBlanks[index].min + delta;
if (newVal >= 0 && newVal <= newBlanks[index].max) {
newBlanks[index].min = newVal;
setBlankCards(newBlanks);
}
};
const handleMaxChange = (index, delta) => {
const newBlanks = [...blankCards];
const newVal = newBlanks[index].max + delta;
if (newVal >= newBlanks[index].min) {
newBlanks[index].max = newVal;
setBlankCards(newBlanks);
}
};
const toggleRangeMode = (index) => {
const newBlanks = [...blankCards];
newBlanks[index].isRange = !newBlanks[index].isRange;
if (!newBlanks[index].isRange) {
newBlanks[index].max = newBlanks[index].min;
}
setBlankCards(newBlanks);
};
const handleSave = () => {
const payloadBlanks = blankCards.map(b => b.isRange ? [b.min, b.max] : b.min);
onSave(targetInfo.term, targetInfo.side, { cardsCount, blankCards: payloadBlanks });
};
const handleDelete = () => {
onSave(targetInfo.term, targetInfo.side, null);
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm animate-fade-in py-4">
<div className="bg-white w-full max-w-6xl p-8 rounded-3xl shadow-2xl mx-4 flex flex-col max-h-[95vh]">
<div className="flex justify-between items-center mb-4 border-b pb-4 shrink-0">
<div>
<h2 className="text-2xl font-bold text-slate-800">Diseñar Subescala</h2>
<p className="text-slate-500 font-medium">
Ajustando pendiente <span className="text-blue-600 font-bold">{targetInfo.side === 'left' ? 'Izquierda (Ascendente)' : 'Derecha (Descendente)'}</span> del término <span className="text-blue-600 font-bold">"{targetInfo.term}"</span>
</p>
</div>
<button onClick={onClose} className="w-10 h-10 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-full font-bold transition-colors"></button>
</div>
<div className="w-full overflow-y-auto overflow-x-auto flex justify-start flex-1 custom-scrollbar px-2 pt-6 pb-12">
<div className="flex flex-row items-start min-w-max relative">
{Array.from({ length: cardsCount }).map((_, index) => (
<React.Fragment key={index}>
{/* CARTA DE REFERENCIA */}
<div className="flex flex-col items-center mx-2 relative z-20">
<div className="relative w-32 h-40 bg-slate-50 border-2 border-slate-300 rounded-2xl shadow-sm flex flex-col items-center justify-center group">
{cardsCount > 3 && index === cardsCount - 1 && (
<button onClick={handleRemoveCard} className="absolute -top-3 -right-3 w-8 h-8 bg-white text-slate-400 rounded-full border border-slate-200 flex items-center justify-center font-bold hover:bg-red-500 hover:text-white transition-colors z-10 shadow-sm opacity-0 group-hover:opacity-100"></button>
)}
<span className="text-4xl font-black text-slate-200">{index + 1}</span>
</div>
</div>
{/* HUECO ENTRE CARTAS */}
{index < cardsCount - 1 && (
<div className="flex flex-col items-center justify-start mx-1 relative min-w-[120px]">
<div className="absolute w-[calc(100%+2rem)] h-1 bg-slate-200 top-[80px] -translate-y-1/2 z-0"></div>
<div className="mt-[60px] flex flex-col items-center relative z-10 w-full">
{blankCards[index].isRange ? (
<div className="flex gap-2 items-start w-full justify-center">
<div className="flex flex-col items-center relative">
<span className="absolute bottom-full mb-1 text-[10px] font-bold text-slate-500">MÍN</span>
<BlankCardsCounter
index={index}
blankCardsCount={blankCards[index].min}
handleBlankCardChange={(idx, delta) => handleMinChange(idx, delta)}
/>
</div>
<div className="font-bold text-slate-300 mt-1">-</div>
<div className="flex flex-col items-center relative">
<span className="absolute bottom-full mb-1 text-[10px] font-bold text-slate-500">MÁX</span>
<BlankCardsCounter
index={index}
blankCardsCount={blankCards[index].max}
handleBlankCardChange={(idx, delta) => handleMaxChange(idx, delta)}
/>
</div>
</div>
) : (
<div className="flex flex-col items-center relative">
<span className="absolute bottom-full mb-1 text-[10px] font-bold text-slate-500">CARTAS</span>
<BlankCardsCounter
index={index}
blankCardsCount={blankCards[index].min}
handleBlankCardChange={(idx, delta) => handleExactChange(idx, delta)}
/>
</div>
)}
<button
onClick={() => toggleRangeMode(index)}
className="mt-4 text-[11px] font-semibold text-blue-600 hover:text-blue-800 hover:bg-blue-50 px-4 py-2 rounded-full border border-transparent hover:border-blue-200 transition-all text-center w-max cursor-pointer z-20"
>
{blankCards[index].isRange ? "Conozco la distancia" : "¿Dudas? Rango"}
</button>
</div>
</div>
)}
</React.Fragment>
))}
{/* Botón Añadir Carta */}
<div className="flex flex-col items-center mx-2 relative z-20">
<button onClick={handleAddCard} className="w-32 h-40 border-4 border-dashed border-slate-300 rounded-2xl flex flex-col items-center justify-center text-slate-400 font-bold hover:bg-blue-50 hover:border-blue-400 hover:text-blue-500 transition-colors group">
<span className="text-3xl group-hover:scale-110 transition-transform">+</span>
</button>
</div>
</div>
</div>
{/* Botones de Acción */}
<div className="mt-4 flex justify-between items-center border-t pt-6 shrink-0">
<button onClick={handleDelete} className="px-6 py-3 rounded-xl font-bold text-red-500 hover:bg-red-50 transition-colors">
Borrar Subescala
</button>
<div className="flex gap-4">
<button onClick={onClose} className="px-6 py-3 rounded-xl font-bold text-slate-600 hover:bg-slate-100 transition-colors">
Cancelar
</button>
<button onClick={handleSave} className="px-8 py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl shadow-md transition-colors">
Guardar Subescala
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
const TermInfo = ({ title, color, children }) => (
<div className="flex flex-col text-xs font-medium bg-slate-50 p-2.5 rounded-xl border border-slate-100">
<span className="uppercase font-black mb-1.5" style={{ color }}>{title}</span>
{children}
</div>
);
export const GraphTooltip = ({ active, payload, label, sortedResults }) => {
if (!active || !payload || !payload.length) return null;
const dataPoint = payload[0].payload;
const activeTerms = sortedResults.filter(item =>
item.isType2 ? (dataPoint[`${item.term}_upper`] ?? 0) > 0 : (dataPoint[item.term] ?? 0) > 0
);
if (activeTerms.length === 0) return null;
return (
<div className="bg-white p-4 border border-slate-200 shadow-xl rounded-2xl min-w-[200px] animate-fade-in relative z-50">
<p className="text-slate-800 font-black border-b border-slate-100 pb-2 mb-3 text-sm flex justify-between items-center gap-4">
<span>Punto X:</span> <span className="text-blue-600">{Number(label).toFixed(3)}</span>
</p>
<div className="flex flex-col gap-3">
{activeTerms.map(item => {
if (item.isType2) {
const lower = dataPoint[`${item.term}_lower`] ?? 0;
const upper = dataPoint[`${item.term}_upper`] ?? 0;
const range = Math.abs(upper - lower);
return range <= 0.001 ? (
<TermInfo key={item.term} title={item.term} color={item.color}>
<span className="text-slate-600 flex justify-between gap-4">Pertenencia: <b>{Number(upper).toFixed(3)}</b></span>
</TermInfo>
) : (
<TermInfo key={item.term} title={item.term} color={item.color}>
<span className="text-slate-600 flex justify-between gap-4">Mínimo: <b>{Number(lower).toFixed(3)}</b></span>
<span className="text-slate-600 flex justify-between gap-4 mt-0.5">Máximo: <b>{Number(upper).toFixed(3)}</b></span>
<span className="text-slate-500 font-bold mt-1.5 pt-1.5 border-t border-slate-200 flex justify-between gap-4">
Incertidumbre: <span>{Number(range).toFixed(3)}</span>
</span>
</TermInfo>
);
}
return (
<TermInfo key={item.term} title={item.term} color={item.color}>
<span className="text-slate-600 flex justify-between gap-4">Pertenencia: <b>{Number(dataPoint[item.term]).toFixed(3)}</b></span>
</TermInfo>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,89 @@
import { useMemo } from 'react';
import { CHART_COLORS } from '../../../config';
const interpolateY = (x, nodes) => {
if (!nodes || nodes.length === 0) return null;
const EPSILON = 1e-5;
const MICRO_STEP = 0.0001;
const firstX = nodes[0][0];
const lastX = nodes[nodes.length - 1][0];
if (x < firstX - MICRO_STEP - EPSILON) return null;
if (x > lastX + MICRO_STEP + EPSILON) return null;
if (x < firstX - EPSILON) return 0;
if (x > lastX + EPSILON) return 0;
for (let i = nodes.length - 1; i >= 0; i--) {
if (Math.abs(nodes[i][0] - x) < EPSILON) return nodes[i][1];
}
for (let i = 0; i < nodes.length - 1; i++) {
const x1 = nodes[i][0];
const x2 = nodes[i + 1][0];
if (Math.abs(x2 - x1) < EPSILON) continue;
if (x >= x1 && x <= x2) {
const y1 = nodes[i][1];
const y2 = nodes[i + 1][1];
return y1 + ((x - x1) * (y2 - y1)) / (x2 - x1);
}
}
return null;
};
export const useGraphData = (data) => {
const sortedResults = useMemo(() => {
const rawItems = data?.levels || data?.results || [];
const processed = rawItems.map((item, index) => {
const isType2 = !!item.lower && !!item.upper;
const color = CHART_COLORS[index % CHART_COLORS.length] || '#333';
let termName = item.term || (item.lower && item.lower.term) || `Termino ${index}`;
if (isType2) {
const lowerNodes = [...(item.lower.left_nodes || []), ...(item.lower.right_nodes || [])].map(n => [Number(n[0]), Number(n[1])]).sort((a,b)=>a[0]-b[0]);
const upperNodes = [...(item.upper.left_nodes || []), ...(item.upper.right_nodes || [])].map(n => [Number(n[0]), Number(n[1])]).sort((a,b)=>a[0]-b[0]);
const coreVal = Array.isArray(item.lower.core) ? Number(item.lower.core[0]) : 0;
return { ...item, term: termName, isType2, lowerNodes, upperNodes, color, coreVal };
} else {
const nodes = [...(item.left_nodes || []), ...(item.right_nodes || [])].map(n => [Number(n[0]), Number(n[1])]).sort((a,b)=>a[0]-b[0]);
const coreVal = Array.isArray(item.core) ? Number(item.core[0]) : 0;
return { ...item, term: termName, isType2, nodes, color, coreVal };
}
});
return processed.sort((a, b) => a.coreVal - b.coreVal);
}, [data]);
const denseData = useMemo(() => {
const xSet = new Set();
const steps = 1000;
for (let i = 0; i <= steps; i++) xSet.add(Number((i / steps).toFixed(4)));
sortedResults.forEach(item => {
const addNodes = (nodes) => nodes.forEach(n => {
const x = n[0];
xSet.add(Number((x - 0.0001).toFixed(4)));
xSet.add(Number(x.toFixed(4)));
xSet.add(Number((x + 0.0001).toFixed(4)));
});
item.isType2 ? (addNodes(item.lowerNodes), addNodes(item.upperNodes)) : addNodes(item.nodes);
});
const xValues = Array.from(xSet).sort((a, b) => a - b);
return xValues.map(x => {
const point = { x };
sortedResults.forEach(item => {
if (item.isType2) {
const lowerRaw = interpolateY(x, item.lowerNodes);
const upperRaw = interpolateY(x, item.upperNodes);
point[`${item.term}_lower`] = lowerRaw;
point[`${item.term}_upper`] = upperRaw;
point[`${item.term}_range`] = (lowerRaw === null && upperRaw === null) ? null : [lowerRaw ?? 0, upperRaw ?? 0];
} else {
point[item.term] = interpolateY(x, item.nodes);
}
});
return point;
});
}, [sortedResults]);
return { sortedResults, denseData };
};
+95
View File
@@ -0,0 +1,95 @@
export default function Footer() {
return (
<footer className="bg-white border-t border-slate-200 mt-auto shrink-0 w-full pt-8 pb-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-8 lg:gap-6">
{/* Proyecto */}
<div className="lg:col-span-4 flex flex-col">
<div className="flex items-center gap-3 mb-3">
<span className="text-xl font-black text-slate-800 tracking-tight">Deck of Cards</span>
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-[10px] font-black uppercase tracking-widest rounded-md">
Software Científico
</span>
</div>
<p className="text-sm text-slate-500 leading-relaxed max-w-sm">
Plataforma web para la elicitación de escalas de valor y construcción de conjuntos difusos interpretables (DoC-MF).
</p>
</div>
{/* Desarrollo */}
<div className="lg:col-span-3 flex flex-col">
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-3">Ingeniería y Desarrollo</h4>
<ul className="text-sm font-bold text-slate-700 space-y-2">
<li className="flex flex-wrap items-center gap-2">
Alexis López Moral
<span className="text-slate-400 font-medium text-[10px] font-mono bg-slate-50 border border-slate-100 px-1.5 py-0.5 rounded">Frontend</span>
</li>
<li className="flex flex-wrap items-center gap-2">
Mireya Cueto Garrido
<span className="text-slate-400 font-medium text-[10px] font-mono bg-slate-50 border border-slate-100 px-1.5 py-0.5 rounded">Backend</span>
</li>
</ul>
</div>
{/* Dirección Científica */}
<div className="lg:col-span-2 flex flex-col">
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-3">Dirección Científica</h4>
<p className="text-sm font-bold text-slate-700">Luis Martínez López</p>
</div>
{/* Enlaces Institucionales y Código */}
<div className="lg:col-span-3 flex flex-col gap-5 sm:items-start lg:items-end">
{/* Universidad de Jaén */}
<a
href="https://www.ujaen.es/"
target="_blank" rel="noopener noreferrer"
className="group flex items-center gap-3 w-fit"
title="Ir a la web oficial de la Universidad de Jaén"
>
<div className="text-right border-r-2 border-slate-300 group-hover:border-blue-600 pr-3 flex flex-col justify-center h-9 transition-colors">
<span className="text-xs font-black text-slate-800 uppercase tracking-widest leading-none mb-1">Universidad</span>
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-[0.3em] leading-none">de Jaén</span>
</div>
<img
src="/uja-logo.png"
alt="Logo UJA"
className="w-9 h-9 object-contain grayscale group-hover:grayscale-0 transition-all opacity-80 group-hover:opacity-100"
/>
</a>
{/* Repositorio GitHub */}
<a
href="https://github.com/alexislopez-dev/deck-of-cards"
target="_blank" rel="noopener noreferrer"
className="group flex items-center gap-3 w-fit"
title="Ver código fuente en GitHub"
>
<div className="text-right border-r-2 border-slate-300 group-hover:border-slate-800 pr-3 flex flex-col justify-center h-9 transition-colors">
<span className="text-xs font-black text-slate-800 uppercase tracking-widest leading-none mb-1">Repositorio</span>
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-[0.3em] leading-none">Oficial</span>
</div>
<svg className="w-9 h-9 text-slate-400 group-hover:text-slate-800 transition-colors" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
</svg>
</a>
</div>
</div>
{/* Sub-Footer: Copyright y Referencia Científica */}
<div className="mt-6 pt-6 border-t border-slate-100 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">
© {new Date().getFullYear()} Deck of Cards App.
</p>
<p className="text-[10px] font-medium text-slate-400 text-center md:text-right">
Basado en la metodología DoC-MF propuesta por D. García-Zamora, B. Dutta, J.R. Figueira y L. Martínez (EJOR, 2024).
</p>
</div>
</div>
</footer>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { FiLogIn, FiLogOut } from 'react-icons/fi';
export default function Header() {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { user, logout, isAuthenticated } = useAuth();
const userInitial = user?.username ? user.username[0].toUpperCase() : "U";
const handleLogout = () => {
logout();
setIsDropdownOpen(false);
navigate('/login');
};
const isActive = (path) => {
return location.pathname === path || (path === '/editor' && location.pathname === '/');
};
return (
<header className="bg-white shadow-sm border-b border-slate-200 sticky top-0 z-50 h-16 shrink-0 w-full">
<div className="max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 h-full flex items-center justify-between">
<Link to="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity whitespace-nowrap">
<img src="/favicon.svg" alt="Deck of Cards Logo" className="w-10 h-10 shadow-sm rounded-xl object-contain" />
<span className="text-2xl font-black bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-indigo-600 hidden sm:block">
Deck of Cards
</span>
</Link>
<div className="flex items-center gap-4 whitespace-nowrap">
<div className="flex items-center gap-1 mr-2">
<Link to="/editor" className={`text-sm font-bold px-4 py-2 rounded-lg transition-all ${isActive('/editor') ? 'text-blue-600' : 'text-slate-600 hover:text-blue-500'}`}>
Editor
</Link>
{isAuthenticated && (
<Link to="/history" className={`text-sm font-bold px-4 py-2 rounded-lg transition-all ${isActive('/history') ? 'text-blue-600' : 'text-slate-600 hover:text-blue-500'}`}>
Historial
</Link>
)}
</div>
{isAuthenticated ? (
<div className="relative border-l border-slate-200 pl-4">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="w-10 h-10 rounded-full bg-blue-100 text-blue-700 font-bold flex items-center justify-center border-2 border-blue-200 hover:bg-blue-200 transition-colors"
>
{userInitial}
</button>
{isDropdownOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsDropdownOpen(false)}></div>
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg border border-slate-100 py-2 z-50">
<div className="px-4 py-2 border-b border-slate-50">
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">Usuario</p>
<p className="text-sm font-bold text-slate-700 truncate">{user?.username}</p>
</div>
<button
onClick={handleLogout}
className="w-full flex items-center gap-2 px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 transition-colors"
>
<FiLogOut className="w-5 h-5" strokeWidth={2.5} />
Cerrar Sesión
</button>
</div>
</>
)}
</div>
) : (
<div className="flex items-center border-l border-slate-200 pl-4">
<Link
to="/login"
className="flex items-center gap-2 text-sm font-bold bg-blue-600 text-white px-5 py-2.5 rounded-xl shadow-sm hover:bg-blue-700 transition-all active:scale-95"
>
<FiLogIn className="w-5 h-5" strokeWidth={2.5} />
Acceder
</Link>
</div>
)}
</div>
</div>
</header>
);
}
@@ -0,0 +1,18 @@
import Header from './Header';
import Footer from './Footer';
export default function MainLayout({ children }) {
return (
<div className="min-h-screen flex flex-col bg-slate-50 font-sans">
<Header />
<main className="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-8 flex flex-col">
{children}
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,37 @@
import React from 'react';
import { ComposedChart, Line, XAxis, YAxis, CartesianGrid, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip } from 'recharts';
export default function Chart({ baseScale, mfDefinitions, selectedTerm, colors }) {
const scaleKeys = Object.keys(baseScale);
return (
<div className="w-full bg-slate-50/50 rounded-2xl border border-slate-200 p-2 mb-6">
<ResponsiveContainer width="99%" height={320}>
<ComposedChart margin={{ top: 20, right: 30, left: 10, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis type="number" dataKey="x" domain={[0, 1]} ticks={[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]} tick={{ fill: '#475569', fontWeight: 600, fontSize: 12 }} />
<YAxis domain={[0, 1]} tick={{ fill: '#475569', fontSize: 12 }} />
<Tooltip formatter={(value) => typeof value === 'number' ? value.toFixed(2) : value} />
{scaleKeys.map((name, index) => {
const val = baseScale[name];
const mf = mfDefinitions[name];
if (!mf) return null;
const color = colors[index % colors.length];
const isSelected = selectedTerm === name;
const trapezeData = [ { x: mf.supportStart, y: 0 }, { x: mf.coreStart, y: 1 }, { x: mf.coreEnd, y: 1 }, { x: mf.supportEnd, y: 0 } ];
return (
<React.Fragment key={`mf-${name}`}>
<ReferenceLine x={val} stroke={color} strokeDasharray="4 4" strokeWidth={isSelected ? 2 : 1} label={{ position: 'top', value: name, fill: color, fontWeight: isSelected ? '900' : 'normal', fontSize: 12 }} />
<ReferenceArea x1={mf.supportStart} x2={mf.supportEnd} fill={color} fillOpacity={isSelected ? 0.3 : 0.05} />
<ReferenceArea x1={mf.coreStart} x2={mf.coreEnd} fill={color} fillOpacity={isSelected ? 0.6 : 0.15} />
<Line data={trapezeData} dataKey="y" type="linear" stroke={color} strokeWidth={isSelected ? 4 : 2} dot={isSelected ? { r: 5, fill: color, stroke: '#fff', strokeWidth: 2 } : false} activeDot={false} isAnimationActive={false} />
</React.Fragment>
);
})}
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,75 @@
export default function Controls({
selectedTerm, currentMf, selectedColor, baseScale, mfDefinitions, updateCurrentMf,
subscales, onOpenSubscale
}) {
if (!selectedTerm || !currentMf) return null;
const scaleKeys = Object.keys(baseScale);
const selectedIndex = scaleKeys.indexOf(selectedTerm);
let absoluteMin = 0, absoluteMax = 1;
if (selectedIndex > 0) absoluteMin = mfDefinitions[scaleKeys[selectedIndex - 1]].coreEnd;
if (selectedIndex < scaleKeys.length - 1) absoluteMax = mfDefinitions[scaleKeys[selectedIndex + 1]].coreStart;
const leftSubscale = subscales?.[selectedTerm]?.left;
const rightSubscale = subscales?.[selectedTerm]?.right;
return (
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-md relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1.5" style={{ backgroundColor: selectedColor }}></div>
<h3 className="text-xl font-bold text-slate-800 mb-4 flex items-center gap-2">
Ajustando: <span style={{ color: selectedColor }}>"{selectedTerm}"</span>
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4 bg-slate-50 p-4 rounded-xl border border-slate-100">
<div>
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1">
<span>Inicio del Soporte (Punto inferior)</span><span style={{ color: selectedColor }}>{currentMf.supportStart.toFixed(3)}</span>
</label>
<input type="range" min={absoluteMin} max={absoluteMax} step="0.001" value={currentMf.supportStart} onChange={(e) => updateCurrentMf('supportStart', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor, opacity: 0.7 }} />
</div>
<div>
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1">
<span>Inicio del Núcleo (Punto superior)</span><span style={{ color: selectedColor }}>{currentMf.coreStart.toFixed(3)}</span>
</label>
<input type="range" min={absoluteMin} max={absoluteMax} step="0.001" value={currentMf.coreStart} onChange={(e) => updateCurrentMf('coreStart', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor }} />
</div>
<div className="pt-2 border-t border-slate-200 flex justify-end">
<button
onClick={() => onOpenSubscale(selectedTerm, 'left', leftSubscale)}
className={`text-sm font-bold px-4 py-2 rounded-lg transition-all border ${leftSubscale ? 'bg-blue-50 text-blue-700 border-blue-200' : 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'}`}
>
{leftSubscale ? `✎ Subescala (Cartas: ${leftSubscale.cardsCount})` : '+ Añadir Subescala'}
</button>
</div>
</div>
<div className="space-y-4 bg-slate-50 p-4 rounded-xl border border-slate-100">
<div>
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1">
<span>Fin del Soporte (Punto inferior)</span><span style={{ color: selectedColor }}>{currentMf.supportEnd.toFixed(3)}</span>
</label>
<input type="range" min={absoluteMin} max={absoluteMax} step="0.001" value={currentMf.supportEnd} onChange={(e) => updateCurrentMf('supportEnd', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor, opacity: 0.7 }} />
</div>
<div>
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1">
<span>Fin del Núcleo (Punto superior)</span><span style={{ color: selectedColor }}>{currentMf.coreEnd.toFixed(3)}</span>
</label>
<input type="range" min={absoluteMin} max={absoluteMax} step="0.001" value={currentMf.coreEnd} onChange={(e) => updateCurrentMf('coreEnd', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor }} />
</div>
<div className="pt-2 border-t border-slate-200 flex justify-end">
<button
onClick={() => onOpenSubscale(selectedTerm, 'right', rightSubscale)}
className={`text-sm font-bold px-4 py-2 rounded-lg transition-all border ${rightSubscale ? 'bg-blue-50 text-blue-700 border-blue-200' : 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'}`}
>
{rightSubscale ? `✎ Subescala (Cartas: ${rightSubscale.cardsCount})` : '+ Añadir Subescala'}
</button>
</div>
</div>
</div>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
export const API_BASE_URL = import.meta.env.VITE_API_URL;
export const CHART_COLORS = [
'#ef4444', '#f59e0b', '#10b981', '#3b82f6',
'#d946ef', '#06b6d4', '#8b5cf6', '#f43f5e', '#6366f1'
];
+7
View File
@@ -0,0 +1,7 @@
import { createContext, useContext } from 'react';
export const AuthContext = createContext();
export const useAuth = () => {
return useContext(AuthContext);
};
+42
View File
@@ -0,0 +1,42 @@
import { useState, useCallback } from 'react';
import { AuthContext } from './AuthContext';
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(() => {
try {
const storedUser = localStorage.getItem('user');
return storedUser ? JSON.parse(storedUser) : null;
} catch {
return null;
}
});
const login = useCallback((data) => {
const currentUser = data.user || data;
const token = data.access_token || data.token;
setUser(currentUser);
localStorage.setItem('user', JSON.stringify(currentUser));
if (token) {
localStorage.setItem('token', token);
}
}, []);
const logout = useCallback(() => {
setUser(null);
localStorage.removeItem('user');
localStorage.removeItem('token');
}, []);
return (
<AuthContext.Provider value={{
user,
isAuthenticated: !!user,
login,
logout
}}>
{children}
</AuthContext.Provider>
);
};
+5
View File
@@ -0,0 +1,5 @@
@import "tailwindcss";
body {
overflow-y: scroll;
}
+48
View File
@@ -0,0 +1,48 @@
import Axios from 'axios';
import { API_BASE_URL } from '../config';
const api = Axios.create({
baseURL: API_BASE_URL,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => {
return response;
},
(error) => {
// Si es un error 401 (No autorizado)
if (error.response && error.response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
// SOLUCIÓN: Solo recargamos y redirigimos si NO estamos ya en /login
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
// Propagamos el error para que los componentes puedan leer backendData
if (error.response && error.response.data) {
return Promise.reject({
...error,
backendData: error.response.data
});
}
return Promise.reject(error);
}
);
export default api;
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { AuthProvider } from './context/AuthProvider.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>,
)
+276
View File
@@ -0,0 +1,276 @@
import { useState } from 'react';
import Step1BaseScale from '../components/editor/Step1BaseScale';
import Step2FuzzyModeling from '../components/editor/Step2FuzzyModeling';
import SubscaleModal from '../components/editor/SubscaleModal';
import { calculateValueFunction, buildFuzzyGraph, saveToHistory } from '../services/docService';
import Step3FinalGraph from '../components/editor/Step3FinalGraph';
export default function DocEditor() {
const [step, setStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
// ESTADOS: FASE 1
const [criterionName, setCriterionName] = useState('');
const [levels, setLevels] = useState(['', '', '']);
const [blankCards, setBlankCards] = useState([0, 0]);
const [errors, setErrors] = useState({ criterion: false, levels: [] });
// ESTADOS: FASE 2
const [baseScale, setBaseScale] = useState({});
const [selectedTerm, setSelectedTerm] = useState(null);
const [mfDefinitions, setMfDefinitions] = useState({});
const [subscales, setSubscales] = useState({});
const [modalTarget, setModalTarget] = useState(null);
// ESTADO: FASE 3
const [finalResult, setFinalResult] = useState(null);
const [submitError, setSubmitError] = useState(null);
// MANEJADORES: FASE 1
const handleCriterionChange = (val) => { setCriterionName(val); if (errors.criterion) setErrors({ ...errors, criterion: false }); };
const handleLevelChange = (index, newValue) => { const newLevels = [...levels]; newLevels[index] = newValue; setLevels(newLevels); if (errors.levels[index]) setErrors({ ...errors, levels: errors.levels.map((e, i) => i === index ? false : e) }); };
const handleAddLevel = () => { setLevels([...levels, '']); setBlankCards([...blankCards, 0]); setErrors({ ...errors, levels: [...errors.levels, false] }); };
const handleRemoveLevel = (indexToRemove) => { if (levels.length <= 3) return; setLevels(levels.filter((_, i) => i !== indexToRemove)); setBlankCards(blankCards.filter((_, i) => i !== (indexToRemove === 0 ? 0 : indexToRemove - 1))); setErrors({ ...errors, levels: errors.levels.filter((_, i) => i !== indexToRemove) }); };
const handleBlankCardChange = (index, delta) => { const newCards = [...blankCards]; if (newCards[index] + delta >= 0) { newCards[index] += delta; setBlankCards(newCards); } };
const handleGenerateBaseScale = async () => {
const newErrors = { criterion: !criterionName.trim(), levels: levels.map(l => !l.trim()) };
if (newErrors.criterion || newErrors.levels.includes(true)) {
setErrors(newErrors);
return alert("Por favor, rellena todos los campos.");
}
setIsLoading(true);
try {
const payloadBase = { criterion_name: criterionName.trim(), levels: levels.map(l => l.trim()), blank_cards: blankCards, references: { "0": 0, [(levels.length - 1).toString()]: 1 } };
const baseResult = await calculateValueFunction(payloadBase);
setBaseScale(baseResult.values);
const initialMfs = {};
Object.entries(baseResult.values).forEach(([name, value]) => { initialMfs[name] = { supportStart: value, coreStart: value, coreEnd: value, supportEnd: value }; });
setMfDefinitions(initialMfs);
setSelectedTerm(Object.keys(baseResult.values)[0]);
setStep(2);
} catch (error) { alert("Error: " + error); } finally { setIsLoading(false); }
};
// MANEJADORES: FASE 2
const updateCurrentMf = (field, value) => {
if (!selectedTerm) return;
let numValue = parseFloat(value);
setMfDefinitions(prev => {
const scaleKeys = Object.keys(baseScale);
const selectedIndex = scaleKeys.indexOf(selectedTerm);
let prevCoreEnd = 0, prevSupportEnd = 0, nextCoreStart = 1, nextSupportStart = 1;
if (selectedIndex > 0) {
prevCoreEnd = prev[scaleKeys[selectedIndex - 1]].coreEnd;
prevSupportEnd = prev[scaleKeys[selectedIndex - 1]].supportEnd;
}
if (selectedIndex < scaleKeys.length - 1) {
nextCoreStart = prev[scaleKeys[selectedIndex + 1]].coreStart;
nextSupportStart = prev[scaleKeys[selectedIndex + 1]].supportStart;
}
const anchor = baseScale[selectedTerm];
if (field === 'supportStart' && numValue < prevCoreEnd) numValue = prevCoreEnd;
if (field === 'coreStart' && numValue < prevSupportEnd) numValue = prevSupportEnd;
if (field === 'coreEnd' && numValue > nextSupportStart) numValue = nextSupportStart;
if (field === 'supportEnd' && numValue > nextCoreStart) numValue = nextCoreStart;
if ((field === 'supportStart' || field === 'coreStart') && numValue > anchor) numValue = anchor;
if ((field === 'supportEnd' || field === 'coreEnd') && numValue < anchor) numValue = anchor;
const current = { ...prev[selectedTerm], [field]: numValue };
if (field === 'supportStart') {
if (current.supportStart > current.coreStart) current.coreStart = current.supportStart;
if (current.coreStart > current.coreEnd) current.coreEnd = current.coreStart;
if (current.coreEnd > current.supportEnd) current.supportEnd = current.coreEnd;
} else if (field === 'coreStart') {
if (current.coreStart < current.supportStart) current.supportStart = current.coreStart;
if (current.coreStart > current.coreEnd) current.coreEnd = current.coreStart;
if (current.coreEnd > current.supportEnd) current.supportEnd = current.coreEnd;
} else if (field === 'coreEnd') {
if (current.coreEnd > current.supportEnd) current.supportEnd = current.coreEnd;
if (current.coreEnd < current.coreStart) current.coreStart = current.coreEnd;
if (current.coreStart < current.supportStart) current.supportStart = current.coreStart;
} else if (field === 'supportEnd') {
if (current.supportEnd < current.coreEnd) current.coreEnd = current.supportEnd;
if (current.coreEnd < current.coreStart) current.coreStart = current.coreEnd;
if (current.coreStart < current.supportStart) current.supportStart = current.coreStart;
}
return { ...prev, [selectedTerm]: current };
});
};
const handleOpenSubscale = (term, side, initialData) => {
setModalTarget({ term, side, initialData });
};
const handleSaveSubscale = (term, side, data) => {
setSubscales(prev => ({
...prev,
[term]: {
...prev[term],
[side]: data
}
}));
setModalTarget(null);
};
// Petición para el endpoint "build"
const handleFinalSubmit = async () => {
setSubmitError(null);
const scaleKeys = Object.keys(baseScale);
const payload = {
levels: scaleKeys.map(term => {
const mf = mfDefinitions[term];
const sub = subscales[term] || {};
const c_start = Number(mf.coreStart.toFixed(4));
const c_end = Number(mf.coreEnd.toFixed(4));
const s_start = Math.min(Number(mf.supportStart.toFixed(4)), c_start);
const s_end = Math.max(Number(mf.supportEnd.toFixed(4)), c_end);
const leftCount = sub.left ? sub.left.cardsCount : 2;
const left_nodes_x = Array.from({ length: leftCount }).map((_, i) =>
Number((s_start + (c_start - s_start) * (i / (leftCount - 1))).toFixed(4))
);
const rightCount = sub.right ? sub.right.cardsCount : 2;
const right_nodes_x = Array.from({ length: rightCount }).map((_, i) =>
Number((c_end + (s_end - c_end) * (i / (rightCount - 1))).toFixed(4))
);
return {
term: term,
core: [ c_start, c_end ],
support: [ s_start, s_end ],
left_nodes_x: left_nodes_x,
left_blank_cards: sub.left ? sub.left.blankCards : [0],
right_nodes_x: right_nodes_x,
right_blank_cards: sub.right ? sub.right.blankCards : [0]
};
})
};
setIsLoading(true);
try {
const result = await buildFuzzyGraph(payload);
setFinalResult(result);
setStep(3);
} catch (error) {
let friendlyMessage = "Ocurrió un error al procesar la solicitud.";
const errorData = error.backendData || error.response?.data || error;
if (errorData.detail) {
if (errorData.detail === "Invalid input data") {
friendlyMessage = "Revisa los valores del Soporte y Núcleo. Asegúrate de que el 'Inicio del Soporte' sea menor o igual al 'Fin del Soporte', y que el 'Núcleo' esté dentro del 'Soporte'.";
} else if (typeof errorData.detail === 'string') {
friendlyMessage = errorData.detail;
}
} else if (errorData.errors && Array.isArray(errorData.errors) && errorData.errors.length > 0) {
friendlyMessage = errorData.errors.map(err => {
let cleanMsg = err.msg ? err.msg.replace("Value error, ", "") : "Valor incorrecto";
if (err.loc && err.loc.includes("levels")) {
const levelIndex = err.loc[err.loc.indexOf("levels") + 1];
const termName = scaleKeys[levelIndex] || `Nivel ${Number(levelIndex) + 1}`;
return `• En la etiqueta "${termName}": ${cleanMsg}`;
}
return `${cleanMsg}`;
}).join("\n");
}
setSubmitError(friendlyMessage);
} finally {
setIsLoading(false);
}
};
// Petición para guardar en el historial
const handleSaveToHistory = async () => {
const token = localStorage.getItem('token');
if (!token) {
alert("Para guardar tu modelo debes iniciar sesión primero. Puedes seguir visualizando la gráfica sin problema.");
return;
}
const defaultName = criterionName ? `Modelo de ${criterionName}` : "Mi nueva gráfica DoC-IT2MF";
const historyName = prompt("Dale un nombre a este modelo para guardarlo en tu historial:", defaultName);
if (!historyName) return;
setIsLoading(true);
try {
const payload = {
name: historyName,
results: finalResult.levels || finalResult.results
};
await saveToHistory(payload);
alert("¡Gráfica guardada con éxito en tu historial!");
} catch (error) {
console.error("Error al guardar en el historial:", error);
alert("Hubo un problema al guardar el modelo: " + error);
} finally {
setIsLoading(false);
}
};
return (
<div className="w-full flex flex-col items-center">
{step === 1 && (
<Step1BaseScale
criterionName={criterionName} handleCriterionChange={handleCriterionChange}
levels={levels} handleLevelChange={handleLevelChange}
handleAddLevel={handleAddLevel} handleRemoveLevel={handleRemoveLevel}
blankCards={blankCards} handleBlankCardChange={handleBlankCardChange}
errors={errors} handleGenerateBaseScale={handleGenerateBaseScale} isLoading={isLoading}
/>
)}
{step === 2 && (
<Step2FuzzyModeling
baseScale={baseScale} mfDefinitions={mfDefinitions}
selectedTerm={selectedTerm} setSelectedTerm={setSelectedTerm}
updateCurrentMf={updateCurrentMf} handleFinalSubmit={handleFinalSubmit}
onBack={() => setStep(1)}
subscales={subscales}
onOpenSubscale={handleOpenSubscale}
submitError={submitError}
/>
)}
{step === 3 && finalResult && (
<div className="flex flex-col w-full">
<Step3FinalGraph data={finalResult} criterionName={criterionName} />
<button
onClick={handleSaveToHistory}
disabled={isLoading}
className={`mt-4 px-8 py-3 font-bold rounded-xl shadow-md w-fit self-end transition-all ${
isLoading ? 'bg-slate-400 text-slate-100 cursor-not-allowed' : 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isLoading ? 'Guardando...' : 'Guardar'}
</button>
</div>
)}
{modalTarget && (
<SubscaleModal
key={`${modalTarget.term}-${modalTarget.side}`}
onClose={() => setModalTarget(null)}
onSave={handleSaveSubscale}
targetInfo={modalTarget}
/>
)}
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getUserHistory, deleteHistoryItem } from '../services/docService';
import Step3FinalGraph from '../components/editor/Step3FinalGraph';
import { FiEye, FiTrash2, FiBarChart2, FiInbox, FiClock } from 'react-icons/fi';
export default function History() {
const [historyItems, setHistoryItems] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [expandedId, setExpandedId] = useState(null);
useEffect(() => {
fetchHistory();
}, []);
const fetchHistory = async () => {
setIsLoading(true);
try {
const data = await getUserHistory();
const items = Array.isArray(data) ? data : data.history || data.items || [];
setHistoryItems(items.reverse());
} catch (error) {
console.error("Error fetching history:", error);
alert("Hubo un problema al cargar el historial.");
} finally {
setIsLoading(false);
}
};
const handleDelete = async (id) => {
if (!window.confirm('¿Seguro que quieres borrar este modelo definitivamente?')) return;
try {
await deleteHistoryItem(id);
setHistoryItems(prev => prev.filter(item => item._id !== id && item.id !== id));
if (expandedId === id) setExpandedId(null);
} catch (err) {
const errorMessage = err.response?.data?.detail || err.message || "Error desconocido";
alert("Error al borrar: " + errorMessage);
}
};
const toggleExpand = (id) => {
setExpandedId(expandedId === id ? null : id);
};
return (
<div className="w-full max-w-7xl mx-auto flex flex-col gap-8 animate-fade-in pb-2">
{/* Cabecera */}
<div className="flex justify-between items-center bg-white p-8 rounded-3xl shadow-sm border border-slate-200">
<div>
<h1 className="text-3xl font-black text-slate-800">Mi Historial</h1>
<p className="text-slate-500 font-medium mt-1">
Aquí están todas las gráficas y modelos que has guardado.
</p>
</div>
<Link
to="/editor"
className="px-6 py-3 bg-blue-50 text-blue-600 font-bold rounded-xl hover:bg-blue-100 transition-colors shadow-sm"
>
+ Nuevo Modelo
</Link>
</div>
{/* Lista de Historial */}
{isLoading ? (
<div className="bg-white p-12 rounded-3xl shadow-sm border border-slate-200 flex flex-col items-center justify-center text-slate-400 border-dashed">
<div className="text-6xl"><FiClock className="w-16 h-16 mb-4 opacity-50" strokeWidth={1.5} /></div>
<p className="font-medium text-lg">Cargando tus gráficas...</p>
</div>
) : historyItems.length === 0 ? (
<div className="bg-white p-12 rounded-3xl shadow-sm border border-slate-200 flex flex-col items-center justify-center text-slate-400 border-dashed">
<span className="text-6xl"><FiInbox className="w-16 h-16 mb-4 opacity-50" strokeWidth={1.5} /></span>
<p className="font-medium text-lg">Aún no has guardado ningún modelo.</p>
<p className="text-sm mt-2">Ve al editor, crea una gráfica y dale a "Guardar".</p>
</div>
) : (
<div className="flex flex-col gap-6">
{historyItems.map((item) => {
const itemId = item._id || item.id;
const isExpanded = expandedId === itemId;
return (
<div key={itemId} className={`bg-white rounded-2xl shadow-sm border transition-all duration-300 ${isExpanded ? 'border-blue-300 ring-4 ring-blue-50' : 'border-slate-200 hover:border-slate-300'}`}>
{/* Cabecera de la Card */}
<div className="p-6 flex flex-col sm:flex-row justify-between items-center gap-4 bg-slate-50/50 rounded-t-2xl">
<div className="flex items-center gap-4 w-full sm:w-auto">
<div className="w-12 h-12 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-xl shadow-inner">
<FiBarChart2 className="w-5 h-5" strokeWidth={2.5} />
</div>
<div>
<h3 className="text-xl font-bold text-slate-800">{item.name || 'Modelo sin título'}</h3>
<p className="text-sm text-slate-500 font-medium">
{item.created_at
? `Guardado el ${new Date(item.created_at).toLocaleDateString('es-ES', { day: '2-digit', month: 'long', year: 'numeric' })}`
: 'Guardado en el historial'
}
</p>
</div>
</div>
<div className="flex gap-3 w-full sm:w-auto">
<button
onClick={() => toggleExpand(itemId)}
className={`flex-1 sm:flex-none px-6 py-2.5 font-bold rounded-xl transition-colors ${isExpanded ? 'bg-slate-200 text-slate-700 hover:bg-slate-300' : 'bg-blue-50 text-blue-600 hover:bg-blue-100'}`}
>
{isExpanded ? 'Ocultar Gráfica ▴' : 'Ver Gráfica ▾'}
</button>
<button
onClick={() => handleDelete(itemId)}
className="px-4 py-2.5 bg-white border border-red-200 text-red-500 font-bold rounded-xl hover:bg-red-50 transition-colors shadow-sm"
title="Borrar modelo"
>
<FiTrash2 className="w-5 h-5" strokeWidth={2.5} />
</button>
</div>
</div>
{/* Contenido Desplegable (La gráfica)*/}
<div
className={`transition-all duration-500 ease-in-out overflow-hidden ${
isExpanded ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
}`}
>
<div className="p-6 border-t border-slate-100 bg-white rounded-b-2xl">
{isExpanded ? (
<Step3FinalGraph data={item} criterionName={item.name} />
) : (
<div className="h-[550px] w-full" />
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { useState, useEffect, useRef } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { authService } from '../services/authService';
import { FiEye, FiEyeOff } from 'react-icons/fi';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
const { login } = useAuth();
const [searchParams] = useSearchParams();
const googleLoginProcessed = useRef(false);
useEffect(() => {
const token = searchParams.get('token');
if (token && !googleLoginProcessed.current) {
googleLoginProcessed.current = true;
const url = new URL(window.location);
url.searchParams.delete('token');
window.history.replaceState({}, '', url);
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
const decodedToken = JSON.parse(jsonPayload);
const googleUser = {
_id: decodedToken.sub || decodedToken.user_id || "google_id",
username: decodedToken.email ? decodedToken.email.split('@')[0] : "Usuario Google",
email: decodedToken.email || ""
};
login({ user: googleUser, access_token: token });
navigate('/', { replace: true });
} catch (err) {
console.error("Error al decodificar el token de Google:", err);
setTimeout(() => {
setError("Error al procesar el login con Google. El token está corrupto.");
}, 0);
}
}
}, [searchParams, login, navigate]);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
const data = await authService.login(email, password);
login(data);
navigate('/');
} catch (err) {
setError('Credenciales incorrectas.');
}
};
const handleGoogleLogin = () => {
window.location.href = "http://localhost:8000/api/auth/google/login";
};
return (
<div className="flex-1 flex items-center justify-center py-4">
<div className="max-w-md w-full bg-white p-10 rounded-3xl shadow-sm border border-slate-200">
<div className="text-center mb-8">
<h2 className="text-3xl font-black text-slate-800 tracking-tight">Deck of Cards</h2>
<p className="text-slate-500 mt-2">Accede a tu historial y gráficas guardadas</p>
</div>
{error && (
<div className="bg-red-50 text-red-600 p-4 rounded-2xl text-sm font-bold mb-6 border border-red-100 text-center">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-bold text-slate-700 ml-1">Email</label>
<input
type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
className="w-full px-5 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
placeholder="correo@ejemplo.com"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-bold text-slate-700 ml-1">Contraseña</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
required value={password} onChange={(e) => setPassword(e.target.value)}
className="w-full pl-5 pr-12 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors focus:outline-none"
>
{showPassword ? (
<FiEye className="w-5 h-5" strokeWidth={2} />
) : (
<FiEyeOff className="w-5 h-5" strokeWidth={2} />
)}
</button>
</div>
</div>
<button type="submit" className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl transition-all shadow-sm active:scale-95 mt-2">
Entrar
</button>
</form>
<div className="relative my-8">
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-slate-100"></div></div>
<div className="relative flex justify-center text-xs uppercase tracking-widest"><span className="px-3 bg-white text-slate-400 font-bold">O</span></div>
</div>
<button type="button" onClick={handleGoogleLogin} className="w-full flex items-center justify-center gap-3 px-4 py-4 border-2 border-slate-100 rounded-2xl bg-white text-slate-700 font-bold hover:bg-slate-50 hover:border-slate-200 transition-all shadow-sm active:scale-95">
<svg className="w-5 h-5" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" /><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" /><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" /><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" /></svg>
Continuar con Google
</button>
<p className="mt-8 text-center text-sm text-slate-500 font-medium">¿Nuevo por aquí? <Link to="/register" className="text-blue-600 hover:underline font-extrabold">Crea una cuenta</Link></p>
</div>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { authService } from '../services/authService';
import { FiEye, FiEyeOff } from 'react-icons/fi';
export default function Register() {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
const { login } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Las contraseñas no coinciden. Por favor, revísalas.');
return;
}
try {
const data = await authService.register(username, email, password);
const userData = { id: data.user_id, username: username, email: email };
login(userData, data.token);
navigate('/');
} catch (err) {
setError(err.response?.data?.detail || 'Error al registrar el usuario.');
}
};
return (
<div className="flex-1 flex items-center justify-center py-4">
<div className="max-w-md w-full bg-white p-10 rounded-3xl shadow-sm border border-slate-200">
<div className="text-center mb-8">
<h2 className="text-3xl font-black text-slate-800 tracking-tight">Crear Cuenta</h2>
<p className="text-slate-500 mt-2">Inicia sesión para guardar tu progreso</p>
</div>
{error && (
<div className="bg-red-50 text-red-600 p-4 rounded-2xl text-sm font-bold mb-6 border border-red-100 text-center">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-bold text-slate-700 ml-1">Nombre de usuario</label>
<input
type="text" required autoComplete="username"
className="w-full px-5 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
value={username} onChange={(e) => setUsername(e.target.value)}
placeholder="Ej: alexis99"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-bold text-slate-700 ml-1">Email</label>
<input
type="email" required autoComplete="email"
className="w-full px-5 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
value={email} onChange={(e) => setEmail(e.target.value)}
placeholder="tu@email.com"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-bold text-slate-700 ml-1">Contraseña</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
required autoComplete="new-password"
className="w-full pl-5 pr-12 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
value={password} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
/>
<button
type="button" onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors focus:outline-none"
>
{showPassword ? (
<FiEye className="w-5 h-5" strokeWidth={2} />
) : (
<FiEyeOff className="w-5 h-5" strokeWidth={2} />
)}
</button>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-bold text-slate-700 ml-1">Confirmar contraseña</label>
<div className="relative">
<input
type={showConfirmPassword ? "text" : "password"}
required autoComplete="new-password"
className="w-full pl-5 pr-12 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
/>
<button
type="button" onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors focus:outline-none"
>
{showConfirmPassword ? (
<FiEye className="w-5 h-5" strokeWidth={2} />
) : (
<FiEyeOff className="w-5 h-5" strokeWidth={2} />
)}
</button>
</div>
</div>
<button type="submit" className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl transition-all shadow-sm active:scale-95 mt-2">
Registrarse
</button>
</form>
<p className="mt-8 text-center text-sm text-slate-500 font-medium">
¿Ya tienes cuenta? <Link to="/login" className="text-blue-600 hover:underline font-extrabold">Inicia sesión aquí</Link>
</p>
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import MainLayout from '../components/layout/MainLayout';
import DocEditor from '../pages/DocEditor';
import Login from '../pages/Login';
import Register from '../pages/Register';
import History from '../pages/History';
export default function AppRouter() {
return (
<Router>
<MainLayout>
<Routes>
<Route path="/" element={<Navigate to="/editor" replace />} />
<Route path="/editor" element={<DocEditor />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/history" element={<History />} />
</Routes>
</MainLayout>
</Router>
);
}
+18
View File
@@ -0,0 +1,18 @@
import api from '../lib/api';
export const authService = {
login: async (email, password) => {
const response = await api.post('/auth/login', { email, password });
return response.data;
},
register: async (username, email, password) => {
const response = await api.post('/auth/register', { username, email, password });
return response.data;
},
getCurrentUser: async () => {
const response = await api.get('/auth/me');
return response.data;
}
};
+51
View File
@@ -0,0 +1,51 @@
import api from '../lib/api';
export const calculateValueFunction = async (payload) => {
try {
const response = await api.post('/criteria/doc/value-function', payload);
return response.data;
} catch (error) {
if (error.response && error.response.data) throw error.response.data;
throw error;
}
};
export const buildFuzzyGraph = async (payload) => {
try {
const response = await api.post('/criteria/doc-it2mf/build', payload);
return response.data;
} catch (error) {
if (error.response && error.response.data) throw error.response.data;
throw error;
}
};
export const saveToHistory = async (payload) => {
try {
const response = await api.post('/history/add', payload);
return response.data;
} catch (error) {
if (error.response && error.response.data) throw error.response.data;
throw error;
}
};
export const getUserHistory = async () => {
try {
const response = await api.get('/history/user');
return response.data;
} catch (error) {
if (error.response && error.response.data) throw error.response.data;
throw error;
}
};
export const deleteHistoryItem = async (historyId) => {
try {
const response = await api.delete(`/history/delete/${historyId}`);
return response.data;
} catch (error) {
if (error.response && error.response.data) throw error.response.data;
throw error;
}
};
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})