Merge pull request #26 from AlexisLopez-Dev/feature/frontend-v3
Feature/frontend v3
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# models/docit2mf_models.py
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator, ValidationInfo
|
||||
from typing import List, Tuple, Union
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ BlankCardInput = Union[int, Tuple[int, int], List[int]]
|
||||
|
||||
class DoCIT2MFRequest(BaseModel):
|
||||
term: str
|
||||
core: Tuple[float, float]
|
||||
support: Tuple[float, float]
|
||||
core: tuple[float, float] = Field(..., description="Núcleo del conjunto difuso: [a, b]")
|
||||
support: tuple[float, float] = Field(..., description="Soporte del conjunto difuso: [c, d]")
|
||||
|
||||
left_nodes_x: List[float]
|
||||
left_blank_cards: List[BlankCardInput]
|
||||
@@ -28,20 +28,20 @@ class DoCIT2MFRequest(BaseModel):
|
||||
def core_valid(cls, v):
|
||||
a, b = v
|
||||
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
|
||||
|
||||
@field_validator("support")
|
||||
def support_valid(cls, v, info):
|
||||
def support_valid(cls, v, info: ValidationInfo):
|
||||
c, d = v
|
||||
if c >= d:
|
||||
raise ValueError("El soporte debe cumplir c < d.")
|
||||
if c > d:
|
||||
raise ValueError("el 'Inicio del Soporte' debe ser menor o igual al 'Fin del Soporte'.")
|
||||
|
||||
core = info.data.get("core")
|
||||
if core:
|
||||
a, b = core
|
||||
if not (c <= a <= b <= d):
|
||||
raise ValueError("El núcleo debe estar dentro del soporte.")
|
||||
if not (c <= a and b <= d):
|
||||
raise ValueError("los valores del 'Núcleo' deben estar estrictamente dentro del 'Soporte'.")
|
||||
return v
|
||||
|
||||
@field_validator("left_blank_cards", "right_blank_cards")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# api/routers/docit2mf_build.py
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from api.models.docit2mf_models import DoCIT2MFMultiRequest
|
||||
@@ -14,11 +14,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@router.post("/doc-it2mf/build")
|
||||
@limiter.limit("10/minute")
|
||||
async def build_doc_it2mf(request: DoCIT2MFMultiRequest):
|
||||
async def build_doc_it2mf(request: Request, body: DoCIT2MFMultiRequest):
|
||||
results = []
|
||||
|
||||
try:
|
||||
for level in request.levels:
|
||||
for level in body.levels:
|
||||
results.append(build_it2mf_from_level(level))
|
||||
except ValueError as e:
|
||||
logger.warning(f"Validation error in doc-it2mf/build: {str(e)}")
|
||||
@@ -27,4 +27,4 @@ async def build_doc_it2mf(request: DoCIT2MFMultiRequest):
|
||||
logger.error(f"Unexpected error in doc-it2mf/build: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
return {"levels": results}
|
||||
return {"levels": results}
|
||||
@@ -1,19 +1,18 @@
|
||||
# api/routers/google_auth.py
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from datetime import datetime, timedelta
|
||||
import httpx
|
||||
import os
|
||||
import jwt
|
||||
|
||||
from api.database.mongodb import users_collection
|
||||
from api.utils.security import create_access_token
|
||||
|
||||
router = APIRouter(prefix="/auth/google", tags=["auth"])
|
||||
|
||||
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
|
||||
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
|
||||
REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI")
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
|
||||
|
||||
# -----------------------------
|
||||
@@ -90,6 +89,20 @@ async def google_callback(request: Request):
|
||||
else:
|
||||
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"
|
||||
)
|
||||
|
||||
return RedirectResponse(f"http://localhost:5173/login?token={token}")
|
||||
await users_collection.update_one(
|
||||
{"_id": user_id},
|
||||
{"$set": {"token": token}}
|
||||
)
|
||||
|
||||
return RedirectResponse(f"http://localhost:5173/login?token={token}")
|
||||
@@ -24,7 +24,7 @@ def _extract_bounds(values: List[Union[int, List[int], tuple]], mode: str) -> Li
|
||||
|
||||
def _sort_nodes(nodes):
|
||||
"""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):
|
||||
@@ -34,14 +34,14 @@ def _enforce_upper_ge_lower(lower, upper):
|
||||
"""
|
||||
# left nodes
|
||||
for i in range(len(lower["left_nodes"])):
|
||||
lx, ly = lower["left_nodes"][i]
|
||||
ux, uy = upper["left_nodes"][i]
|
||||
ly = lower["left_nodes"][i][1]
|
||||
uy = upper["left_nodes"][i][1]
|
||||
upper["left_nodes"][i][1] = max(uy, ly)
|
||||
|
||||
# right nodes
|
||||
for i in range(len(lower["right_nodes"])):
|
||||
lx, ly = lower["right_nodes"][i]
|
||||
ux, uy = upper["right_nodes"][i]
|
||||
ly = lower["right_nodes"][i][1]
|
||||
uy = upper["right_nodes"][i][1]
|
||||
upper["right_nodes"][i][1] = max(uy, ly)
|
||||
|
||||
return upper
|
||||
@@ -73,9 +73,13 @@ def build_it2mf_from_level(level: DoCIT2MFRequest):
|
||||
right_nodes_x=level.right_nodes_x,
|
||||
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["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_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["right_nodes"] = _sort_nodes(upper["right_nodes"])
|
||||
|
||||
@@ -109,4 +117,4 @@ def build_it2mf_from_level(level: DoCIT2MFRequest):
|
||||
"term": level.term,
|
||||
"lower": lower,
|
||||
"upper": upper
|
||||
}
|
||||
}
|
||||
Generated
+10
@@ -12,6 +12,7 @@
|
||||
"axios": "^1.13.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"recharts": "^3.8.0",
|
||||
"tailwindcss": "^4.2.2"
|
||||
@@ -3077,6 +3078,15 @@
|
||||
"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": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"axios": "^1.13.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"recharts": "^3.8.0",
|
||||
"tailwindcss": "^4.2.2"
|
||||
|
||||
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,4 +1,4 @@
|
||||
import { AppRouter } from './routers/AppRouter';
|
||||
import AppRouter from './routers/AppRouter';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function BlankCardsCounter({ index, blankCardsCount, handleBlankCardChange }) {
|
||||
|
||||
const maxCardsPerRow = 7;
|
||||
@@ -7,35 +9,29 @@ export default function BlankCardsCounter({ index, blankCardsCount, handleBlankC
|
||||
}
|
||||
|
||||
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">
|
||||
<button
|
||||
onClick={() => handleBlankCardChange(index, -1)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-full bg-slate-50 hover:bg-slate-200 text-slate-600 font-bold transition-colors"
|
||||
>-</button>
|
||||
|
||||
{/* 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
|
||||
onClick={() => handleBlankCardChange(index, -1)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-full bg-slate-50 hover:bg-slate-200 text-slate-600 font-bold transition-colors"
|
||||
>-</button>
|
||||
|
||||
<div className="flex flex-col items-center leading-none min-w-[3rem]">
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-1">Blancas</span>
|
||||
<span className="text-base font-black text-blue-600">{blankCardsCount}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleBlankCardChange(index, 1)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-full bg-slate-50 hover:bg-slate-200 text-slate-600 font-bold transition-colors"
|
||||
>+</button>
|
||||
<div className="flex flex-col items-center leading-none min-w-[3rem]">
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-1">Blancas</span>
|
||||
<span className="text-base font-black text-blue-600 leading-none">{blankCardsCount}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleBlankCardChange(index, 1)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-full bg-slate-50 hover:bg-slate-200 text-slate-600 font-bold transition-colors"
|
||||
>+</button>
|
||||
</div>
|
||||
|
||||
{/* Cartas blancas */}
|
||||
{blankCardsCount > 0 && (
|
||||
<div className="flex flex-col items-center gap-y-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) => (
|
||||
<div key={rowIndex} className="flex flex-row items-center justify-center -space-x-4">
|
||||
{row.map((_, colIndex) => (
|
||||
|
||||
@@ -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 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 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
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">
|
||||
<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} />
|
||||
|
||||
<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 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">
|
||||
|
||||
{/* CARTA DE NIVEL */}
|
||||
<div className="flex flex-col items-center mx-2 relative z-20">
|
||||
<CardEditor index={index} level={level} handleLevelChange={handleLevelChange} handleRemoveLevel={handleRemoveLevel} totalLevels={levels.length} error={errors.levels[index]} canRemove={levels.length > 3} />
|
||||
</div>
|
||||
|
||||
{/* HUECO ENTRE CARTAS Y CONTADOR */}
|
||||
{index < levels.length - 1 && (
|
||||
<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">
|
||||
<BlankCardsCounter index={index} blankCardsCount={blankCards[index]} handleBlankCardChange={handleBlankCardChange} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<AddLevelButton handleAddLevel={handleAddLevel} />
|
||||
|
||||
{/* BOTÓN AÑADIR NIVEL */}
|
||||
<div className="flex flex-col items-center mx-2 relative z-20">
|
||||
<AddLevelButton handleAddLevel={handleAddLevel} />
|
||||
</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'}`}>
|
||||
{isLoading ? 'Calculando...' : 'Generar Gráfica Continua'}
|
||||
</button>
|
||||
|
||||
@@ -11,7 +11,8 @@ export default function Step2FuzzyModeling({
|
||||
handleFinalSubmit,
|
||||
onBack,
|
||||
subscales,
|
||||
onOpenSubscale
|
||||
onOpenSubscale,
|
||||
submitError
|
||||
}) {
|
||||
const scaleKeys = Object.keys(baseScale);
|
||||
|
||||
@@ -51,6 +52,20 @@ export default function Step2FuzzyModeling({
|
||||
colors={CHART_COLORS}
|
||||
/>
|
||||
|
||||
{submitError && (
|
||||
<div className="bg-red-50 mb-6 border-red-500 p-4 rounded-xl shadow-sm animate-fade-in mx-2">
|
||||
<div className="flex items-center">
|
||||
<span className="text-red-500 text-xl mr-3">⚠️</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-red-800">Error de validación al generar la gráfica</h3>
|
||||
<div className="mt-1 text-sm text-red-700 whitespace-pre-line font-medium">
|
||||
{submitError}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Controls
|
||||
selectedTerm={selectedTerm}
|
||||
currentMf={mfDefinitions[selectedTerm]}
|
||||
@@ -64,7 +79,7 @@ export default function Step2FuzzyModeling({
|
||||
|
||||
<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">
|
||||
Guardar Todo el Espectro Difuso
|
||||
Generar el Espectro Difuso
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,103 +1,92 @@
|
||||
import { useMemo } from 'react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { CHART_COLORS } from '../../config';
|
||||
import React, { useState, useEffect, memo } from 'react';
|
||||
import { ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { useGraphData } from './finalGraph/useGraphData';
|
||||
import { GraphTooltip } from './finalGraph/GraphTooltip';
|
||||
|
||||
const Step3FinalGraph = ({ data }) => {
|
||||
const sortedResults = useMemo(() => {
|
||||
if (!data || !data.results) return [];
|
||||
const Step3FinalGraph = memo(({ data, criterionName }) => {
|
||||
const { sortedResults, denseData } = useGraphData(data);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
const withPermanentColors = data.results.map((item, index) => ({
|
||||
...item,
|
||||
color: CHART_COLORS[index % CHART_COLORS.length]
|
||||
}));
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsReady(true);
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return withPermanentColors.sort((a, b) => {
|
||||
const coreA = Array.isArray(a.core) ? Number(a.core[0]) : 0;
|
||||
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>;
|
||||
if (!data || (!data.levels && !data.results)) {
|
||||
return <p className="text-center mt-10 text-slate-500">Cargando datos...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-[550px] mt-2 bg-white p-6 rounded-2xl shadow-sm border border-slate-200 flex flex-col">
|
||||
<h3 className="text-2xl font-bold mb-4 text-center text-slate-800">Espectro Difuso Final</h3>
|
||||
<div className="w-full h-[550px] bg-white p-6 rounded-2xl shadow-sm border border-slate-200 flex flex-col final-graph-container relative">
|
||||
<style>{`.final-graph-container svg * { clip-path: none !important; }`}</style>
|
||||
|
||||
{/* Gráfica */}
|
||||
<div className="flex-1 w-full min-h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart margin={{ top: 10, right: 30, left: 10, bottom: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.5} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="x"
|
||||
type="number"
|
||||
domain={[0, 1]}
|
||||
tickCount={11}
|
||||
tick={{ fill: '#475569', fontWeight: 600, fontSize: 14 }}
|
||||
allowDuplicatedCategory={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 1]}
|
||||
tickCount={6}
|
||||
tick={{ fill: '#475569', fontSize: 14 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value, name) => [Number(value).toFixed(3), name]}
|
||||
labelFormatter={(label) => `X: ${Number(label).toFixed(3)}`}
|
||||
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{sortedResults.map((item) => {
|
||||
const lineData = [...(item.left_nodes || []), ...(item.right_nodes || [])].map(node => ({
|
||||
x: Number(node[0]),
|
||||
y: Number(node[1])
|
||||
}));
|
||||
|
||||
return (
|
||||
<Line
|
||||
key={item.term}
|
||||
data={lineData}
|
||||
type="linear"
|
||||
dataKey="y"
|
||||
name={item.term.toUpperCase()}
|
||||
stroke={item.color}
|
||||
strokeWidth={4}
|
||||
dot={{ r: 5, strokeWidth: 2, fill: '#fff' }}
|
||||
activeDot={{ r: 8 }}
|
||||
isAnimationActive={true}
|
||||
animationDuration={1500}
|
||||
{/* Gráfica */}
|
||||
<div className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${isReady ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{isReady && (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={denseData} margin={{ top: 15, right: 50, left: 10, bottom: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.5} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="x" type="number" domain={[0, 1]} allowDataOverflow={true}
|
||||
ticks={[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]}
|
||||
tick={{ fill: '#475569', fontWeight: 600, fontSize: 14 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<YAxis
|
||||
domain={[0, 1]} tickCount={6} tickFormatter={(val) => Number(val.toFixed(2))}
|
||||
tick={{ fill: '#475569', fontSize: 14 }}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
content={<GraphTooltip sortedResults={sortedResults} />}
|
||||
cursor={{ stroke: '#cbd5e1', strokeWidth: 1, strokeDasharray: '5 5' }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
|
||||
{sortedResults.map((item) => (
|
||||
<React.Fragment key={item.term}>
|
||||
{item.isType2 ? (
|
||||
<>
|
||||
<Area type="linear" dataKey={`${item.term}_range`} fill={item.color} fillOpacity={0.25} stroke="none" isAnimationActive={false} />
|
||||
<Line type="linear" dataKey={`${item.term}_upper`} stroke={item.color} strokeWidth={2} strokeDasharray="5 5" dot={false} isAnimationActive={false} />
|
||||
<Line type="linear" dataKey={`${item.term}_lower`} stroke={item.color} strokeWidth={3} dot={false} isAnimationActive={false} />
|
||||
</>
|
||||
) : (
|
||||
<Line type="linear" dataKey={item.term} stroke={item.color} strokeWidth={4} dot={false} isAnimationActive={false} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leyenda */}
|
||||
<div className="flex flex-wrap justify-center gap-x-8 gap-y-3 mt-6 pb-2">
|
||||
<div className="flex flex-wrap justify-center gap-x-8 gap-y-3 mt-6 pb-2 relative z-10">
|
||||
{sortedResults.map((item) => (
|
||||
<div key={`legend-${item.term}`} className="flex items-center gap-2">
|
||||
|
||||
<span
|
||||
className="w-3.5 h-3.5 rounded-full shadow-sm"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
|
||||
<span
|
||||
className="text-sm font-medium uppercase tracking-wide"
|
||||
style={{ color: item.color }}
|
||||
>
|
||||
{item.term}
|
||||
</span>
|
||||
</div>
|
||||
<div key={`legend-${item.term}`} className="flex items-center gap-2">
|
||||
<span className="w-3.5 h-3.5 rounded-full shadow-sm" style={{ backgroundColor: item.color }} />
|
||||
<span className="text-sm font-medium uppercase tracking-wide" style={{ color: item.color }}>{item.term}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default Step3FinalGraph;
|
||||
@@ -3,30 +3,78 @@ import BlankCardsCounter from '../BlankCardsCounter';
|
||||
|
||||
export default function SubscaleModal({ onClose, onSave, targetInfo }) {
|
||||
|
||||
const [cardsCount, setCardsCount] = useState(targetInfo?.initialData?.cardsCount || 2);
|
||||
const [blankCards, setBlankCards] = useState(targetInfo?.initialData?.blankCards || [0]);
|
||||
const initialCount = Math.max(3, targetInfo?.initialData?.cardsCount || 3);
|
||||
const [cardsCount, setCardsCount] = useState(initialCount);
|
||||
|
||||
const [blankCards, setBlankCards] = useState(() => {
|
||||
let initialBlanks = targetInfo?.initialData?.blankCards;
|
||||
|
||||
if (!initialBlanks || initialBlanks.length === 0) {
|
||||
initialBlanks = [0, 0];
|
||||
} else if (initialBlanks.length < initialCount - 1) {
|
||||
const padding = Array(initialCount - 1 - initialBlanks.length).fill(0);
|
||||
initialBlanks = [...initialBlanks, ...padding];
|
||||
}
|
||||
|
||||
return initialBlanks.map(b => {
|
||||
if (Array.isArray(b)) {
|
||||
return { min: b[0], max: b[1], isRange: true };
|
||||
}
|
||||
return { min: b, max: b, isRange: false };
|
||||
});
|
||||
});
|
||||
|
||||
const handleAddCard = () => {
|
||||
setCardsCount(prev => prev + 1);
|
||||
setBlankCards([...blankCards, 0]);
|
||||
setBlankCards([...blankCards, { min: 0, max: 0, isRange: false }]);
|
||||
};
|
||||
|
||||
const handleRemoveCard = () => {
|
||||
if (cardsCount <= 2) return;
|
||||
if (cardsCount <= 3) return;
|
||||
setCardsCount(prev => prev - 1);
|
||||
setBlankCards(blankCards.slice(0, -1));
|
||||
};
|
||||
|
||||
const handleBlankCardChange = (index, delta) => {
|
||||
const handleExactChange = (index, delta) => {
|
||||
const newBlanks = [...blankCards];
|
||||
if (newBlanks[index] + delta >= 0) {
|
||||
newBlanks[index] += delta;
|
||||
const newVal = newBlanks[index].min + delta;
|
||||
if (newVal >= 0) {
|
||||
newBlanks[index].min = newVal;
|
||||
newBlanks[index].max = newVal;
|
||||
setBlankCards(newBlanks);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMinChange = (index, delta) => {
|
||||
const newBlanks = [...blankCards];
|
||||
const newVal = newBlanks[index].min + delta;
|
||||
if (newVal >= 0 && newVal <= newBlanks[index].max) {
|
||||
newBlanks[index].min = newVal;
|
||||
setBlankCards(newBlanks);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMaxChange = (index, delta) => {
|
||||
const newBlanks = [...blankCards];
|
||||
const newVal = newBlanks[index].max + delta;
|
||||
if (newVal >= newBlanks[index].min) {
|
||||
newBlanks[index].max = newVal;
|
||||
setBlankCards(newBlanks);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRangeMode = (index) => {
|
||||
const newBlanks = [...blankCards];
|
||||
newBlanks[index].isRange = !newBlanks[index].isRange;
|
||||
if (!newBlanks[index].isRange) {
|
||||
newBlanks[index].max = newBlanks[index].min;
|
||||
}
|
||||
setBlankCards(newBlanks);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
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 = () => {
|
||||
@@ -34,10 +82,10 @@ export default function SubscaleModal({ onClose, onSave, targetInfo }) {
|
||||
};
|
||||
|
||||
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="bg-white w-full max-w-5xl p-8 rounded-3xl shadow-2xl mx-4 flex flex-col">
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm animate-fade-in py-4">
|
||||
<div className="bg-white w-full max-w-6xl p-8 rounded-3xl shadow-2xl mx-4 flex flex-col max-h-[95vh]">
|
||||
|
||||
<div className="flex justify-between items-center mb-6 border-b pb-4">
|
||||
<div className="flex justify-between items-center mb-4 border-b pb-4 shrink-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">Diseñar Subescala</h2>
|
||||
<p className="text-slate-500 font-medium">
|
||||
@@ -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>
|
||||
</div>
|
||||
|
||||
{/* Tablero */}
|
||||
<div className="w-full py-10 overflow-x-auto flex justify-start px-4">
|
||||
<div className="w-full overflow-y-auto overflow-x-auto flex justify-start flex-1 custom-scrollbar px-2 pt-6 pb-12">
|
||||
<div className="flex flex-row items-start min-w-max relative">
|
||||
|
||||
{Array.from({ length: cardsCount }).map((_, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<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">
|
||||
{cardsCount > 2 && 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>
|
||||
{cardsCount > 3 && index === cardsCount - 1 && (
|
||||
<button onClick={handleRemoveCard} className="absolute -top-3 -right-3 w-8 h-8 bg-white text-slate-400 rounded-full border border-slate-200 flex items-center justify-center font-bold hover:bg-red-500 hover:text-white transition-colors z-10 shadow-sm opacity-0 group-hover:opacity-100">✕</button>
|
||||
)}
|
||||
<span className="text-4xl font-black text-slate-200">{index + 1}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HUECO ENTRE CARTAS */}
|
||||
{index < cardsCount - 1 && (
|
||||
<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>
|
||||
))}
|
||||
|
||||
<div className="mx-2 my-2 h-40 flex items-center">
|
||||
<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">
|
||||
<span className="text-3xl">+</span>
|
||||
{/* Botón Añadir Carta */}
|
||||
<div className="flex flex-col items-center mx-2 relative z-20">
|
||||
<button onClick={handleAddCard} className="w-32 h-40 border-4 border-dashed border-slate-300 rounded-2xl flex flex-col items-center justify-center text-slate-400 font-bold hover:bg-blue-50 hover:border-blue-400 hover:text-blue-500 transition-colors group">
|
||||
<span className="text-3xl group-hover:scale-110 transition-transform">+</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botones */}
|
||||
<div className="mt-8 flex justify-between items-center border-t pt-6">
|
||||
{/* Botones de Acción */}
|
||||
<div className="mt-4 flex justify-between items-center border-t pt-6 shrink-0">
|
||||
<button onClick={handleDelete} className="px-6 py-3 rounded-xl font-bold text-red-500 hover:bg-red-50 transition-colors">
|
||||
Borrar Subescala
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const TermInfo = ({ title, color, children }) => (
|
||||
<div className="flex flex-col text-xs font-medium bg-slate-50 p-2.5 rounded-xl border border-slate-100">
|
||||
<span className="uppercase font-black mb-1.5" style={{ color }}>{title}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GraphTooltip = ({ active, payload, label, sortedResults }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
const dataPoint = payload[0].payload;
|
||||
|
||||
const activeTerms = sortedResults.filter(item =>
|
||||
item.isType2 ? (dataPoint[`${item.term}_upper`] ?? 0) > 0 : (dataPoint[item.term] ?? 0) > 0
|
||||
);
|
||||
|
||||
if (activeTerms.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 border border-slate-200 shadow-xl rounded-2xl min-w-[200px] animate-fade-in relative z-50">
|
||||
<p className="text-slate-800 font-black border-b border-slate-100 pb-2 mb-3 text-sm flex justify-between items-center gap-4">
|
||||
<span>Punto X:</span> <span className="text-blue-600">{Number(label).toFixed(3)}</span>
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{activeTerms.map(item => {
|
||||
if (item.isType2) {
|
||||
const lower = dataPoint[`${item.term}_lower`] ?? 0;
|
||||
const upper = dataPoint[`${item.term}_upper`] ?? 0;
|
||||
const range = Math.abs(upper - lower);
|
||||
|
||||
return range <= 0.001 ? (
|
||||
<TermInfo key={item.term} title={item.term} color={item.color}>
|
||||
<span className="text-slate-600 flex justify-between gap-4">Pertenencia: <b>{Number(upper).toFixed(3)}</b></span>
|
||||
</TermInfo>
|
||||
) : (
|
||||
<TermInfo key={item.term} title={item.term} color={item.color}>
|
||||
<span className="text-slate-600 flex justify-between gap-4">Mínimo: <b>{Number(lower).toFixed(3)}</b></span>
|
||||
<span className="text-slate-600 flex justify-between gap-4 mt-0.5">Máximo: <b>{Number(upper).toFixed(3)}</b></span>
|
||||
<span className="text-slate-500 font-bold mt-1.5 pt-1.5 border-t border-slate-200 flex justify-between gap-4">
|
||||
Incertidumbre: <span>{Number(range).toFixed(3)}</span>
|
||||
</span>
|
||||
</TermInfo>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TermInfo key={item.term} title={item.term} color={item.color}>
|
||||
<span className="text-slate-600 flex justify-between gap-4">Pertenencia: <b>{Number(dataPoint[item.term]).toFixed(3)}</b></span>
|
||||
</TermInfo>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useMemo } from 'react';
|
||||
import { CHART_COLORS } from '../../../config';
|
||||
|
||||
const interpolateY = (x, nodes) => {
|
||||
if (!nodes || nodes.length === 0) return null;
|
||||
const EPSILON = 1e-5;
|
||||
const MICRO_STEP = 0.0001;
|
||||
const firstX = nodes[0][0];
|
||||
const lastX = nodes[nodes.length - 1][0];
|
||||
|
||||
if (x < firstX - MICRO_STEP - EPSILON) return null;
|
||||
if (x > lastX + MICRO_STEP + EPSILON) return null;
|
||||
if (x < firstX - EPSILON) return 0;
|
||||
if (x > lastX + EPSILON) return 0;
|
||||
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
if (Math.abs(nodes[i][0] - x) < EPSILON) return nodes[i][1];
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodes.length - 1; i++) {
|
||||
const x1 = nodes[i][0];
|
||||
const x2 = nodes[i + 1][0];
|
||||
if (Math.abs(x2 - x1) < EPSILON) continue;
|
||||
if (x >= x1 && x <= x2) {
|
||||
const y1 = nodes[i][1];
|
||||
const y2 = nodes[i + 1][1];
|
||||
return y1 + ((x - x1) * (y2 - y1)) / (x2 - x1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useGraphData = (data) => {
|
||||
const sortedResults = useMemo(() => {
|
||||
const rawItems = data?.levels || data?.results || [];
|
||||
const processed = rawItems.map((item, index) => {
|
||||
const isType2 = !!item.lower && !!item.upper;
|
||||
const color = CHART_COLORS[index % CHART_COLORS.length] || '#333';
|
||||
let termName = item.term || (item.lower && item.lower.term) || `Termino ${index}`;
|
||||
|
||||
if (isType2) {
|
||||
const lowerNodes = [...(item.lower.left_nodes || []), ...(item.lower.right_nodes || [])].map(n => [Number(n[0]), Number(n[1])]).sort((a,b)=>a[0]-b[0]);
|
||||
const upperNodes = [...(item.upper.left_nodes || []), ...(item.upper.right_nodes || [])].map(n => [Number(n[0]), Number(n[1])]).sort((a,b)=>a[0]-b[0]);
|
||||
const coreVal = Array.isArray(item.lower.core) ? Number(item.lower.core[0]) : 0;
|
||||
return { ...item, term: termName, isType2, lowerNodes, upperNodes, color, coreVal };
|
||||
} else {
|
||||
const nodes = [...(item.left_nodes || []), ...(item.right_nodes || [])].map(n => [Number(n[0]), Number(n[1])]).sort((a,b)=>a[0]-b[0]);
|
||||
const coreVal = Array.isArray(item.core) ? Number(item.core[0]) : 0;
|
||||
return { ...item, term: termName, isType2, nodes, color, coreVal };
|
||||
}
|
||||
});
|
||||
return processed.sort((a, b) => a.coreVal - b.coreVal);
|
||||
}, [data]);
|
||||
|
||||
const denseData = useMemo(() => {
|
||||
const xSet = new Set();
|
||||
const steps = 1000;
|
||||
for (let i = 0; i <= steps; i++) xSet.add(Number((i / steps).toFixed(4)));
|
||||
|
||||
sortedResults.forEach(item => {
|
||||
const addNodes = (nodes) => nodes.forEach(n => {
|
||||
const x = n[0];
|
||||
xSet.add(Number((x - 0.0001).toFixed(4)));
|
||||
xSet.add(Number(x.toFixed(4)));
|
||||
xSet.add(Number((x + 0.0001).toFixed(4)));
|
||||
});
|
||||
item.isType2 ? (addNodes(item.lowerNodes), addNodes(item.upperNodes)) : addNodes(item.nodes);
|
||||
});
|
||||
|
||||
const xValues = Array.from(xSet).sort((a, b) => a - b);
|
||||
return xValues.map(x => {
|
||||
const point = { x };
|
||||
sortedResults.forEach(item => {
|
||||
if (item.isType2) {
|
||||
const lowerRaw = interpolateY(x, item.lowerNodes);
|
||||
const upperRaw = interpolateY(x, item.upperNodes);
|
||||
point[`${item.term}_lower`] = lowerRaw;
|
||||
point[`${item.term}_upper`] = upperRaw;
|
||||
point[`${item.term}_range`] = (lowerRaw === null && upperRaw === null) ? null : [lowerRaw ?? 0, upperRaw ?? 0];
|
||||
} else {
|
||||
point[item.term] = interpolateY(x, item.nodes);
|
||||
}
|
||||
});
|
||||
return point;
|
||||
});
|
||||
}, [sortedResults]);
|
||||
|
||||
return { sortedResults, denseData };
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-white border-t border-slate-200 mt-auto shrink-0 w-full pt-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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<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 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>
|
||||
<Header />
|
||||
|
||||
{/* Contenido principal */}
|
||||
<main className="max-w-7xl mx-auto px-4 py-6">
|
||||
<Outlet />
|
||||
<main className="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-8 flex flex-col">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,6 @@ export default function Controls({
|
||||
</h3>
|
||||
|
||||
<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>
|
||||
<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 }} />
|
||||
</div>
|
||||
|
||||
{/* Botón subescala izquierda */}
|
||||
<div className="pt-2 border-t border-slate-200 flex justify-end">
|
||||
<button
|
||||
onClick={() => onOpenSubscale(selectedTerm, 'left', leftSubscale)}
|
||||
@@ -48,22 +46,20 @@ export default function Controls({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lado derecho (Pendiente descendente) */}
|
||||
<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>
|
||||
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1">
|
||||
<span>Fin del Soporte (Punto inferior)</span><span style={{ color: selectedColor }}>{currentMf.supportEnd.toFixed(3)}</span>
|
||||
</label>
|
||||
<input type="range" min={absoluteMin} max={absoluteMax} step="0.001" value={currentMf.supportEnd} onChange={(e) => updateCurrentMf('supportEnd', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor, opacity: 0.7 }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1">
|
||||
<span>Fin del Núcleo (Punto superior)</span><span style={{ color: selectedColor }}>{currentMf.coreEnd.toFixed(3)}</span>
|
||||
</label>
|
||||
<input type="range" min={absoluteMin} max={absoluteMax} step="0.001" value={currentMf.coreEnd} onChange={(e) => updateCurrentMf('coreEnd', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor }} />
|
||||
</div>
|
||||
|
||||
{/* Botón subescala derecha */}
|
||||
<div className="pt-2 border-t border-slate-200 flex justify-end">
|
||||
<button
|
||||
onClick={() => onOpenSubscale(selectedTerm, 'right', rightSubscale)}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const AuthContext = createContext();
|
||||
|
||||
export const useAuth = () => {
|
||||
return useContext(AuthContext);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { AuthContext } from './AuthContext';
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(() => {
|
||||
try {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
return storedUser ? JSON.parse(storedUser) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const login = useCallback((data) => {
|
||||
const currentUser = data.user || data;
|
||||
const token = data.access_token || data.token;
|
||||
|
||||
setUser(currentUser);
|
||||
localStorage.setItem('user', JSON.stringify(currentUser));
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setUser(null);
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
login,
|
||||
logout
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
import { AuthProvider } from './context/AuthProvider.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
)
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import Step1BaseScale from '../components/editor/Step1BaseScale';
|
||||
import Step2FuzzyModeling from '../components/editor/Step2FuzzyModeling';
|
||||
import SubscaleModal from '../components/editor/SubscaleModal';
|
||||
import { calculateValueFunction, buildFuzzyGraph } from '../services/docService';
|
||||
import { calculateValueFunction, buildFuzzyGraph, saveToHistory } from '../services/docService';
|
||||
import Step3FinalGraph from '../components/editor/Step3FinalGraph';
|
||||
|
||||
export default function DocEditor() {
|
||||
@@ -24,6 +24,7 @@ export default function DocEditor() {
|
||||
|
||||
// ESTADO: FASE 3
|
||||
const [finalResult, setFinalResult] = useState(null);
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
|
||||
// MANEJADORES: FASE 1
|
||||
const handleCriterionChange = (val) => { setCriterionName(val); if (errors.criterion) setErrors({ ...errors, criterion: false }); };
|
||||
@@ -119,6 +120,7 @@ export default function DocEditor() {
|
||||
|
||||
// Petición para el endpoint "build"
|
||||
const handleFinalSubmit = async () => {
|
||||
setSubmitError(null);
|
||||
const scaleKeys = Object.keys(baseScale);
|
||||
|
||||
const payload = {
|
||||
@@ -157,14 +159,64 @@ export default function DocEditor() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await buildFuzzyGraph(payload);
|
||||
console.log("RESPUESTA DEL BACKEND:", result);
|
||||
|
||||
setFinalResult(result);
|
||||
setStep(3);
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Error del servidor: \n" + JSON.stringify(error, null, 2));
|
||||
|
||||
let friendlyMessage = "Ocurrió un error al procesar la solicitud.";
|
||||
const errorData = error.backendData || error.response?.data || error;
|
||||
|
||||
if (errorData.detail) {
|
||||
if (errorData.detail === "Invalid input data") {
|
||||
friendlyMessage = "Revisa los valores del Soporte y Núcleo. Asegúrate de que el 'Inicio del Soporte' sea menor o igual al 'Fin del Soporte', y que el 'Núcleo' esté dentro del 'Soporte'.";
|
||||
} else if (typeof errorData.detail === 'string') {
|
||||
friendlyMessage = errorData.detail;
|
||||
}
|
||||
} else if (errorData.errors && Array.isArray(errorData.errors) && errorData.errors.length > 0) {
|
||||
friendlyMessage = errorData.errors.map(err => {
|
||||
let cleanMsg = err.msg ? err.msg.replace("Value error, ", "") : "Valor incorrecto";
|
||||
if (err.loc && err.loc.includes("levels")) {
|
||||
const levelIndex = err.loc[err.loc.indexOf("levels") + 1];
|
||||
const termName = scaleKeys[levelIndex] || `Nivel ${Number(levelIndex) + 1}`;
|
||||
return `• En la etiqueta "${termName}": ${cleanMsg}`;
|
||||
}
|
||||
return `• ${cleanMsg}`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
setSubmitError(friendlyMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Petición para guardar en el historial
|
||||
const handleSaveToHistory = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
alert("Para guardar tu modelo debes iniciar sesión primero. Puedes seguir visualizando la gráfica sin problema.");
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultName = criterionName ? `Modelo de ${criterionName}` : "Mi nueva gráfica DoC-IT2MF";
|
||||
const historyName = prompt("Dale un nombre a este modelo para guardarlo en tu historial:", defaultName);
|
||||
|
||||
if (!historyName) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: historyName,
|
||||
results: finalResult.levels || finalResult.results
|
||||
};
|
||||
|
||||
await saveToHistory(payload);
|
||||
|
||||
alert("¡Gráfica guardada con éxito en tu historial!");
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error al guardar en el historial:", error);
|
||||
alert("Hubo un problema al guardar el modelo: " + error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -191,18 +243,22 @@ export default function DocEditor() {
|
||||
onBack={() => setStep(1)}
|
||||
subscales={subscales}
|
||||
onOpenSubscale={handleOpenSubscale}
|
||||
submitError={submitError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 3 && finalResult && (
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
<Step3FinalGraph data={finalResult} />
|
||||
<div className="flex flex-col w-full">
|
||||
<Step3FinalGraph data={finalResult} criterionName={criterionName} />
|
||||
|
||||
<button
|
||||
onClick={() => console.log("Lógica para guardar")}
|
||||
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"
|
||||
onClick={handleSaveToHistory}
|
||||
disabled={isLoading}
|
||||
className={`mt-4 px-8 py-3 font-bold rounded-xl shadow-md w-fit self-end transition-all ${
|
||||
isLoading ? 'bg-slate-400 text-slate-100 cursor-not-allowed' : 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
Finalizar y Guardar
|
||||
{isLoading ? 'Guardando...' : 'Guardar'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 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 (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<MainLayout />}>
|
||||
<Route index element={<DocEditor />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Router>
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/editor" replace />} />
|
||||
<Route path="/editor" element={<DocEditor />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import api from '../lib/api';
|
||||
|
||||
export const authService = {
|
||||
login: async (email, password) => {
|
||||
const response = await api.post('/auth/login', { email, password });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
register: async (username, email, password) => {
|
||||
const response = await api.post('/auth/register', { username, email, password });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getCurrentUser: async () => {
|
||||
const response = await api.get('/auth/me');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
@@ -5,19 +5,47 @@ export const calculateValueFunction = async (payload) => {
|
||||
const response = await api.post('/criteria/doc/value-function', payload);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error calculating value function:', error);
|
||||
throw error.response?.data?.detail || error.message;
|
||||
if (error.response && error.response.data) throw error.response.data;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const buildFuzzyGraph = async (payload) => {
|
||||
try {
|
||||
const response = await api.post('/criteria/doc-mf/build', payload);
|
||||
const response = await api.post('/criteria/doc-it2mf/build', payload);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error building fuzzy graph:', error);
|
||||
throw error.response?.data?.detail || error.message;
|
||||
if (error.response && error.response.data) throw error.response.data;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const saveToHistory = async (payload) => {
|
||||
try {
|
||||
const response = await api.post('/history/add', payload);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error.response && error.response.data) throw error.response.data;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserHistory = async () => {
|
||||
try {
|
||||
const response = await api.get('/history/user');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error.response && error.response.data) throw error.response.data;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteHistoryItem = async (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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user