diff --git a/backend/api/models/docit2mf_models.py b/backend/api/models/docit2mf_models.py
index d8069cb..8464a9a 100644
--- a/backend/api/models/docit2mf_models.py
+++ b/backend/api/models/docit2mf_models.py
@@ -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")
diff --git a/backend/api/routers/docit2mf_build.py b/backend/api/routers/docit2mf_build.py
index ba4c06a..f0761aa 100644
--- a/backend/api/routers/docit2mf_build.py
+++ b/backend/api/routers/docit2mf_build.py
@@ -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}
\ No newline at end of file
diff --git a/backend/api/routers/google_auth.py b/backend/api/routers/google_auth.py
index c7e5f77..96734a2 100644
--- a/backend/api/routers/google_auth.py
+++ b/backend/api/routers/google_auth.py
@@ -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}")
\ No newline at end of file
diff --git a/backend/api/services/docit2mf_build_service.py b/backend/api/services/docit2mf_build_service.py
index 82083ac..77ca7cb 100644
--- a/backend/api/services/docit2mf_build_service.py
+++ b/backend/api/services/docit2mf_build_service.py
@@ -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
- }
+ }
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index c0cf46f..6727ebe 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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",
diff --git a/frontend/package.json b/frontend/package.json
index debc10c..6b49322 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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"
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
index 6893eb1..cd55914 100644
--- a/frontend/public/favicon.svg
+++ b/frontend/public/favicon.svg
@@ -1 +1 @@
-
\ No newline at end of file
+logo doc
\ No newline at end of file
diff --git a/frontend/public/uja-logo.png b/frontend/public/uja-logo.png
new file mode 100644
index 0000000..77d17fd
Binary files /dev/null and b/frontend/public/uja-logo.png differ
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index df64ca3..e05ea1a 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,4 +1,4 @@
-import { AppRouter } from './routers/AppRouter';
+import AppRouter from './routers/AppRouter';
function App() {
return (
diff --git a/frontend/src/components/BlankCardsCounter.jsx b/frontend/src/components/BlankCardsCounter.jsx
index b35b7ed..3292eea 100644
--- a/frontend/src/components/BlankCardsCounter.jsx
+++ b/frontend/src/components/BlankCardsCounter.jsx
@@ -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 (
-
+
-
+ {/* Bloque de botones */}
+
+
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"
+ >-
- {/* Línea conectora horizontal */}
-
-
- {/* Botones - y + */}
-
-
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"
- >-
-
-
- Blancas
- {blankCardsCount}
-
-
-
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"
- >+
+
+ Blancas
+ {blankCardsCount}
+
+
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"
+ >+
{/* Cartas blancas */}
{blankCardsCount > 0 && (
-
+
{rows.map((row, rowIndex) => (
{row.map((_, colIndex) => (
diff --git a/frontend/src/components/CardEditor.jsx b/frontend/src/components/CardEditor.jsx
index c5e887f..4783869 100644
--- a/frontend/src/components/CardEditor.jsx
+++ b/frontend/src/components/CardEditor.jsx
@@ -10,9 +10,9 @@ export default function CardEditor({ index, level, handleLevelChange, handleRemo
)}
{index + 1}
{index + 1}
- 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'}`} />
+ 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'}`} />
-
{error &&
Escribe una etiqueta
}
+
{error &&
Escribe un término
}
);
}
\ No newline at end of file
diff --git a/frontend/src/components/EyeIcon.jsx b/frontend/src/components/EyeIcon.jsx
new file mode 100644
index 0000000..70437ac
--- /dev/null
+++ b/frontend/src/components/EyeIcon.jsx
@@ -0,0 +1,12 @@
+export default function EyeIcon({ isOpen }) {
+ return isOpen ? (
+
+
+
+
+ ) : (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/editor/Step1BaseScale.jsx b/frontend/src/components/editor/Step1BaseScale.jsx
index 115b450..7bdb24b 100644
--- a/frontend/src/components/editor/Step1BaseScale.jsx
+++ b/frontend/src/components/editor/Step1BaseScale.jsx
@@ -37,7 +37,7 @@ export default function Step1BaseScale({
const currentScale = isZoomActive && needsZoom ? dynamicScale : 1;
return (
-
+
@@ -59,30 +59,48 @@ export default function Step1BaseScale({
-
+
{levels.map((level, index) => (
-
+
+ {/* CARTA DE NIVEL */}
+
3} />
+
+ {/* HUECO ENTRE CARTAS Y CONTADOR */}
{index < levels.length - 1 && (
-
+
)}
))}
-
-
+
+ {/* LÍNEA HACIA EL BOTÓN DE AÑADIR */}
+
-
+
+ {/* BOTÓN AÑADIR NIVEL */}
+
+
-
+ {/* Generar Gráfica Continua */}
+
{isLoading ? 'Calculando...' : 'Generar Gráfica Continua'}
diff --git a/frontend/src/components/editor/Step2FuzzyModeling.jsx b/frontend/src/components/editor/Step2FuzzyModeling.jsx
index 34ba6f8..2369c54 100644
--- a/frontend/src/components/editor/Step2FuzzyModeling.jsx
+++ b/frontend/src/components/editor/Step2FuzzyModeling.jsx
@@ -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 && (
+
+
+
⚠️
+
+
Error de validación al generar la gráfica
+
+ {submitError}
+
+
+
+
+ )}
+
- Guardar Todo el Espectro Difuso
+ Generar el Espectro Difuso
diff --git a/frontend/src/components/editor/Step3FinalGraph.jsx b/frontend/src/components/editor/Step3FinalGraph.jsx
index 45c9d27..8e344f9 100644
--- a/frontend/src/components/editor/Step3FinalGraph.jsx
+++ b/frontend/src/components/editor/Step3FinalGraph.jsx
@@ -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
Cargando gráfico final...
;
+ if (!data || (!data.levels && !data.results)) {
+ return
Cargando datos...
;
}
return (
-
-
Espectro Difuso Final
+
+
- {/* Gráfica */}
-
-
-
-
-
-
- [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)' }}
- />
+
+ {criterionName ? `Criterio: ${criterionName}` : 'Espectro Difuso Final'}
+
+
+
+
+ {!isReady && (
+
+
+
Generando gráfica...
+
+ )}
- {sortedResults.map((item) => {
- const lineData = [...(item.left_nodes || []), ...(item.right_nodes || [])].map(node => ({
- x: Number(node[0]),
- y: Number(node[1])
- }));
-
- return (
-
+ {isReady && (
+
+
+
+
- );
- })}
-
-
+ Number(val.toFixed(2))}
+ tick={{ fill: '#475569', fontSize: 14 }}
+ />
+
+ }
+ cursor={{ stroke: '#cbd5e1', strokeWidth: 1, strokeDasharray: '5 5' }}
+ isAnimationActive={false}
+ />
+
+ {sortedResults.map((item) => (
+
+ {item.isType2 ? (
+ <>
+
+
+
+ >
+ ) : (
+
+ )}
+
+ ))}
+
+
+ )}
+
{/* Leyenda */}
-
+
{sortedResults.map((item) => (
-
-
-
-
-
- {item.term}
-
-
+
+
+ {item.term}
+
))}
-
);
-};
+});
export default Step3FinalGraph;
\ No newline at end of file
diff --git a/frontend/src/components/editor/SubscaleModal.jsx b/frontend/src/components/editor/SubscaleModal.jsx
index 70bb4ce..6f70c3a 100644
--- a/frontend/src/components/editor/SubscaleModal.jsx
+++ b/frontend/src/components/editor/SubscaleModal.jsx
@@ -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 (
-
-
+
+
-
+
Diseñar Subescala
@@ -47,35 +95,84 @@ export default function SubscaleModal({ onClose, onSave, targetInfo }) {
✕
- {/* Tablero */}
-
+
+
{Array.from({ length: cardsCount }).map((_, index) => (
-
+ {/* CARTA DE REFERENCIA */}
+
- {cardsCount > 2 && index === cardsCount - 1 && (
- ×
+ {cardsCount > 3 && index === cardsCount - 1 && (
+ ✕
)}
{index + 1}
+
+ {/* HUECO ENTRE CARTAS */}
{index < cardsCount - 1 && (
-
+
+
+
+
+ {blankCards[index].isRange ? (
+
+
+ MÍN
+ handleMinChange(idx, delta)}
+ />
+
+
+
-
+
+
+ MÁX
+ handleMaxChange(idx, delta)}
+ />
+
+
+ ) : (
+
+ CARTAS
+ handleExactChange(idx, delta)}
+ />
+
+ )}
+
+
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"}
+
+
+
)}
))}
-
-
- +
+ {/* Botón Añadir Carta */}
+
+
+ +
+
- {/* Botones */}
-
+ {/* Botones de Acción */}
+
Borrar Subescala
diff --git a/frontend/src/components/editor/finalGraph/GraphTooltip.jsx b/frontend/src/components/editor/finalGraph/GraphTooltip.jsx
new file mode 100644
index 0000000..bc95ac9
--- /dev/null
+++ b/frontend/src/components/editor/finalGraph/GraphTooltip.jsx
@@ -0,0 +1,53 @@
+const TermInfo = ({ title, color, children }) => (
+
+ {title}
+ {children}
+
+);
+
+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 (
+
+
+ Punto X: {Number(label).toFixed(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 ? (
+
+ Pertenencia: {Number(upper).toFixed(3)}
+
+ ) : (
+
+ Mínimo: {Number(lower).toFixed(3)}
+ Máximo: {Number(upper).toFixed(3)}
+
+ Incertidumbre: {Number(range).toFixed(3)}
+
+
+ );
+ }
+ return (
+
+ Pertenencia: {Number(dataPoint[item.term]).toFixed(3)}
+
+ );
+ })}
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/editor/finalGraph/useGraphData.js b/frontend/src/components/editor/finalGraph/useGraphData.js
new file mode 100644
index 0000000..408cdfc
--- /dev/null
+++ b/frontend/src/components/editor/finalGraph/useGraphData.js
@@ -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 };
+};
\ No newline at end of file
diff --git a/frontend/src/components/layout/Footer.jsx b/frontend/src/components/layout/Footer.jsx
new file mode 100644
index 0000000..4bb3a2f
--- /dev/null
+++ b/frontend/src/components/layout/Footer.jsx
@@ -0,0 +1,95 @@
+export default function Footer() {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/layout/Header.jsx b/frontend/src/components/layout/Header.jsx
new file mode 100644
index 0000000..2011ca7
--- /dev/null
+++ b/frontend/src/components/layout/Header.jsx
@@ -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 (
+
+
+
+
+
+
+ Deck of Cards
+
+
+
+
+
+
+ Editor
+
+ {isAuthenticated && (
+
+ Historial
+
+ )}
+
+
+ {isAuthenticated ? (
+
+
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}
+
+
+ {isDropdownOpen && (
+ <>
+
setIsDropdownOpen(false)}>
+
+
+
Usuario
+
{user?.username}
+
+
+
+
+ Cerrar Sesión
+
+
+ >
+ )}
+
+ ) : (
+
+
+
+
+ Acceder
+
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/layout/MainLayout.jsx b/frontend/src/components/layout/MainLayout.jsx
index c8bc184..b6d6da7 100644
--- a/frontend/src/components/layout/MainLayout.jsx
+++ b/frontend/src/components/layout/MainLayout.jsx
@@ -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 (
-
+
- {/* Cabecera */}
-
-
-
- DoC
-
-
- Deck of Cards
-
-
-
+
- {/* Contenido principal */}
-
-
+
+ {children}
+
+
);
}
\ No newline at end of file
diff --git a/frontend/src/components/membershipFunction/Controls.jsx b/frontend/src/components/membershipFunction/Controls.jsx
index f879c4a..565cc84 100644
--- a/frontend/src/components/membershipFunction/Controls.jsx
+++ b/frontend/src/components/membershipFunction/Controls.jsx
@@ -22,7 +22,6 @@ export default function Controls({
- {/* Lado izquierdo (Pendiente ascendente) */}
@@ -37,7 +36,6 @@ export default function Controls({
updateCurrentMf('coreStart', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor }} />
- {/* Botón subescala izquierda */}
onOpenSubscale(selectedTerm, 'left', leftSubscale)}
@@ -48,22 +46,20 @@ export default function Controls({
- {/* Lado derecho (Pendiente descendente) */}
-
-
- Fin del Núcleo (Punto superior) {currentMf.coreEnd.toFixed(3)}
-
- updateCurrentMf('coreEnd', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor }} />
-
Fin del Soporte (Punto inferior) {currentMf.supportEnd.toFixed(3)}
updateCurrentMf('supportEnd', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor, opacity: 0.7 }} />
+
+
+ Fin del Núcleo (Punto superior) {currentMf.coreEnd.toFixed(3)}
+
+ updateCurrentMf('coreEnd', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor }} />
+
- {/* Botón subescala derecha */}
onOpenSubscale(selectedTerm, 'right', rightSubscale)}
diff --git a/frontend/src/context/AuthContext.js b/frontend/src/context/AuthContext.js
new file mode 100644
index 0000000..ac7e88c
--- /dev/null
+++ b/frontend/src/context/AuthContext.js
@@ -0,0 +1,7 @@
+import { createContext, useContext } from 'react';
+
+export const AuthContext = createContext();
+
+export const useAuth = () => {
+ return useContext(AuthContext);
+};
\ No newline at end of file
diff --git a/frontend/src/context/AuthProvider.jsx b/frontend/src/context/AuthProvider.jsx
new file mode 100644
index 0000000..4f76f7e
--- /dev/null
+++ b/frontend/src/context/AuthProvider.jsx
@@ -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 (
+
+ {children}
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
index dae5d65..1cabe5d 100644
--- a/frontend/src/lib/api.js
+++ b/frontend/src/lib/api.js
@@ -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;
\ No newline at end of file
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index b9a1a6d..33c21e7 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -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(
-
+
+
+
,
-)
+)
\ No newline at end of file
diff --git a/frontend/src/pages/AdvancedMode.jsx b/frontend/src/pages/AdvancedMode.jsx
deleted file mode 100644
index 88a5a83..0000000
--- a/frontend/src/pages/AdvancedMode.jsx
+++ /dev/null
@@ -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 (
-
-
- {/* PASO 1 */}
- {step === 1 && (
-
-
-
-
- Paso 1: Establecer escala
-
- {needsZoom && (
- {
- 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'}`}
- >
- {isZoomActive ? '🔍' : '🖼️'}
- {isZoomActive ? 'Ver de cerca (Scroll)' : 'Ajustar mesa'}
-
- )}
-
-
-
-
-
-
-
-
-
- {levels.map((level, index) => (
-
-
- 3} />
-
- {index < levels.length - 1 && (
-
- )}
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
- {isLoading ? 'Calculando...' : 'Generar Gráfica Continua'}
-
-
-
- )}
-
- {/* PASO 2 */}
- {step === 2 && (
-
-
-
Paso 2: Modelar Conceptos Difusos
- setStep(1)} className="text-slate-500 hover:text-blue-600 text-sm font-semibold underline">← Volver a las cartas
-
-
-
- {scaleKeys.map((name, index) => {
- const color = COLORS[index % COLORS.length];
- const isSelected = selectedTerm === name;
- return (
- 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'}`}>
- {name} (X: {baseScale[name].toFixed(2)})
-
- );
- })}
-
-
-
-
-
-
-
-
- Guardar Todo el Espectro Difuso
-
-
-
- )}
-
- );
-}
\ No newline at end of file
diff --git a/frontend/src/pages/BasicMode.jsx b/frontend/src/pages/BasicMode.jsx
deleted file mode 100644
index 3d913e6..0000000
--- a/frontend/src/pages/BasicMode.jsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
- {levels.map((level, index) => (
-
-
-
-
- {index < levels.length - 1 && (
-
- )}
-
- ))}
-
-
-
-
-
-
- {isLoading ? 'Calculando...' : 'Calcular Valores DoC'}
-
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/frontend/src/pages/DocEditor.jsx b/frontend/src/pages/DocEditor.jsx
index 4cdc508..8e318ab 100644
--- a/frontend/src/pages/DocEditor.jsx
+++ b/frontend/src/pages/DocEditor.jsx
@@ -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 && (
-
-
+
+
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'}
)}
diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx
new file mode 100644
index 0000000..6947934
--- /dev/null
+++ b/frontend/src/pages/History.jsx
@@ -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 (
+
+ {/* Cabecera */}
+
+
+
Mi Historial
+
+ Aquí están todas las gráficas y modelos que has guardado.
+
+
+
+ + Nuevo Modelo
+
+
+
+ {/* Lista de Historial */}
+ {isLoading ? (
+
+
⏳
+
Cargando tus gráficas...
+
+ ) : historyItems.length === 0 ? (
+
+
📭
+
Aún no has guardado ningún modelo.
+
Ve al editor, crea una gráfica y dale a "Guardar".
+
+ ) : (
+
+ {historyItems.map((item) => {
+ const itemId = item._id || item.id;
+ const isExpanded = expandedId === itemId;
+
+ return (
+
+
+ {/* Cabecera de la Card */}
+
+
+
+ 📊
+
+
+
{item.name || 'Modelo sin título'}
+
+ {item.created_at
+ ? `Guardado el ${new Date(item.created_at).toLocaleDateString('es-ES', { day: '2-digit', month: 'long', year: 'numeric' })}`
+ : 'Guardado en el historial'
+ }
+
+
+
+
+
+ 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 ▾'}
+
+ 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
+
+
+
+
+ {/* Contenido Desplegable (La gráfica)*/}
+
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx
new file mode 100644
index 0000000..8f3ff7a
--- /dev/null
+++ b/frontend/src/pages/Login.jsx
@@ -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 (
+
+
+
+
+
Deck of Cards
+
Accede a tu historial y gráficas guardadas
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+
+ Continuar con Google
+
+
+
¿Nuevo por aquí? Crea una cuenta
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx
new file mode 100644
index 0000000..cdc38fc
--- /dev/null
+++ b/frontend/src/pages/Register.jsx
@@ -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 (
+
+
+
+
+
Crear Cuenta
+
Inicia sesión para guardar tu progreso
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ ¿Ya tienes cuenta? Inicia sesión aquí
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/routers/AppRouter.jsx b/frontend/src/routers/AppRouter.jsx
index 5cc0534..3337675 100644
--- a/frontend/src/routers/AppRouter.jsx
+++ b/frontend/src/routers/AppRouter.jsx
@@ -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 (
-
-
- }>
- } />
- } />
-
-
-
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
);
}
\ No newline at end of file
diff --git a/frontend/src/services/authService.js b/frontend/src/services/authService.js
new file mode 100644
index 0000000..f568a1d
--- /dev/null
+++ b/frontend/src/services/authService.js
@@ -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;
+ }
+};
\ No newline at end of file
diff --git a/frontend/src/services/docService.js b/frontend/src/services/docService.js
index 53c3cc8..56f9b66 100644
--- a/frontend/src/services/docService.js
+++ b/frontend/src/services/docService.js
@@ -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;
+ }
};
\ No newline at end of file