+13
@@ -0,0 +1,13 @@
|
||||
# Caché de Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Variables de entorno
|
||||
.env
|
||||
.env*
|
||||
|
||||
|
||||
# Configuraciones del editor
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}
|
||||
@@ -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))]}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -0,0 +1,10 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
motor
|
||||
pydantic
|
||||
passlib
|
||||
bcrypt==4.0.1
|
||||
email-validator
|
||||
slowapi
|
||||
httpx
|
||||
PyJWT
|
||||
@@ -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:
|
||||
@@ -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?
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
CMD ["npm", "run", "dev", "--", "--host"]
|
||||
@@ -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.
|
||||
@@ -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_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
Generated
+3618
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const AuthContext = createContext();
|
||||
|
||||
export const useAuth = () => {
|
||||
return useContext(AuthContext);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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(),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user