Merge pull request #26 from AlexisLopez-Dev/feature/frontend-v3

Feature/frontend v3
This commit is contained in:
Alexis López
2026-04-15 08:47:47 +02:00
committed by GitHub
35 changed files with 1290 additions and 617 deletions
+9 -9
View File
@@ -1,6 +1,6 @@
# models/docit2mf_models.py # models/docit2mf_models.py
from pydantic import BaseModel, field_validator from pydantic import BaseModel, Field, field_validator, ValidationInfo
from typing import List, Tuple, Union from typing import List, Tuple, Union
@@ -9,8 +9,8 @@ BlankCardInput = Union[int, Tuple[int, int], List[int]]
class DoCIT2MFRequest(BaseModel): class DoCIT2MFRequest(BaseModel):
term: str term: str
core: Tuple[float, float] core: tuple[float, float] = Field(..., description="Núcleo del conjunto difuso: [a, b]")
support: Tuple[float, float] support: tuple[float, float] = Field(..., description="Soporte del conjunto difuso: [c, d]")
left_nodes_x: List[float] left_nodes_x: List[float]
left_blank_cards: List[BlankCardInput] left_blank_cards: List[BlankCardInput]
@@ -28,20 +28,20 @@ class DoCIT2MFRequest(BaseModel):
def core_valid(cls, v): def core_valid(cls, v):
a, b = v a, b = v
if a > b: if a > b:
raise ValueError("El núcleo debe cumplir a <= b.") raise ValueError("el 'Inicio del Núcleo' debe ser menor o igual al 'Fin del Núcleo'.")
return v return v
@field_validator("support") @field_validator("support")
def support_valid(cls, v, info): def support_valid(cls, v, info: ValidationInfo):
c, d = v c, d = v
if c >= d: if c > d:
raise ValueError("El soporte debe cumplir c < d.") raise ValueError("el 'Inicio del Soporte' debe ser menor o igual al 'Fin del Soporte'.")
core = info.data.get("core") core = info.data.get("core")
if core: if core:
a, b = core a, b = core
if not (c <= a <= b <= d): if not (c <= a and b <= d):
raise ValueError("El núcleo debe estar dentro del soporte.") raise ValueError("los valores del 'Núcleo' deben estar estrictamente dentro del 'Soporte'.")
return v return v
@field_validator("left_blank_cards", "right_blank_cards") @field_validator("left_blank_cards", "right_blank_cards")
+3 -3
View File
@@ -1,7 +1,7 @@
# api/routers/docit2mf_build.py # api/routers/docit2mf_build.py
import logging import logging
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Request
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from api.models.docit2mf_models import DoCIT2MFMultiRequest from api.models.docit2mf_models import DoCIT2MFMultiRequest
@@ -14,11 +14,11 @@ logger = logging.getLogger(__name__)
@router.post("/doc-it2mf/build") @router.post("/doc-it2mf/build")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def build_doc_it2mf(request: DoCIT2MFMultiRequest): async def build_doc_it2mf(request: Request, body: DoCIT2MFMultiRequest):
results = [] results = []
try: try:
for level in request.levels: for level in body.levels:
results.append(build_it2mf_from_level(level)) results.append(build_it2mf_from_level(level))
except ValueError as e: except ValueError as e:
logger.warning(f"Validation error in doc-it2mf/build: {str(e)}") logger.warning(f"Validation error in doc-it2mf/build: {str(e)}")
+17 -4
View File
@@ -1,19 +1,18 @@
# api/routers/google_auth.py
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from datetime import datetime, timedelta
import httpx import httpx
import os import os
import jwt import jwt
from api.database.mongodb import users_collection from api.database.mongodb import users_collection
from api.utils.security import create_access_token
router = APIRouter(prefix="/auth/google", tags=["auth"]) router = APIRouter(prefix="/auth/google", tags=["auth"])
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET") GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI") REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI")
SECRET_KEY = os.getenv("SECRET_KEY")
# ----------------------------- # -----------------------------
@@ -90,6 +89,20 @@ async def google_callback(request: Request):
else: else:
user_id = user["_id"] user_id = user["_id"]
token = create_access_token({"user_id": str(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}") return RedirectResponse(f"http://localhost:5173/login?token={token}")
+17 -9
View File
@@ -24,7 +24,7 @@ def _extract_bounds(values: List[Union[int, List[int], tuple]], mode: str) -> Li
def _sort_nodes(nodes): def _sort_nodes(nodes):
"""Ordena los nodos por su coordenada X.""" """Ordena los nodos por su coordenada X."""
return sorted(nodes, key=lambda p: p[0]) return sorted([list(p) for p in nodes], key=lambda p: p[0])
def _enforce_upper_ge_lower(lower, upper): def _enforce_upper_ge_lower(lower, upper):
@@ -34,14 +34,14 @@ def _enforce_upper_ge_lower(lower, upper):
""" """
# left nodes # left nodes
for i in range(len(lower["left_nodes"])): for i in range(len(lower["left_nodes"])):
lx, ly = lower["left_nodes"][i] ly = lower["left_nodes"][i][1]
ux, uy = upper["left_nodes"][i] uy = upper["left_nodes"][i][1]
upper["left_nodes"][i][1] = max(uy, ly) upper["left_nodes"][i][1] = max(uy, ly)
# right nodes # right nodes
for i in range(len(lower["right_nodes"])): for i in range(len(lower["right_nodes"])):
lx, ly = lower["right_nodes"][i] ly = lower["right_nodes"][i][1]
ux, uy = upper["right_nodes"][i] uy = upper["right_nodes"][i][1]
upper["right_nodes"][i][1] = max(uy, ly) upper["right_nodes"][i][1] = max(uy, ly)
return upper return upper
@@ -73,9 +73,13 @@ def build_it2mf_from_level(level: DoCIT2MFRequest):
right_nodes_x=level.right_nodes_x, right_nodes_x=level.right_nodes_x,
right_blank_cards=right_min, right_blank_cards=right_min,
) )
lower = build_doc_mf_level(lower_level)
# Ordenar nodos LMF # Convertir a dict para poder manipular como diccionario
lower = build_doc_mf_level(lower_level)
if hasattr(lower, "model_dump"):
lower = lower.model_dump()
# Asegurar que los nodos son listas mutables y ordenar
lower["left_nodes"] = _sort_nodes(lower["left_nodes"]) lower["left_nodes"] = _sort_nodes(lower["left_nodes"])
lower["right_nodes"] = _sort_nodes(lower["right_nodes"]) lower["right_nodes"] = _sort_nodes(lower["right_nodes"])
@@ -94,9 +98,13 @@ def build_it2mf_from_level(level: DoCIT2MFRequest):
right_nodes_x=level.right_nodes_x, right_nodes_x=level.right_nodes_x,
right_blank_cards=right_max, right_blank_cards=right_max,
) )
upper = build_doc_mf_level(upper_level)
# Ordenar nodos UMF # Convertir a dict para poder manipular como diccionario
upper = build_doc_mf_level(upper_level)
if hasattr(upper, "model_dump"):
upper = upper.model_dump()
# Asegurar que los nodos son listas mutables y ordenar
upper["left_nodes"] = _sort_nodes(upper["left_nodes"]) upper["left_nodes"] = _sort_nodes(upper["left_nodes"])
upper["right_nodes"] = _sort_nodes(upper["right_nodes"]) upper["right_nodes"] = _sort_nodes(upper["right_nodes"])
+10
View File
@@ -12,6 +12,7 @@
"axios": "^1.13.6", "axios": "^1.13.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-icons": "^5.6.0",
"react-router-dom": "^7.13.2", "react-router-dom": "^7.13.2",
"recharts": "^3.8.0", "recharts": "^3.8.0",
"tailwindcss": "^4.2.2" "tailwindcss": "^4.2.2"
@@ -3077,6 +3078,15 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-icons": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz",
"integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
+1
View File
@@ -14,6 +14,7 @@
"axios": "^1.13.6", "axios": "^1.13.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-icons": "^5.6.0",
"react-router-dom": "^7.13.2", "react-router-dom": "^7.13.2",
"recharts": "^3.8.0", "recharts": "^3.8.0",
"tailwindcss": "^4.2.2" "tailwindcss": "^4.2.2"
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
import { AppRouter } from './routers/AppRouter'; import AppRouter from './routers/AppRouter';
function App() { function App() {
return ( return (
+7 -11
View File
@@ -1,3 +1,5 @@
import React from 'react';
export default function BlankCardsCounter({ index, blankCardsCount, handleBlankCardChange }) { export default function BlankCardsCounter({ index, blankCardsCount, handleBlankCardChange }) {
const maxCardsPerRow = 7; const maxCardsPerRow = 7;
@@ -7,15 +9,10 @@ export default function BlankCardsCounter({ index, blankCardsCount, handleBlankC
} }
return ( return (
<div className="flex flex-col items-center mx-1 my-2 z-10 w-32"> <div className="flex flex-col items-center w-full">
<div className="relative w-full h-52 flex flex-col items-center justify-center shrink-0"> {/* 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">
{/* Línea conectora horizontal */}
<div className="absolute w-[calc(100%+2rem)] h-1 bg-slate-200 top-[50%] -translate-y-1/2 z-0 rounded"></div>
{/* Botones - y + */}
<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">
<button <button
onClick={() => handleBlankCardChange(index, -1)} 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" 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"
@@ -23,7 +20,7 @@ export default function BlankCardsCounter({ index, blankCardsCount, handleBlankC
<div className="flex flex-col items-center leading-none min-w-[3rem]"> <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-[9px] font-bold text-slate-400 uppercase tracking-widest mb-1">Blancas</span>
<span className="text-base font-black text-blue-600">{blankCardsCount}</span> <span className="text-base font-black text-blue-600 leading-none">{blankCardsCount}</span>
</div> </div>
<button <button
@@ -31,11 +28,10 @@ export default function BlankCardsCounter({ index, blankCardsCount, handleBlankC
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" 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> >+</button>
</div> </div>
</div>
{/* Cartas blancas */} {/* Cartas blancas */}
{blankCardsCount > 0 && ( {blankCardsCount > 0 && (
<div className="flex flex-col items-center gap-y-3 w-full justify-center -mt-16 relative z-0"> <div className="flex flex-col items-center gap-y-2 w-full mt-3 relative z-0">
{rows.map((row, rowIndex) => ( {rows.map((row, rowIndex) => (
<div key={rowIndex} className="flex flex-row items-center justify-center -space-x-4"> <div key={rowIndex} className="flex flex-row items-center justify-center -space-x-4">
{row.map((_, colIndex) => ( {row.map((_, colIndex) => (
+2 -2
View File
@@ -10,9 +10,9 @@ export default function CardEditor({ index, level, handleLevelChange, handleRemo
)} )}
<span className="absolute top-3 left-4 text-sm font-black text-slate-300">{index + 1}</span> <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> <span className="absolute bottom-3 right-4 text-sm font-black text-slate-300 rotate-180">{index + 1}</span>
<input type="text" placeholder="Etiqueta..." 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'}`} /> <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>
<div className="h-6 mt-2">{error && <p className="text-red-500 text-xs font-semibold animate-pulse">Escribe una etiqueta</p>}</div> <div className="h-6 mt-2">{error && <p className="text-red-500 text-xs font-semibold animate-pulse">Escribe un término</p>}</div>
</div> </div>
); );
} }
+12
View File
@@ -0,0 +1,12 @@
export default function EyeIcon({ isOpen }) {
return isOpen ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
);
}
@@ -37,7 +37,7 @@ export default function Step1BaseScale({
const currentScale = isZoomActive && needsZoom ? dynamicScale : 1; const currentScale = isZoomActive && needsZoom ? dynamicScale : 1;
return ( return (
<div className="w-full bg-white p-6 rounded-2xl shadow-sm border border-slate-200 mb-6 flex flex-col items-center animate-fade-in relative overflow-visible"> <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"> <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"> <h2 className="text-xl font-bold text-slate-800">
@@ -59,30 +59,48 @@ export default function Step1BaseScale({
<CriterionInput criterionName={criterionName} setCriterionName={handleCriterionChange} error={errors.criterion} /> <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 pb-8 pt-4 px-4' : 'overflow-hidden flex justify-center pb-8 pt-4'}`}> <div ref={containerRef} className={`w-full mt-2 transition-all relative ${!isZoomActive && needsZoom ? 'overflow-x-auto flex justify-start pb-12 pt-4 px-4 custom-scrollbar' : 'overflow-visible flex justify-center pb-12 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 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"> <div ref={tableRef} className="flex flex-row items-start relative px-10 overflow-visible">
{levels.map((level, index) => ( {levels.map((level, index) => (
<React.Fragment key={index}> <React.Fragment key={index}>
<div className="flex flex-col items-center mx-2 my-2 relative z-20">
{/* 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} /> <CardEditor index={index} level={level} handleLevelChange={handleLevelChange} handleRemoveLevel={handleRemoveLevel} totalLevels={levels.length} error={errors.levels[index]} canRemove={levels.length > 3} />
</div> </div>
{/* HUECO ENTRE CARTAS Y CONTADOR */}
{index < levels.length - 1 && ( {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} /> <BlankCardsCounter index={index} blankCardsCount={blankCards[index]} handleBlankCardChange={handleBlankCardChange} />
</div>
</div>
)} )}
</React.Fragment> </React.Fragment>
))} ))}
<div className="mx-1 my-2 h-52 flex items-center justify-center">
<div className="w-10 h-1 bg-slate-200 rounded"></div> {/* 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> </div>
{/* BOTÓN AÑADIR NIVEL */}
<div className="flex flex-col items-center mx-2 relative z-20">
<AddLevelButton handleAddLevel={handleAddLevel} /> <AddLevelButton handleAddLevel={handleAddLevel} />
</div> </div>
</div> </div>
</div>
</div> </div>
<div className="w-full max-w-lg mt-2 pt-6 border-t border-slate-200 flex flex-col items-center z-20 relative bg-white"> {/* Generar Gráfica Continua */}
<div className="w-full max-w-lg mt-8 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'}`}> <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'} {isLoading ? 'Calculando...' : 'Generar Gráfica Continua'}
</button> </button>
@@ -11,7 +11,8 @@ export default function Step2FuzzyModeling({
handleFinalSubmit, handleFinalSubmit,
onBack, onBack,
subscales, subscales,
onOpenSubscale onOpenSubscale,
submitError
}) { }) {
const scaleKeys = Object.keys(baseScale); const scaleKeys = Object.keys(baseScale);
@@ -51,6 +52,20 @@ export default function Step2FuzzyModeling({
colors={CHART_COLORS} 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 <Controls
selectedTerm={selectedTerm} selectedTerm={selectedTerm}
currentMf={mfDefinitions[selectedTerm]} currentMf={mfDefinitions[selectedTerm]}
@@ -64,7 +79,7 @@ export default function Step2FuzzyModeling({
<div className="w-full mt-8 flex justify-center"> <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"> <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">
Guardar Todo el Espectro Difuso Generar el Espectro Difuso
</button> </button>
</div> </div>
</div> </div>
@@ -1,103 +1,92 @@
import { useMemo } from 'react'; import React, { useState, useEffect, memo } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { CHART_COLORS } from '../../config'; import { useGraphData } from './finalGraph/useGraphData';
import { GraphTooltip } from './finalGraph/GraphTooltip';
const Step3FinalGraph = ({ data }) => { const Step3FinalGraph = memo(({ data, criterionName }) => {
const sortedResults = useMemo(() => { const { sortedResults, denseData } = useGraphData(data);
if (!data || !data.results) return []; const [isReady, setIsReady] = useState(false);
const withPermanentColors = data.results.map((item, index) => ({ useEffect(() => {
...item, const timer = setTimeout(() => {
color: CHART_COLORS[index % CHART_COLORS.length] setIsReady(true);
})); }, 400);
return () => clearTimeout(timer);
}, []);
return withPermanentColors.sort((a, b) => { if (!data || (!data.levels && !data.results)) {
const coreA = Array.isArray(a.core) ? Number(a.core[0]) : 0; return <p className="text-center mt-10 text-slate-500">Cargando datos...</p>;
const coreB = Array.isArray(b.core) ? Number(b.core[0]) : 0;
return coreA - coreB;
});
}, [data]);
if (!data || !data.results) {
return <p className="text-center mt-10 text-slate-500">Cargando gráfico final...</p>;
} }
return ( return (
<div className="w-full h-[550px] mt-2 bg-white p-6 rounded-2xl shadow-sm border border-slate-200 flex flex-col"> <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">
<h3 className="text-2xl font-bold mb-4 text-center text-slate-800">Espectro Difuso Final</h3> <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 */} {/* Gráfica */}
<div className="flex-1 w-full min-h-[400px]"> <div className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${isReady ? 'opacity-100' : 'opacity-0'}`}>
{isReady && (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart margin={{ top: 10, right: 30, left: 10, bottom: 10 }}> <ComposedChart data={denseData} margin={{ top: 15, right: 50, left: 10, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" opacity={0.5} vertical={false} /> <CartesianGrid strokeDasharray="3 3" opacity={0.5} vertical={false} />
<XAxis <XAxis
dataKey="x" dataKey="x" type="number" domain={[0, 1]} allowDataOverflow={true}
type="number" ticks={[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]}
domain={[0, 1]}
tickCount={11}
tick={{ fill: '#475569', fontWeight: 600, fontSize: 14 }} tick={{ fill: '#475569', fontWeight: 600, fontSize: 14 }}
allowDuplicatedCategory={false}
/> />
<YAxis <YAxis
domain={[0, 1]} domain={[0, 1]} tickCount={6} tickFormatter={(val) => Number(val.toFixed(2))}
tickCount={6}
tick={{ fill: '#475569', fontSize: 14 }} tick={{ fill: '#475569', fontSize: 14 }}
/> />
<Tooltip <Tooltip
formatter={(value, name) => [Number(value).toFixed(3), name]} content={<GraphTooltip sortedResults={sortedResults} />}
labelFormatter={(label) => `X: ${Number(label).toFixed(3)}`} cursor={{ stroke: '#cbd5e1', strokeWidth: 1, strokeDasharray: '5 5' }}
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }} isAnimationActive={false}
/> />
{sortedResults.map((item) => { {sortedResults.map((item) => (
const lineData = [...(item.left_nodes || []), ...(item.right_nodes || [])].map(node => ({ <React.Fragment key={item.term}>
x: Number(node[0]), {item.isType2 ? (
y: Number(node[1]) <>
})); <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} />
return ( <Line type="linear" dataKey={`${item.term}_lower`} stroke={item.color} strokeWidth={3} dot={false} isAnimationActive={false} />
<Line </>
key={item.term} ) : (
data={lineData} <Line type="linear" dataKey={item.term} stroke={item.color} strokeWidth={4} dot={false} isAnimationActive={false} />
type="linear" )}
dataKey="y" </React.Fragment>
name={item.term.toUpperCase()} ))}
stroke={item.color} </ComposedChart>
strokeWidth={4}
dot={{ r: 5, strokeWidth: 2, fill: '#fff' }}
activeDot={{ r: 8 }}
isAnimationActive={true}
animationDuration={1500}
/>
);
})}
</LineChart>
</ResponsiveContainer> </ResponsiveContainer>
)}
</div>
</div> </div>
{/* Leyenda */} {/* Leyenda */}
<div className="flex flex-wrap justify-center gap-x-8 gap-y-3 mt-6 pb-2"> <div className="flex flex-wrap justify-center gap-x-8 gap-y-3 mt-6 pb-2 relative z-10">
{sortedResults.map((item) => ( {sortedResults.map((item) => (
<div key={`legend-${item.term}`} className="flex items-center gap-2"> <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 <span className="text-sm font-medium uppercase tracking-wide" style={{ color: item.color }}>{item.term}</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> </div>
</div> </div>
); );
}; });
export default Step3FinalGraph; export default Step3FinalGraph;
+119 -22
View File
@@ -3,30 +3,78 @@ import BlankCardsCounter from '../BlankCardsCounter';
export default function SubscaleModal({ onClose, onSave, targetInfo }) { export default function SubscaleModal({ onClose, onSave, targetInfo }) {
const [cardsCount, setCardsCount] = useState(targetInfo?.initialData?.cardsCount || 2); const initialCount = Math.max(3, targetInfo?.initialData?.cardsCount || 3);
const [blankCards, setBlankCards] = useState(targetInfo?.initialData?.blankCards || [0]); 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 = () => { const handleAddCard = () => {
setCardsCount(prev => prev + 1); setCardsCount(prev => prev + 1);
setBlankCards([...blankCards, 0]); setBlankCards([...blankCards, { min: 0, max: 0, isRange: false }]);
}; };
const handleRemoveCard = () => { const handleRemoveCard = () => {
if (cardsCount <= 2) return; if (cardsCount <= 3) return;
setCardsCount(prev => prev - 1); setCardsCount(prev => prev - 1);
setBlankCards(blankCards.slice(0, -1)); setBlankCards(blankCards.slice(0, -1));
}; };
const handleBlankCardChange = (index, delta) => { const handleExactChange = (index, delta) => {
const newBlanks = [...blankCards]; const newBlanks = [...blankCards];
if (newBlanks[index] + delta >= 0) { const newVal = newBlanks[index].min + delta;
newBlanks[index] += delta; if (newVal >= 0) {
newBlanks[index].min = newVal;
newBlanks[index].max = newVal;
setBlankCards(newBlanks); 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 handleSave = () => {
onSave(targetInfo.term, targetInfo.side, { cardsCount, blankCards }); const payloadBlanks = blankCards.map(b => b.isRange ? [b.min, b.max] : b.min);
onSave(targetInfo.term, targetInfo.side, { cardsCount, blankCards: payloadBlanks });
}; };
const handleDelete = () => { const handleDelete = () => {
@@ -34,10 +82,10 @@ export default function SubscaleModal({ onClose, onSave, targetInfo }) {
}; };
return ( return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm animate-fade-in"> <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-5xl p-8 rounded-3xl shadow-2xl mx-4 flex flex-col"> <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-6 border-b pb-4"> <div className="flex justify-between items-center mb-4 border-b pb-4 shrink-0">
<div> <div>
<h2 className="text-2xl font-bold text-slate-800">Diseñar Subescala</h2> <h2 className="text-2xl font-bold text-slate-800">Diseñar Subescala</h2>
<p className="text-slate-500 font-medium"> <p className="text-slate-500 font-medium">
@@ -47,35 +95,84 @@ export default function SubscaleModal({ onClose, onSave, targetInfo }) {
<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> <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>
{/* Tablero */} <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="w-full py-10 overflow-x-auto flex justify-start px-4">
<div className="flex flex-row items-start min-w-max relative"> <div className="flex flex-row items-start min-w-max relative">
{Array.from({ length: cardsCount }).map((_, index) => ( {Array.from({ length: cardsCount }).map((_, index) => (
<React.Fragment key={index}> <React.Fragment key={index}>
<div className="flex flex-col items-center mx-2 my-2 relative z-20"> {/* 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"> <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 > 2 && index === cardsCount - 1 && ( {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> <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> <span className="text-4xl font-black text-slate-200">{index + 1}</span>
</div> </div>
</div> </div>
{/* HUECO ENTRE CARTAS */}
{index < cardsCount - 1 && ( {index < cardsCount - 1 && (
<BlankCardsCounter index={index} blankCardsCount={blankCards[index]} handleBlankCardChange={handleBlankCardChange} /> <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> </React.Fragment>
))} ))}
<div className="mx-2 my-2 h-40 flex items-center"> {/* Botón Añadir Carta */}
<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"> <div className="flex flex-col items-center mx-2 relative z-20">
<span className="text-3xl">+</span> <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> </button>
</div> </div>
</div> </div>
</div> </div>
{/* Botones */} {/* Botones de Acción */}
<div className="mt-8 flex justify-between items-center border-t pt-6"> <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"> <button onClick={handleDelete} className="px-6 py-3 rounded-xl font-bold text-red-500 hover:bg-red-50 transition-colors">
Borrar Subescala Borrar Subescala
</button> </button>
@@ -0,0 +1,53 @@
const TermInfo = ({ title, color, children }) => (
<div className="flex flex-col text-xs font-medium bg-slate-50 p-2.5 rounded-xl border border-slate-100">
<span className="uppercase font-black mb-1.5" style={{ color }}>{title}</span>
{children}
</div>
);
export const GraphTooltip = ({ active, payload, label, sortedResults }) => {
if (!active || !payload || !payload.length) return null;
const dataPoint = payload[0].payload;
const activeTerms = sortedResults.filter(item =>
item.isType2 ? (dataPoint[`${item.term}_upper`] ?? 0) > 0 : (dataPoint[item.term] ?? 0) > 0
);
if (activeTerms.length === 0) return null;
return (
<div className="bg-white p-4 border border-slate-200 shadow-xl rounded-2xl min-w-[200px] animate-fade-in relative z-50">
<p className="text-slate-800 font-black border-b border-slate-100 pb-2 mb-3 text-sm flex justify-between items-center gap-4">
<span>Punto X:</span> <span className="text-blue-600">{Number(label).toFixed(3)}</span>
</p>
<div className="flex flex-col gap-3">
{activeTerms.map(item => {
if (item.isType2) {
const lower = dataPoint[`${item.term}_lower`] ?? 0;
const upper = dataPoint[`${item.term}_upper`] ?? 0;
const range = Math.abs(upper - lower);
return range <= 0.001 ? (
<TermInfo key={item.term} title={item.term} color={item.color}>
<span className="text-slate-600 flex justify-between gap-4">Pertenencia: <b>{Number(upper).toFixed(3)}</b></span>
</TermInfo>
) : (
<TermInfo key={item.term} title={item.term} color={item.color}>
<span className="text-slate-600 flex justify-between gap-4">Mínimo: <b>{Number(lower).toFixed(3)}</b></span>
<span className="text-slate-600 flex justify-between gap-4 mt-0.5">Máximo: <b>{Number(upper).toFixed(3)}</b></span>
<span className="text-slate-500 font-bold mt-1.5 pt-1.5 border-t border-slate-200 flex justify-between gap-4">
Incertidumbre: <span>{Number(range).toFixed(3)}</span>
</span>
</TermInfo>
);
}
return (
<TermInfo key={item.term} title={item.term} color={item.color}>
<span className="text-slate-600 flex justify-between gap-4">Pertenencia: <b>{Number(dataPoint[item.term]).toFixed(3)}</b></span>
</TermInfo>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,89 @@
import { useMemo } from 'react';
import { CHART_COLORS } from '../../../config';
const interpolateY = (x, nodes) => {
if (!nodes || nodes.length === 0) return null;
const EPSILON = 1e-5;
const MICRO_STEP = 0.0001;
const firstX = nodes[0][0];
const lastX = nodes[nodes.length - 1][0];
if (x < firstX - MICRO_STEP - EPSILON) return null;
if (x > lastX + MICRO_STEP + EPSILON) return null;
if (x < firstX - EPSILON) return 0;
if (x > lastX + EPSILON) return 0;
for (let i = nodes.length - 1; i >= 0; i--) {
if (Math.abs(nodes[i][0] - x) < EPSILON) return nodes[i][1];
}
for (let i = 0; i < nodes.length - 1; i++) {
const x1 = nodes[i][0];
const x2 = nodes[i + 1][0];
if (Math.abs(x2 - x1) < EPSILON) continue;
if (x >= x1 && x <= x2) {
const y1 = nodes[i][1];
const y2 = nodes[i + 1][1];
return y1 + ((x - x1) * (y2 - y1)) / (x2 - x1);
}
}
return null;
};
export const useGraphData = (data) => {
const sortedResults = useMemo(() => {
const rawItems = data?.levels || data?.results || [];
const processed = rawItems.map((item, index) => {
const isType2 = !!item.lower && !!item.upper;
const color = CHART_COLORS[index % CHART_COLORS.length] || '#333';
let termName = item.term || (item.lower && item.lower.term) || `Termino ${index}`;
if (isType2) {
const lowerNodes = [...(item.lower.left_nodes || []), ...(item.lower.right_nodes || [])].map(n => [Number(n[0]), Number(n[1])]).sort((a,b)=>a[0]-b[0]);
const upperNodes = [...(item.upper.left_nodes || []), ...(item.upper.right_nodes || [])].map(n => [Number(n[0]), Number(n[1])]).sort((a,b)=>a[0]-b[0]);
const coreVal = Array.isArray(item.lower.core) ? Number(item.lower.core[0]) : 0;
return { ...item, term: termName, isType2, lowerNodes, upperNodes, color, coreVal };
} else {
const nodes = [...(item.left_nodes || []), ...(item.right_nodes || [])].map(n => [Number(n[0]), Number(n[1])]).sort((a,b)=>a[0]-b[0]);
const coreVal = Array.isArray(item.core) ? Number(item.core[0]) : 0;
return { ...item, term: termName, isType2, nodes, color, coreVal };
}
});
return processed.sort((a, b) => a.coreVal - b.coreVal);
}, [data]);
const denseData = useMemo(() => {
const xSet = new Set();
const steps = 1000;
for (let i = 0; i <= steps; i++) xSet.add(Number((i / steps).toFixed(4)));
sortedResults.forEach(item => {
const addNodes = (nodes) => nodes.forEach(n => {
const x = n[0];
xSet.add(Number((x - 0.0001).toFixed(4)));
xSet.add(Number(x.toFixed(4)));
xSet.add(Number((x + 0.0001).toFixed(4)));
});
item.isType2 ? (addNodes(item.lowerNodes), addNodes(item.upperNodes)) : addNodes(item.nodes);
});
const xValues = Array.from(xSet).sort((a, b) => a - b);
return xValues.map(x => {
const point = { x };
sortedResults.forEach(item => {
if (item.isType2) {
const lowerRaw = interpolateY(x, item.lowerNodes);
const upperRaw = interpolateY(x, item.upperNodes);
point[`${item.term}_lower`] = lowerRaw;
point[`${item.term}_upper`] = upperRaw;
point[`${item.term}_range`] = (lowerRaw === null && upperRaw === null) ? null : [lowerRaw ?? 0, upperRaw ?? 0];
} else {
point[item.term] = interpolateY(x, item.nodes);
}
});
return point;
});
}, [sortedResults]);
return { sortedResults, denseData };
};
+95
View File
@@ -0,0 +1,95 @@
export default function Footer() {
return (
<footer className="bg-white border-t border-slate-200 mt-auto shrink-0 w-full pt-12 pb-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-8 lg:gap-6">
{/* Proyecto */}
<div className="lg:col-span-4 flex flex-col">
<div className="flex items-center gap-3 mb-3">
<span className="text-xl font-black text-slate-800 tracking-tight">Deck of Cards</span>
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-[10px] font-black uppercase tracking-widest rounded-md">
Software Científico
</span>
</div>
<p className="text-sm text-slate-500 leading-relaxed max-w-sm">
Plataforma web para la elicitación de escalas de valor y construcción de conjuntos difusos interpretables (DoC-MF).
</p>
</div>
{/* Desarrollo */}
<div className="lg:col-span-3 flex flex-col">
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-3">Ingeniería y Desarrollo</h4>
<ul className="text-sm font-bold text-slate-700 space-y-2">
<li className="flex flex-wrap items-center gap-2">
Alexis López Moral
<span className="text-slate-400 font-medium text-[10px] font-mono bg-slate-50 border border-slate-100 px-1.5 py-0.5 rounded">Frontend</span>
</li>
<li className="flex flex-wrap items-center gap-2">
Mireya Cueto Garrido
<span className="text-slate-400 font-medium text-[10px] font-mono bg-slate-50 border border-slate-100 px-1.5 py-0.5 rounded">Backend</span>
</li>
</ul>
</div>
{/* Dirección Científica */}
<div className="lg:col-span-2 flex flex-col">
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-3">Dirección Científica</h4>
<p className="text-sm font-bold text-slate-700">Luis Martínez López</p>
</div>
{/* Enlaces Institucionales y Código */}
<div className="lg:col-span-3 flex flex-col gap-5 sm:items-start lg:items-end">
{/* Universidad de Jaén */}
<a
href="https://www.ujaen.es/"
target="_blank" rel="noopener noreferrer"
className="group flex items-center gap-3 w-fit"
title="Ir a la web oficial de la Universidad de Jaén"
>
<div className="text-right border-r-2 border-slate-300 group-hover:border-blue-600 pr-3 flex flex-col justify-center h-9 transition-colors">
<span className="text-xs font-black text-slate-800 uppercase tracking-widest leading-none mb-1">Universidad</span>
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-[0.3em] leading-none">de Jaén</span>
</div>
<img
src="/uja-logo.png"
alt="Logo UJA"
className="w-9 h-9 object-contain grayscale group-hover:grayscale-0 transition-all opacity-80 group-hover:opacity-100"
/>
</a>
{/* Repositorio GitHub */}
<a
href="https://github.com/alexislopez-dev/deck-of-cards"
target="_blank" rel="noopener noreferrer"
className="group flex items-center gap-3 w-fit"
title="Ver código fuente en GitHub"
>
<div className="text-right border-r-2 border-slate-300 group-hover:border-slate-800 pr-3 flex flex-col justify-center h-9 transition-colors">
<span className="text-xs font-black text-slate-800 uppercase tracking-widest leading-none mb-1">Repositorio</span>
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-[0.3em] leading-none">Oficial</span>
</div>
<svg className="w-9 h-9 text-slate-400 group-hover:text-slate-800 transition-colors" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
</svg>
</a>
</div>
</div>
{/* Sub-Footer: Copyright y Referencia Científica */}
<div className="mt-6 pt-6 border-t border-slate-100 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">
© {new Date().getFullYear()} Deck of Cards App.
</p>
<p className="text-[10px] font-medium text-slate-400 text-center md:text-right">
Basado en la metodología DoC-MF propuesta por D. García-Zamora, B. Dutta, J.R. Figueira y L. Martínez (EJOR, 2024).
</p>
</div>
</div>
</footer>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { FiLogIn, FiLogOut } from 'react-icons/fi';
export default function Header() {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { user, logout, isAuthenticated } = useAuth();
const userInitial = user?.username ? user.username[0].toUpperCase() : "U";
const handleLogout = () => {
logout();
setIsDropdownOpen(false);
navigate('/login');
};
const isActive = (path) => {
return location.pathname === path || (path === '/editor' && location.pathname === '/');
};
return (
<header className="bg-white shadow-sm border-b border-slate-200 sticky top-0 z-50 h-16 shrink-0 w-full">
<div className="max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 h-full flex items-center justify-between">
<Link to="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity whitespace-nowrap">
<img src="/favicon.svg" alt="Deck of Cards Logo" className="w-10 h-10 shadow-sm rounded-xl object-contain" />
<span className="text-2xl font-black bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-indigo-600 hidden sm:block">
Deck of Cards
</span>
</Link>
<div className="flex items-center gap-4 whitespace-nowrap">
<div className="flex items-center gap-1 mr-2">
<Link to="/editor" className={`text-sm font-bold px-4 py-2 rounded-lg transition-all ${isActive('/editor') ? 'text-blue-600' : 'text-slate-600 hover:text-blue-500'}`}>
Editor
</Link>
{isAuthenticated && (
<Link to="/history" className={`text-sm font-bold px-4 py-2 rounded-lg transition-all ${isActive('/history') ? 'text-blue-600' : 'text-slate-600 hover:text-blue-500'}`}>
Historial
</Link>
)}
</div>
{isAuthenticated ? (
<div className="relative border-l border-slate-200 pl-4">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="w-10 h-10 rounded-full bg-blue-100 text-blue-700 font-bold flex items-center justify-center border-2 border-blue-200 hover:bg-blue-200 transition-colors"
>
{userInitial}
</button>
{isDropdownOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsDropdownOpen(false)}></div>
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg border border-slate-100 py-2 z-50">
<div className="px-4 py-2 border-b border-slate-50">
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">Usuario</p>
<p className="text-sm font-bold text-slate-700 truncate">{user?.username}</p>
</div>
<button
onClick={handleLogout}
className="w-full flex items-center gap-2 px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 transition-colors"
>
<FiLogOut className="w-5 h-5" strokeWidth={2.5} />
Cerrar Sesión
</button>
</div>
</>
)}
</div>
) : (
<div className="flex items-center border-l border-slate-200 pl-4">
<Link
to="/login"
className="flex items-center gap-2 text-sm font-bold bg-blue-600 text-white px-5 py-2.5 rounded-xl shadow-sm hover:bg-blue-700 transition-all active:scale-95"
>
<FiLogIn className="w-5 h-5" strokeWidth={2.5} />
Acceder
</Link>
</div>
)}
</div>
</div>
</header>
);
}
+9 -17
View File
@@ -1,26 +1,18 @@
import { Outlet } from 'react-router-dom'; import Header from './Header';
import Footer from './Footer';
export default function MainLayout() { export default function MainLayout({ children }) {
return ( return (
<div className="min-h-screen bg-slate-50 font-sans text-slate-900"> <div className="min-h-screen flex flex-col bg-slate-50 font-sans">
{/* Cabecera */} <Header />
<header className="bg-white border-b border-slate-200 sticky top-0 z-50 shadow-sm">
<div className="max-w-7xl mx-auto px-4 h-14 flex items-center gap-3">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center shadow-inner">
<span className="text-white font-black text-xl leading-none">DoC</span>
</div>
<h1 className="text-xl font-bold text-slate-800">
Deck of Cards
</h1>
</div>
</header>
{/* Contenido principal */} <main className="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-8 flex flex-col">
<main className="max-w-7xl mx-auto px-4 py-6"> {children}
<Outlet />
</main> </main>
<Footer />
</div> </div>
); );
} }
@@ -22,7 +22,6 @@ export default function Controls({
</h3> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Lado izquierdo (Pendiente ascendente) */}
<div className="space-y-4 bg-slate-50 p-4 rounded-xl border border-slate-100"> <div className="space-y-4 bg-slate-50 p-4 rounded-xl border border-slate-100">
<div> <div>
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1"> <label className="flex justify-between text-xs font-bold text-slate-600 mb-1">
@@ -37,7 +36,6 @@ export default function Controls({
<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 }} /> <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>
{/* Botón subescala izquierda */}
<div className="pt-2 border-t border-slate-200 flex justify-end"> <div className="pt-2 border-t border-slate-200 flex justify-end">
<button <button
onClick={() => onOpenSubscale(selectedTerm, 'left', leftSubscale)} onClick={() => onOpenSubscale(selectedTerm, 'left', leftSubscale)}
@@ -48,22 +46,20 @@ export default function Controls({
</div> </div>
</div> </div>
{/* Lado derecho (Pendiente descendente) */}
<div className="space-y-4 bg-slate-50 p-4 rounded-xl border border-slate-100"> <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 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> <div>
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1"> <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> <span>Fin del Soporte (Punto inferior)</span><span style={{ color: selectedColor }}>{currentMf.supportEnd.toFixed(3)}</span>
</label> </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 }} /> <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>
<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>
{/* Botón subescala derecha */}
<div className="pt-2 border-t border-slate-200 flex justify-end"> <div className="pt-2 border-t border-slate-200 flex justify-end">
<button <button
onClick={() => onOpenSubscale(selectedTerm, 'right', rightSubscale)} onClick={() => onOpenSubscale(selectedTerm, 'right', rightSubscale)}
+7
View File
@@ -0,0 +1,7 @@
import { createContext, useContext } from 'react';
export const AuthContext = createContext();
export const useAuth = () => {
return useContext(AuthContext);
};
+42
View File
@@ -0,0 +1,42 @@
import { useState, useCallback } from 'react';
import { AuthContext } from './AuthContext';
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(() => {
try {
const storedUser = localStorage.getItem('user');
return storedUser ? JSON.parse(storedUser) : null;
} catch {
return null;
}
});
const login = useCallback((data) => {
const currentUser = data.user || data;
const token = data.access_token || data.token;
setUser(currentUser);
localStorage.setItem('user', JSON.stringify(currentUser));
if (token) {
localStorage.setItem('token', token);
}
}, []);
const logout = useCallback(() => {
setUser(null);
localStorage.removeItem('user');
localStorage.removeItem('token');
}, []);
return (
<AuthContext.Provider value={{
user,
isAuthenticated: !!user,
login,
logout
}}>
{children}
</AuthContext.Provider>
);
};
+29
View File
@@ -9,5 +9,34 @@ const api = Axios.create({
} }
}); });
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) => {
if (error.response && error.response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
if (error.response && error.response.data) {
return Promise.reject({
...error,
backendData: error.response.data
});
}
return Promise.reject(error);
}
);
export default api; export default api;
+3
View File
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.jsx' import App from './App.jsx'
import { AuthProvider } from './context/AuthProvider.jsx'
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<AuthProvider>
<App /> <App />
</AuthProvider>
</StrictMode>, </StrictMode>,
) )
-236
View File
@@ -1,236 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import CriterionInput from '../components/CriterionInput';
import CardEditor from '../components/CardEditor';
import BlankCardsCounter from '../components/BlankCardsCounter';
import AddLevelButton from '../components/AddLevelButton';
import Chart from '../components/membershipFunction/Chart';
import Controls from '../components/membershipFunction/Controls';
import { calculateValueFunction } from '../services/docService';
const COLORS = ['#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#d946ef', '#06b6d4', '#8b5cf6', '#f43f5e', '#6366f1'];
export default function AdvancedMode() {
const [step, setStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [criterionName, setCriterionName] = useState('');
const [levels, setLevels] = useState(['', '', '']);
const [blankCards, setBlankCards] = useState([0, 0]);
const [errors, setErrors] = useState({ criterion: false, levels: [] });
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, step]);
// Estados Fase 2 (Franjas)
const [baseScale, setBaseScale] = useState({});
const [selectedTerm, setSelectedTerm] = useState(null);
const [mfDefinitions, setMfDefinitions] = useState({});
// Manejadores de Escala
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); }
};
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 handleFinalSubmit = () => {
console.log("PAYLOAD DOC-MF:", { base_scale: baseScale, membership_functions: mfDefinitions });
alert("¡Mira la consola! JSON preparado.");
};
const scaleKeys = Object.keys(baseScale);
const selectedColor = COLORS[scaleKeys.indexOf(selectedTerm) % COLORS.length] || '#2563eb';
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 flex flex-col items-center">
{/* PASO 1 */}
{step === 1 && (
<div className="w-full bg-white p-6 rounded-2xl shadow-sm border border-slate-200 mb-6 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: Establecer escala
</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 ? '🔍' : '🖼️'}</span>
{isZoomActive ? 'Ver de cerca (Scroll)' : '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 pb-8 pt-4 px-4' : 'overflow-hidden flex justify-center pb-8 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}>
<div className="flex flex-col items-center mx-2 my-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>
{index < levels.length - 1 && (
<BlankCardsCounter index={index} blankCardsCount={blankCards[index]} handleBlankCardChange={handleBlankCardChange} />
)}
</React.Fragment>
))}
<div className="mx-1 my-2 h-52 flex items-center justify-center">
<div className="w-10 h-1 bg-slate-200 rounded"></div>
</div>
<AddLevelButton handleAddLevel={handleAddLevel} />
</div>
</div>
</div>
<div className="w-full max-w-lg mt-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>
)}
{/* PASO 2 */}
{step === 2 && (
<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={() => setStep(1)} 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 color = COLORS[index % COLORS.length];
const isSelected = selectedTerm === name;
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={COLORS} />
<Controls selectedTerm={selectedTerm} currentMf={mfDefinitions[selectedTerm]} selectedColor={selectedColor} baseScale={baseScale} mfDefinitions={mfDefinitions} updateCurrentMf={updateCurrentMf} />
<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-black hover:shadow-lg transition-all">
Guardar Todo el Espectro Difuso
</button>
</div>
</div>
)}
</div>
);
}
-156
View File
@@ -1,156 +0,0 @@
import { useState } from 'react';
import CriterionInput from '../components/CriterionInput';
import CardEditor from '../components/CardEditor';
import BlankCardsCounter from '../components/BlankCardsCounter';
import AddLevelButton from '../components/AddLevelButton';
import ValueFunctionChart from '../components/ValueFunctionChart';
import { calculateValueFunction } from '../services/docService';
export default function BasicMode() {
const [criterionName, setCriterionName] = useState('');
const [levels, setLevels] = useState(['', '', '']);
const [blankCards, setBlankCards] = useState([0, 0]);
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState(null);
const [errors, setErrors] = useState({ criterion: false, levels: [] });
const handleCalculate = async () => {
let hasError = false;
const newErrors = { criterion: false, levels: Array(levels.length).fill(false) };
if (!criterionName.trim()) {
newErrors.criterion = true;
hasError = true;
}
levels.forEach((level, idx) => {
if (!level.trim()) {
newErrors.levels[idx] = true;
hasError = true;
}
});
setErrors(newErrors);
if (hasError) return;
setIsLoading(true);
setResult(null);
const payload = {
criterion_name: criterionName.trim(),
levels: levels.map(l => l.trim()),
blank_cards: blankCards,
references: { "0": 0, [(levels.length - 1).toString()]: 1 }
};
try {
const data = await calculateValueFunction(payload);
setResult(data);
} catch (error) {
alert("No se ha podido conectar con el backend: " + error);
} finally {
setIsLoading(false);
}
};
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]) {
const newErrLevels = [...errors.levels];
newErrLevels[index] = false;
setErrors({ ...errors, levels: newErrLevels });
}
};
const handleAddLevel = () => {
setLevels([...levels, '']);
setBlankCards([...blankCards, 0]);
setErrors({ ...errors, levels: [...errors.levels, false] });
};
const handleRemoveLevel = (indexToRemove) => {
if (levels.length <= 3) return;
const newLevels = levels.filter((_, index) => index !== indexToRemove);
const blankIndexToRemove = indexToRemove === 0 ? 0 : indexToRemove - 1;
const newBlankCards = blankCards.filter((_, index) => index !== blankIndexToRemove);
const newErrLevels = errors.levels.filter((_, index) => index !== indexToRemove);
setLevels(newLevels);
setBlankCards(newBlankCards);
setErrors({ ...errors, levels: newErrLevels });
};
const handleBlankCardChange = (index, delta) => {
const newBlankCards = [...blankCards];
const newValue = newBlankCards[index] + delta;
if (newValue >= 0) {
newBlankCards[index] = newValue;
setBlankCards(newBlankCards);
}
};
return (
<div className="w-full flex flex-col items-center">
<CriterionInput
criterionName={criterionName}
setCriterionName={handleCriterionChange}
error={errors.criterion}
/>
<div className="w-full max-w-lg flex flex-col items-center">
{levels.map((level, index) => (
<div key={index} className="w-full flex flex-col items-center">
<CardEditor
index={index}
level={level}
handleLevelChange={handleLevelChange}
handleRemoveLevel={handleRemoveLevel}
totalLevels={levels.length}
error={errors.levels[index]}
/>
{index < levels.length - 1 && (
<BlankCardsCounter
index={index}
blankCardsCount={blankCards[index]}
handleBlankCardChange={handleBlankCardChange}
/>
)}
</div>
))}
<AddLevelButton handleAddLevel={handleAddLevel} />
</div>
<div className="w-full max-w-lg mt-12 pt-8 border-t-2 border-slate-200 flex flex-col items-center">
<button
onClick={handleCalculate}
disabled={isLoading}
className={`w-full py-4 text-white text-xl font-bold rounded-xl shadow-lg 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 hover:shadow-xl'
}`}
>
{isLoading ? 'Calculando...' : 'Calcular Valores DoC'}
</button>
</div>
<ValueFunctionChart result={result} />
</div>
);
}
+66 -10
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import Step1BaseScale from '../components/editor/Step1BaseScale'; import Step1BaseScale from '../components/editor/Step1BaseScale';
import Step2FuzzyModeling from '../components/editor/Step2FuzzyModeling'; import Step2FuzzyModeling from '../components/editor/Step2FuzzyModeling';
import SubscaleModal from '../components/editor/SubscaleModal'; import SubscaleModal from '../components/editor/SubscaleModal';
import { calculateValueFunction, buildFuzzyGraph } from '../services/docService'; import { calculateValueFunction, buildFuzzyGraph, saveToHistory } from '../services/docService';
import Step3FinalGraph from '../components/editor/Step3FinalGraph'; import Step3FinalGraph from '../components/editor/Step3FinalGraph';
export default function DocEditor() { export default function DocEditor() {
@@ -24,6 +24,7 @@ export default function DocEditor() {
// ESTADO: FASE 3 // ESTADO: FASE 3
const [finalResult, setFinalResult] = useState(null); const [finalResult, setFinalResult] = useState(null);
const [submitError, setSubmitError] = useState(null);
// MANEJADORES: FASE 1 // MANEJADORES: FASE 1
const handleCriterionChange = (val) => { setCriterionName(val); if (errors.criterion) setErrors({ ...errors, criterion: false }); }; const handleCriterionChange = (val) => { setCriterionName(val); if (errors.criterion) setErrors({ ...errors, criterion: false }); };
@@ -119,6 +120,7 @@ export default function DocEditor() {
// Petición para el endpoint "build" // Petición para el endpoint "build"
const handleFinalSubmit = async () => { const handleFinalSubmit = async () => {
setSubmitError(null);
const scaleKeys = Object.keys(baseScale); const scaleKeys = Object.keys(baseScale);
const payload = { const payload = {
@@ -157,14 +159,64 @@ export default function DocEditor() {
setIsLoading(true); setIsLoading(true);
try { try {
const result = await buildFuzzyGraph(payload); const result = await buildFuzzyGraph(payload);
console.log("RESPUESTA DEL BACKEND:", result);
setFinalResult(result); setFinalResult(result);
setStep(3); 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) { } catch (error) {
console.error(error); console.error("Error al guardar en el historial:", error);
alert("Error del servidor: \n" + JSON.stringify(error, null, 2)); alert("Hubo un problema al guardar el modelo: " + error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -191,18 +243,22 @@ export default function DocEditor() {
onBack={() => setStep(1)} onBack={() => setStep(1)}
subscales={subscales} subscales={subscales}
onOpenSubscale={handleOpenSubscale} onOpenSubscale={handleOpenSubscale}
submitError={submitError}
/> />
)} )}
{step === 3 && finalResult && ( {step === 3 && finalResult && (
<div className="flex flex-col gap-6 w-full"> <div className="flex flex-col w-full">
<Step3FinalGraph data={finalResult} /> <Step3FinalGraph data={finalResult} criterionName={criterionName} />
<button <button
onClick={() => console.log("Lógica para guardar")} onClick={handleSaveToHistory}
className="mt-4 px-8 py-3 bg-blue-600 text-white font-bold rounded-xl shadow-md hover:bg-blue-700 w-fit self-end transition-all" 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'
}`}
> >
Finalizar y Guardar {isLoading ? 'Guardando...' : 'Guardar'}
</button> </button>
</div> </div>
)} )}
+141
View File
@@ -0,0 +1,141 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getUserHistory, deleteHistoryItem } from '../services/docService';
import Step3FinalGraph from '../components/editor/Step3FinalGraph';
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 (error) {
alert("Error al borrar: " + error);
}
};
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="animate-spin text-4xl mb-4"></div>
<p className="font-medium">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 mb-4">📭</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">
📊
</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"
>
Borrar
</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>
);
}
+135
View File
@@ -0,0 +1,135 @@
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 EyeIcon from '../components/EyeIcon';
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">
<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"
>
<EyeIcon isOpen={showPassword} />
</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>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { authService } from '../services/authService';
import EyeIcon from '../components/EyeIcon';
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">
<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: usuario99"
/>
</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"
>
<EyeIcon isOpen={showPassword} />
</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"
>
<EyeIcon isOpen={showConfirmPassword} />
</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>
);
}
+14 -8
View File
@@ -1,16 +1,22 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import MainLayout from '../components/layout/MainLayout'; import MainLayout from '../components/layout/MainLayout';
import DocEditor from '../pages/DocEditor'; import DocEditor from '../pages/DocEditor';
import Login from '../pages/Login';
import Register from '../pages/Register';
import History from '../pages/History';
export function AppRouter() { export default function AppRouter() {
return ( return (
<BrowserRouter> <Router>
<MainLayout>
<Routes> <Routes>
<Route path="/" element={<MainLayout />}> <Route path="/" element={<Navigate to="/editor" replace />} />
<Route index element={<DocEditor />} /> <Route path="/editor" element={<DocEditor />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="/login" element={<Login />} />
</Route> <Route path="/register" element={<Register />} />
<Route path="/history" element={<History />} />
</Routes> </Routes>
</BrowserRouter> </MainLayout>
</Router>
); );
} }
+18
View File
@@ -0,0 +1,18 @@
import api from '../lib/api';
export const authService = {
login: async (email, password) => {
const response = await api.post('/auth/login', { email, password });
return response.data;
},
register: async (username, email, password) => {
const response = await api.post('/auth/register', { username, email, password });
return response.data;
},
getCurrentUser: async () => {
const response = await api.get('/auth/me');
return response.data;
}
};
+35 -7
View File
@@ -5,19 +5,47 @@ export const calculateValueFunction = async (payload) => {
const response = await api.post('/criteria/doc/value-function', payload); const response = await api.post('/criteria/doc/value-function', payload);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error calculating value function:', error); if (error.response && error.response.data) throw error.response.data;
throw error.response?.data?.detail || error.message; throw error;
} }
}; };
export const buildFuzzyGraph = async (payload) => { export const buildFuzzyGraph = async (payload) => {
try { try {
const response = await api.post('/criteria/doc-mf/build', payload); const response = await api.post('/criteria/doc-it2mf/build', payload);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error building fuzzy graph:', error); if (error.response && error.response.data) throw error.response.data;
throw error.response?.data?.detail || error.message; 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 (id) => {
try {
const response = await api.delete(`/history/${id}`);
return response.data;
} catch (error) {
if (error.response && error.response.data) throw error.response.data;
throw error;
} }
}; };