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

Feature/frontend v3
This commit is contained in:
Alexis López
2026-04-15 08:47:47 +02:00
committed by GitHub
35 changed files with 1290 additions and 617 deletions
+9 -9
View File
@@ -1,6 +1,6 @@
# models/docit2mf_models.py
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")
+4 -4
View File
@@ -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}
+18 -5
View File
@@ -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}")
+18 -10
View File
@@ -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
}
}
+10
View File
@@ -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",
+1
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { AppRouter } from './routers/AppRouter';
import AppRouter from './routers/AppRouter';
function App() {
return (
+18 -22
View File
@@ -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) => (
+2 -2
View File
@@ -10,9 +10,9 @@ export default function CardEditor({ index, level, handleLevelChange, handleRemo
)}
<span className="absolute top-3 left-4 text-sm font-black text-slate-300">{index + 1}</span>
<span className="absolute 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>
);
}
+12
View File
@@ -0,0 +1,12 @@
export default function EyeIcon({ isOpen }) {
return isOpen ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="w-5 h-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
);
}
@@ -37,7 +37,7 @@ export default function Step1BaseScale({
const currentScale = isZoomActive && needsZoom ? dynamicScale : 1;
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;
+119 -22
View File
@@ -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 };
};
+95
View File
@@ -0,0 +1,95 @@
export default function Footer() {
return (
<footer className="bg-white border-t border-slate-200 mt-auto shrink-0 w-full pt-12 pb-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-8 lg:gap-6">
{/* Proyecto */}
<div className="lg:col-span-4 flex flex-col">
<div className="flex items-center gap-3 mb-3">
<span className="text-xl font-black text-slate-800 tracking-tight">Deck of Cards</span>
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-[10px] font-black uppercase tracking-widest rounded-md">
Software Científico
</span>
</div>
<p className="text-sm text-slate-500 leading-relaxed max-w-sm">
Plataforma web para la elicitación de escalas de valor y construcción de conjuntos difusos interpretables (DoC-MF).
</p>
</div>
{/* Desarrollo */}
<div className="lg:col-span-3 flex flex-col">
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-3">Ingeniería y Desarrollo</h4>
<ul className="text-sm font-bold text-slate-700 space-y-2">
<li className="flex flex-wrap items-center gap-2">
Alexis López Moral
<span className="text-slate-400 font-medium text-[10px] font-mono bg-slate-50 border border-slate-100 px-1.5 py-0.5 rounded">Frontend</span>
</li>
<li className="flex flex-wrap items-center gap-2">
Mireya Cueto Garrido
<span className="text-slate-400 font-medium text-[10px] font-mono bg-slate-50 border border-slate-100 px-1.5 py-0.5 rounded">Backend</span>
</li>
</ul>
</div>
{/* Dirección Científica */}
<div className="lg:col-span-2 flex flex-col">
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-3">Dirección Científica</h4>
<p className="text-sm font-bold text-slate-700">Luis Martínez López</p>
</div>
{/* Enlaces Institucionales y Código */}
<div className="lg:col-span-3 flex flex-col gap-5 sm:items-start lg:items-end">
{/* Universidad de Jaén */}
<a
href="https://www.ujaen.es/"
target="_blank" rel="noopener noreferrer"
className="group flex items-center gap-3 w-fit"
title="Ir a la web oficial de la Universidad de Jaén"
>
<div className="text-right border-r-2 border-slate-300 group-hover:border-blue-600 pr-3 flex flex-col justify-center h-9 transition-colors">
<span className="text-xs font-black text-slate-800 uppercase tracking-widest leading-none mb-1">Universidad</span>
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-[0.3em] leading-none">de Jaén</span>
</div>
<img
src="/uja-logo.png"
alt="Logo UJA"
className="w-9 h-9 object-contain grayscale group-hover:grayscale-0 transition-all opacity-80 group-hover:opacity-100"
/>
</a>
{/* Repositorio GitHub */}
<a
href="https://github.com/alexislopez-dev/deck-of-cards"
target="_blank" rel="noopener noreferrer"
className="group flex items-center gap-3 w-fit"
title="Ver código fuente en GitHub"
>
<div className="text-right border-r-2 border-slate-300 group-hover:border-slate-800 pr-3 flex flex-col justify-center h-9 transition-colors">
<span className="text-xs font-black text-slate-800 uppercase tracking-widest leading-none mb-1">Repositorio</span>
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-[0.3em] leading-none">Oficial</span>
</div>
<svg className="w-9 h-9 text-slate-400 group-hover:text-slate-800 transition-colors" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
</svg>
</a>
</div>
</div>
{/* Sub-Footer: Copyright y Referencia Científica */}
<div className="mt-6 pt-6 border-t border-slate-100 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">
© {new Date().getFullYear()} Deck of Cards App.
</p>
<p className="text-[10px] font-medium text-slate-400 text-center md:text-right">
Basado en la metodología DoC-MF propuesta por D. García-Zamora, B. Dutta, J.R. Figueira y L. Martínez (EJOR, 2024).
</p>
</div>
</div>
</footer>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { FiLogIn, FiLogOut } from 'react-icons/fi';
export default function Header() {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { user, logout, isAuthenticated } = useAuth();
const userInitial = user?.username ? user.username[0].toUpperCase() : "U";
const handleLogout = () => {
logout();
setIsDropdownOpen(false);
navigate('/login');
};
const isActive = (path) => {
return location.pathname === path || (path === '/editor' && location.pathname === '/');
};
return (
<header className="bg-white shadow-sm border-b border-slate-200 sticky top-0 z-50 h-16 shrink-0 w-full">
<div className="max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 h-full flex items-center justify-between">
<Link to="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity whitespace-nowrap">
<img src="/favicon.svg" alt="Deck of Cards Logo" className="w-10 h-10 shadow-sm rounded-xl object-contain" />
<span className="text-2xl font-black bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-indigo-600 hidden sm:block">
Deck of Cards
</span>
</Link>
<div className="flex items-center gap-4 whitespace-nowrap">
<div className="flex items-center gap-1 mr-2">
<Link to="/editor" className={`text-sm font-bold px-4 py-2 rounded-lg transition-all ${isActive('/editor') ? 'text-blue-600' : 'text-slate-600 hover:text-blue-500'}`}>
Editor
</Link>
{isAuthenticated && (
<Link to="/history" className={`text-sm font-bold px-4 py-2 rounded-lg transition-all ${isActive('/history') ? 'text-blue-600' : 'text-slate-600 hover:text-blue-500'}`}>
Historial
</Link>
)}
</div>
{isAuthenticated ? (
<div className="relative border-l border-slate-200 pl-4">
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="w-10 h-10 rounded-full bg-blue-100 text-blue-700 font-bold flex items-center justify-center border-2 border-blue-200 hover:bg-blue-200 transition-colors"
>
{userInitial}
</button>
{isDropdownOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsDropdownOpen(false)}></div>
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-lg border border-slate-100 py-2 z-50">
<div className="px-4 py-2 border-b border-slate-50">
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">Usuario</p>
<p className="text-sm font-bold text-slate-700 truncate">{user?.username}</p>
</div>
<button
onClick={handleLogout}
className="w-full flex items-center gap-2 px-4 py-2 text-sm font-bold text-red-600 hover:bg-red-50 transition-colors"
>
<FiLogOut className="w-5 h-5" strokeWidth={2.5} />
Cerrar Sesión
</button>
</div>
</>
)}
</div>
) : (
<div className="flex items-center border-l border-slate-200 pl-4">
<Link
to="/login"
className="flex items-center gap-2 text-sm font-bold bg-blue-600 text-white px-5 py-2.5 rounded-xl shadow-sm hover:bg-blue-700 transition-all active:scale-95"
>
<FiLogIn className="w-5 h-5" strokeWidth={2.5} />
Acceder
</Link>
</div>
)}
</div>
</div>
</header>
);
}
+9 -17
View File
@@ -1,26 +1,18 @@
import { Outlet } from 'react-router-dom';
import Header from './Header';
import Footer from './Footer';
export default function MainLayout() {
export default function MainLayout({ children }) {
return (
<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)}
+7
View File
@@ -0,0 +1,7 @@
import { createContext, useContext } from 'react';
export const AuthContext = createContext();
export const useAuth = () => {
return useContext(AuthContext);
};
+42
View File
@@ -0,0 +1,42 @@
import { useState, useCallback } from 'react';
import { AuthContext } from './AuthContext';
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(() => {
try {
const storedUser = localStorage.getItem('user');
return storedUser ? JSON.parse(storedUser) : null;
} catch {
return null;
}
});
const login = useCallback((data) => {
const currentUser = data.user || data;
const token = data.access_token || data.token;
setUser(currentUser);
localStorage.setItem('user', JSON.stringify(currentUser));
if (token) {
localStorage.setItem('token', token);
}
}, []);
const logout = useCallback(() => {
setUser(null);
localStorage.removeItem('user');
localStorage.removeItem('token');
}, []);
return (
<AuthContext.Provider value={{
user,
isAuthenticated: !!user,
login,
logout
}}>
{children}
</AuthContext.Provider>
);
};
+29
View File
@@ -9,5 +9,34 @@ const api = Axios.create({
}
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response && error.response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
}
if (error.response && error.response.data) {
return Promise.reject({
...error,
backendData: error.response.data
});
}
return Promise.reject(error);
}
);
export default api;
+5 -2
View File
@@ -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>,
)
)
-236
View File
@@ -1,236 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import CriterionInput from '../components/CriterionInput';
import CardEditor from '../components/CardEditor';
import BlankCardsCounter from '../components/BlankCardsCounter';
import AddLevelButton from '../components/AddLevelButton';
import Chart from '../components/membershipFunction/Chart';
import Controls from '../components/membershipFunction/Controls';
import { calculateValueFunction } from '../services/docService';
const COLORS = ['#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#d946ef', '#06b6d4', '#8b5cf6', '#f43f5e', '#6366f1'];
export default function AdvancedMode() {
const [step, setStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [criterionName, setCriterionName] = useState('');
const [levels, setLevels] = useState(['', '', '']);
const [blankCards, setBlankCards] = useState([0, 0]);
const [errors, setErrors] = useState({ criterion: false, levels: [] });
const [isZoomActive, setIsZoomActive] = useState(true);
const containerRef = useRef(null);
const tableRef = useRef(null);
const [dimensions, setDimensions] = useState({ container: 1000, table: 0 });
useEffect(() => {
const updateMeasurements = () => {
if (containerRef.current && tableRef.current) {
setDimensions({
container: containerRef.current.offsetWidth,
table: tableRef.current.scrollWidth
});
}
};
const timeoutId = setTimeout(updateMeasurements, 50);
window.addEventListener('resize', updateMeasurements);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', updateMeasurements);
};
}, [levels, blankCards, step]);
// Estados Fase 2 (Franjas)
const [baseScale, setBaseScale] = useState({});
const [selectedTerm, setSelectedTerm] = useState(null);
const [mfDefinitions, setMfDefinitions] = useState({});
// Manejadores de Escala
const handleCriterionChange = (val) => { setCriterionName(val); if (errors.criterion) setErrors({ ...errors, criterion: false }); };
const handleLevelChange = (index, newValue) => { const newLevels = [...levels]; newLevels[index] = newValue; setLevels(newLevels); if (errors.levels[index]) setErrors({ ...errors, levels: errors.levels.map((e, i) => i === index ? false : e) }); };
const handleAddLevel = () => { setLevels([...levels, '']); setBlankCards([...blankCards, 0]); setErrors({ ...errors, levels: [...errors.levels, false] }); };
const handleRemoveLevel = (indexToRemove) => { if (levels.length <= 3) return; setLevels(levels.filter((_, i) => i !== indexToRemove)); setBlankCards(blankCards.filter((_, i) => i !== (indexToRemove === 0 ? 0 : indexToRemove - 1))); setErrors({ ...errors, levels: errors.levels.filter((_, i) => i !== indexToRemove) }); };
const handleBlankCardChange = (index, delta) => { const newCards = [...blankCards]; if (newCards[index] + delta >= 0) { newCards[index] += delta; setBlankCards(newCards); } };
const handleGenerateBaseScale = async () => {
const newErrors = { criterion: !criterionName.trim(), levels: levels.map(l => !l.trim()) };
if (newErrors.criterion || newErrors.levels.includes(true)) {
setErrors(newErrors);
return alert("Por favor, rellena todos los campos.");
}
setIsLoading(true);
try {
const payloadBase = { criterion_name: criterionName.trim(), levels: levels.map(l => l.trim()), blank_cards: blankCards, references: { "0": 0, [(levels.length - 1).toString()]: 1 } };
const baseResult = await calculateValueFunction(payloadBase);
setBaseScale(baseResult.values);
const initialMfs = {};
Object.entries(baseResult.values).forEach(([name, value]) => { initialMfs[name] = { supportStart: value, coreStart: value, coreEnd: value, supportEnd: value }; });
setMfDefinitions(initialMfs);
setSelectedTerm(Object.keys(baseResult.values)[0]);
setStep(2);
} catch (error) { alert("Error: " + error); } finally { setIsLoading(false); }
};
const updateCurrentMf = (field, value) => {
if (!selectedTerm) return;
let numValue = parseFloat(value);
setMfDefinitions(prev => {
const scaleKeys = Object.keys(baseScale);
const selectedIndex = scaleKeys.indexOf(selectedTerm);
let prevCoreEnd = 0, prevSupportEnd = 0, nextCoreStart = 1, nextSupportStart = 1;
if (selectedIndex > 0) {
prevCoreEnd = prev[scaleKeys[selectedIndex - 1]].coreEnd;
prevSupportEnd = prev[scaleKeys[selectedIndex - 1]].supportEnd;
}
if (selectedIndex < scaleKeys.length - 1) {
nextCoreStart = prev[scaleKeys[selectedIndex + 1]].coreStart;
nextSupportStart = prev[scaleKeys[selectedIndex + 1]].supportStart;
}
const anchor = baseScale[selectedTerm];
if (field === 'supportStart' && numValue < prevCoreEnd) numValue = prevCoreEnd;
if (field === 'coreStart' && numValue < prevSupportEnd) numValue = prevSupportEnd;
if (field === 'coreEnd' && numValue > nextSupportStart) numValue = nextSupportStart;
if (field === 'supportEnd' && numValue > nextCoreStart) numValue = nextCoreStart;
if ((field === 'supportStart' || field === 'coreStart') && numValue > anchor) numValue = anchor;
if ((field === 'supportEnd' || field === 'coreEnd') && numValue < anchor) numValue = anchor;
const current = { ...prev[selectedTerm], [field]: numValue };
if (field === 'supportStart') {
if (current.supportStart > current.coreStart) current.coreStart = current.supportStart;
if (current.coreStart > current.coreEnd) current.coreEnd = current.coreStart;
if (current.coreEnd > current.supportEnd) current.supportEnd = current.coreEnd;
} else if (field === 'coreStart') {
if (current.coreStart < current.supportStart) current.supportStart = current.coreStart;
if (current.coreStart > current.coreEnd) current.coreEnd = current.coreStart;
if (current.coreEnd > current.supportEnd) current.supportEnd = current.coreEnd;
} else if (field === 'coreEnd') {
if (current.coreEnd > current.supportEnd) current.supportEnd = current.coreEnd;
if (current.coreEnd < current.coreStart) current.coreStart = current.coreEnd;
if (current.coreStart < current.supportStart) current.supportStart = current.coreStart;
} else if (field === 'supportEnd') {
if (current.supportEnd < current.coreEnd) current.coreEnd = current.supportEnd;
if (current.coreEnd < current.coreStart) current.coreStart = current.coreEnd;
if (current.coreStart < current.supportStart) current.supportStart = current.coreStart;
}
return { ...prev, [selectedTerm]: current };
});
};
const handleFinalSubmit = () => {
console.log("PAYLOAD DOC-MF:", { base_scale: baseScale, membership_functions: mfDefinitions });
alert("¡Mira la consola! JSON preparado.");
};
const scaleKeys = Object.keys(baseScale);
const selectedColor = COLORS[scaleKeys.indexOf(selectedTerm) % COLORS.length] || '#2563eb';
const needsZoom = dimensions.table > dimensions.container;
const dynamicScale = needsZoom ? (dimensions.container / dimensions.table) * 0.95 : 1;
const currentScale = isZoomActive && needsZoom ? dynamicScale : 1;
return (
<div className="w-full flex flex-col items-center">
{/* PASO 1 */}
{step === 1 && (
<div className="w-full bg-white p-6 rounded-2xl shadow-sm border border-slate-200 mb-6 flex flex-col items-center animate-fade-in relative overflow-visible">
<div className="flex justify-between items-center w-full mb-4 border-b pb-3 relative z-30">
<h2 className="text-xl font-bold text-slate-800">
Paso 1: Establecer escala
</h2>
{needsZoom && (
<button
onClick={() => {
if (containerRef.current) containerRef.current.scrollLeft = 0;
setIsZoomActive(!isZoomActive);
}}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg font-bold transition-all shadow-sm border text-sm ${isZoomActive ? 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-white border-slate-200 text-slate-600'}`}
>
<span>{isZoomActive ? '🔍' : '🖼️'}</span>
{isZoomActive ? 'Ver de cerca (Scroll)' : 'Ajustar mesa'}
</button>
)}
</div>
<CriterionInput criterionName={criterionName} setCriterionName={handleCriterionChange} error={errors.criterion} />
<div ref={containerRef} className={`w-full mt-2 transition-all relative ${!isZoomActive && needsZoom ? 'overflow-x-auto flex justify-start pb-8 pt-4 px-4' : 'overflow-hidden flex justify-center pb-8 pt-4'}`}>
<div className={`flex flex-row items-start min-w-max transition-transform duration-500 ease-out px-4 origin-top`} style={{ transform: `scale(${currentScale})`, marginBottom: isZoomActive && currentScale < 1 ? `-${(1 - currentScale) * 300}px` : '0px' }}>
<div ref={tableRef} className="flex flex-row items-start relative px-10 overflow-visible">
{levels.map((level, index) => (
<React.Fragment key={index}>
<div className="flex flex-col items-center mx-2 my-2 relative z-20">
<CardEditor index={index} level={level} handleLevelChange={handleLevelChange} handleRemoveLevel={handleRemoveLevel} totalLevels={levels.length} error={errors.levels[index]} canRemove={levels.length > 3} />
</div>
{index < levels.length - 1 && (
<BlankCardsCounter index={index} blankCardsCount={blankCards[index]} handleBlankCardChange={handleBlankCardChange} />
)}
</React.Fragment>
))}
<div className="mx-1 my-2 h-52 flex items-center justify-center">
<div className="w-10 h-1 bg-slate-200 rounded"></div>
</div>
<AddLevelButton handleAddLevel={handleAddLevel} />
</div>
</div>
</div>
<div className="w-full max-w-lg mt-2 pt-6 border-t border-slate-200 flex flex-col items-center z-20 relative bg-white">
<button onClick={handleGenerateBaseScale} disabled={isLoading} className={`w-full py-3 text-white text-lg font-bold rounded-xl shadow-md transition-all active:scale-[0.98] ${isLoading ? 'bg-slate-400 cursor-not-allowed' : 'bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700'}`}>
{isLoading ? 'Calculando...' : 'Generar Gráfica Continua'}
</button>
</div>
</div>
)}
{/* PASO 2 */}
{step === 2 && (
<div className="w-full bg-white p-6 rounded-2xl shadow-sm border border-slate-200 animate-fade-in relative overflow-visible">
<div className="flex justify-between items-center mb-6 border-b pb-3">
<h2 className="text-xl font-bold text-slate-800">Paso 2: Modelar Conceptos Difusos</h2>
<button onClick={() => setStep(1)} className="text-slate-500 hover:text-blue-600 text-sm font-semibold underline"> Volver a las cartas</button>
</div>
<div className="flex flex-wrap justify-center gap-3 mb-6">
{scaleKeys.map((name, index) => {
const color = COLORS[index % COLORS.length];
const isSelected = selectedTerm === name;
return (
<button key={name} onClick={() => setSelectedTerm(name)} style={isSelected ? { backgroundColor: color, borderColor: color, color: '#fff' } : { borderColor: color, color: '#475569' }} className={`px-5 py-2 rounded-lg font-bold border-2 transition-all duration-300 flex flex-col items-center shadow-sm hover:shadow-md ${isSelected ? 'transform scale-105' : 'bg-white opacity-80 hover:opacity-100'}`}>
<span>{name}</span><span className="text-[10px] font-normal opacity-80">(X: {baseScale[name].toFixed(2)})</span>
</button>
);
})}
</div>
<Chart baseScale={baseScale} mfDefinitions={mfDefinitions} selectedTerm={selectedTerm} colors={COLORS} />
<Controls selectedTerm={selectedTerm} currentMf={mfDefinitions[selectedTerm]} selectedColor={selectedColor} baseScale={baseScale} mfDefinitions={mfDefinitions} updateCurrentMf={updateCurrentMf} />
<div className="w-full mt-8 flex justify-center">
<button onClick={handleFinalSubmit} className="px-10 py-3 bg-slate-900 text-white text-lg font-bold rounded-xl shadow-md hover:bg-black hover:shadow-lg transition-all">
Guardar Todo el Espectro Difuso
</button>
</div>
</div>
)}
</div>
);
}
-156
View File
@@ -1,156 +0,0 @@
import { useState } from 'react';
import CriterionInput from '../components/CriterionInput';
import CardEditor from '../components/CardEditor';
import BlankCardsCounter from '../components/BlankCardsCounter';
import AddLevelButton from '../components/AddLevelButton';
import ValueFunctionChart from '../components/ValueFunctionChart';
import { calculateValueFunction } from '../services/docService';
export default function BasicMode() {
const [criterionName, setCriterionName] = useState('');
const [levels, setLevels] = useState(['', '', '']);
const [blankCards, setBlankCards] = useState([0, 0]);
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState(null);
const [errors, setErrors] = useState({ criterion: false, levels: [] });
const handleCalculate = async () => {
let hasError = false;
const newErrors = { criterion: false, levels: Array(levels.length).fill(false) };
if (!criterionName.trim()) {
newErrors.criterion = true;
hasError = true;
}
levels.forEach((level, idx) => {
if (!level.trim()) {
newErrors.levels[idx] = true;
hasError = true;
}
});
setErrors(newErrors);
if (hasError) return;
setIsLoading(true);
setResult(null);
const payload = {
criterion_name: criterionName.trim(),
levels: levels.map(l => l.trim()),
blank_cards: blankCards,
references: { "0": 0, [(levels.length - 1).toString()]: 1 }
};
try {
const data = await calculateValueFunction(payload);
setResult(data);
} catch (error) {
alert("No se ha podido conectar con el backend: " + error);
} finally {
setIsLoading(false);
}
};
const handleCriterionChange = (val) => {
setCriterionName(val);
if (errors.criterion) setErrors({ ...errors, criterion: false });
};
const handleLevelChange = (index, newValue) => {
const newLevels = [...levels];
newLevels[index] = newValue;
setLevels(newLevels);
if (errors.levels[index]) {
const newErrLevels = [...errors.levels];
newErrLevels[index] = false;
setErrors({ ...errors, levels: newErrLevels });
}
};
const handleAddLevel = () => {
setLevels([...levels, '']);
setBlankCards([...blankCards, 0]);
setErrors({ ...errors, levels: [...errors.levels, false] });
};
const handleRemoveLevel = (indexToRemove) => {
if (levels.length <= 3) return;
const newLevels = levels.filter((_, index) => index !== indexToRemove);
const blankIndexToRemove = indexToRemove === 0 ? 0 : indexToRemove - 1;
const newBlankCards = blankCards.filter((_, index) => index !== blankIndexToRemove);
const newErrLevels = errors.levels.filter((_, index) => index !== indexToRemove);
setLevels(newLevels);
setBlankCards(newBlankCards);
setErrors({ ...errors, levels: newErrLevels });
};
const handleBlankCardChange = (index, delta) => {
const newBlankCards = [...blankCards];
const newValue = newBlankCards[index] + delta;
if (newValue >= 0) {
newBlankCards[index] = newValue;
setBlankCards(newBlankCards);
}
};
return (
<div className="w-full flex flex-col items-center">
<CriterionInput
criterionName={criterionName}
setCriterionName={handleCriterionChange}
error={errors.criterion}
/>
<div className="w-full max-w-lg flex flex-col items-center">
{levels.map((level, index) => (
<div key={index} className="w-full flex flex-col items-center">
<CardEditor
index={index}
level={level}
handleLevelChange={handleLevelChange}
handleRemoveLevel={handleRemoveLevel}
totalLevels={levels.length}
error={errors.levels[index]}
/>
{index < levels.length - 1 && (
<BlankCardsCounter
index={index}
blankCardsCount={blankCards[index]}
handleBlankCardChange={handleBlankCardChange}
/>
)}
</div>
))}
<AddLevelButton handleAddLevel={handleAddLevel} />
</div>
<div className="w-full max-w-lg mt-12 pt-8 border-t-2 border-slate-200 flex flex-col items-center">
<button
onClick={handleCalculate}
disabled={isLoading}
className={`w-full py-4 text-white text-xl font-bold rounded-xl shadow-lg transition-all active:scale-[0.98] ${
isLoading ? 'bg-slate-400 cursor-not-allowed' : 'bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 hover:shadow-xl'
}`}
>
{isLoading ? 'Calculando...' : 'Calcular Valores DoC'}
</button>
</div>
<ValueFunctionChart result={result} />
</div>
);
}
+67 -11
View File
@@ -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>
)}
+141
View File
@@ -0,0 +1,141 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getUserHistory, deleteHistoryItem } from '../services/docService';
import Step3FinalGraph from '../components/editor/Step3FinalGraph';
export default function History() {
const [historyItems, setHistoryItems] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [expandedId, setExpandedId] = useState(null);
useEffect(() => {
fetchHistory();
}, []);
const fetchHistory = async () => {
setIsLoading(true);
try {
const data = await getUserHistory();
const items = Array.isArray(data) ? data : data.history || data.items || [];
setHistoryItems(items.reverse());
} catch (error) {
console.error("Error fetching history:", error);
alert("Hubo un problema al cargar el historial.");
} finally {
setIsLoading(false);
}
};
const handleDelete = async (id) => {
if (!window.confirm('¿Seguro que quieres borrar este modelo definitivamente?')) return;
try {
await deleteHistoryItem(id);
setHistoryItems(prev => prev.filter(item => item._id !== id && item.id !== id));
if (expandedId === id) setExpandedId(null);
} catch (error) {
alert("Error al borrar: " + error);
}
};
const toggleExpand = (id) => {
setExpandedId(expandedId === id ? null : id);
};
return (
<div className="w-full max-w-7xl mx-auto flex flex-col gap-8 animate-fade-in pb-2">
{/* Cabecera */}
<div className="flex justify-between items-center bg-white p-8 rounded-3xl shadow-sm border border-slate-200">
<div>
<h1 className="text-3xl font-black text-slate-800">Mi Historial</h1>
<p className="text-slate-500 font-medium mt-1">
Aquí están todas las gráficas y modelos que has guardado.
</p>
</div>
<Link
to="/editor"
className="px-6 py-3 bg-blue-50 text-blue-600 font-bold rounded-xl hover:bg-blue-100 transition-colors shadow-sm"
>
+ Nuevo Modelo
</Link>
</div>
{/* Lista de Historial */}
{isLoading ? (
<div className="bg-white p-12 rounded-3xl shadow-sm border border-slate-200 flex flex-col items-center justify-center text-slate-400 border-dashed">
<div className="animate-spin text-4xl mb-4"></div>
<p className="font-medium">Cargando tus gráficas...</p>
</div>
) : historyItems.length === 0 ? (
<div className="bg-white p-12 rounded-3xl shadow-sm border border-slate-200 flex flex-col items-center justify-center text-slate-400 border-dashed">
<span className="text-6xl mb-4">📭</span>
<p className="font-medium text-lg">Aún no has guardado ningún modelo.</p>
<p className="text-sm mt-2">Ve al editor, crea una gráfica y dale a "Guardar".</p>
</div>
) : (
<div className="flex flex-col gap-6">
{historyItems.map((item) => {
const itemId = item._id || item.id;
const isExpanded = expandedId === itemId;
return (
<div key={itemId} className={`bg-white rounded-2xl shadow-sm border transition-all duration-300 ${isExpanded ? 'border-blue-300 ring-4 ring-blue-50' : 'border-slate-200 hover:border-slate-300'}`}>
{/* Cabecera de la Card */}
<div className="p-6 flex flex-col sm:flex-row justify-between items-center gap-4 bg-slate-50/50 rounded-t-2xl">
<div className="flex items-center gap-4 w-full sm:w-auto">
<div className="w-12 h-12 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-xl shadow-inner">
📊
</div>
<div>
<h3 className="text-xl font-bold text-slate-800">{item.name || 'Modelo sin título'}</h3>
<p className="text-sm text-slate-500 font-medium">
{item.created_at
? `Guardado el ${new Date(item.created_at).toLocaleDateString('es-ES', { day: '2-digit', month: 'long', year: 'numeric' })}`
: 'Guardado en el historial'
}
</p>
</div>
</div>
<div className="flex gap-3 w-full sm:w-auto">
<button
onClick={() => toggleExpand(itemId)}
className={`flex-1 sm:flex-none px-6 py-2.5 font-bold rounded-xl transition-colors ${isExpanded ? 'bg-slate-200 text-slate-700 hover:bg-slate-300' : 'bg-blue-50 text-blue-600 hover:bg-blue-100'}`}
>
{isExpanded ? 'Ocultar Gráfica ▴' : 'Ver Gráfica ▾'}
</button>
<button
onClick={() => handleDelete(itemId)}
className="px-4 py-2.5 bg-white border border-red-200 text-red-500 font-bold rounded-xl hover:bg-red-50 transition-colors shadow-sm"
title="Borrar modelo"
>
Borrar
</button>
</div>
</div>
{/* Contenido Desplegable (La gráfica)*/}
<div
className={`transition-all duration-500 ease-in-out overflow-hidden ${
isExpanded ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
}`}
>
<div className="p-6 border-t border-slate-100 bg-white rounded-b-2xl">
{isExpanded ? (
<Step3FinalGraph data={item} criterionName={item.name} />
) : (
<div className="h-[550px] w-full" />
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { useState, useEffect, useRef } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { authService } from '../services/authService';
import EyeIcon from '../components/EyeIcon';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
const { login } = useAuth();
const [searchParams] = useSearchParams();
const googleLoginProcessed = useRef(false);
useEffect(() => {
const token = searchParams.get('token');
if (token && !googleLoginProcessed.current) {
googleLoginProcessed.current = true;
const url = new URL(window.location);
url.searchParams.delete('token');
window.history.replaceState({}, '', url);
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
const decodedToken = JSON.parse(jsonPayload);
const googleUser = {
_id: decodedToken.sub || decodedToken.user_id || "google_id",
username: decodedToken.email ? decodedToken.email.split('@')[0] : "Usuario Google",
email: decodedToken.email || ""
};
login({ user: googleUser, access_token: token });
navigate('/', { replace: true });
} catch (err) {
console.error("Error al decodificar el token de Google:", err);
setTimeout(() => {
setError("Error al procesar el login con Google. El token está corrupto.");
}, 0);
}
}
}, [searchParams, login, navigate]);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
const data = await authService.login(email, password);
login(data);
navigate('/');
} catch (err) {
setError('Credenciales incorrectas.');
}
};
const handleGoogleLogin = () => {
window.location.href = "http://localhost:8000/api/auth/google/login";
};
return (
<div className="flex-1 flex items-center justify-center">
<div className="max-w-md w-full bg-white p-10 rounded-3xl shadow-sm border border-slate-200">
<div className="text-center mb-8">
<h2 className="text-3xl font-black text-slate-800 tracking-tight">Deck of Cards</h2>
<p className="text-slate-500 mt-2">Accede a tu historial y gráficas guardadas</p>
</div>
{error && (
<div className="bg-red-50 text-red-600 p-4 rounded-2xl text-sm font-bold mb-6 border border-red-100 text-center">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-bold text-slate-700 ml-1">Email</label>
<input
type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
className="w-full px-5 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
placeholder="correo@ejemplo.com"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-bold text-slate-700 ml-1">Contraseña</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
required value={password} onChange={(e) => setPassword(e.target.value)}
className="w-full pl-5 pr-12 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors focus:outline-none"
>
<EyeIcon isOpen={showPassword} />
</button>
</div>
</div>
<button type="submit" className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl transition-all shadow-sm active:scale-95 mt-2">
Entrar
</button>
</form>
<div className="relative my-8">
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-slate-100"></div></div>
<div className="relative flex justify-center text-xs uppercase tracking-widest"><span className="px-3 bg-white text-slate-400 font-bold">O</span></div>
</div>
<button type="button" onClick={handleGoogleLogin} className="w-full flex items-center justify-center gap-3 px-4 py-4 border-2 border-slate-100 rounded-2xl bg-white text-slate-700 font-bold hover:bg-slate-50 hover:border-slate-200 transition-all shadow-sm active:scale-95">
<svg className="w-5 h-5" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" /><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" /><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" /><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" /></svg>
Continuar con Google
</button>
<p className="mt-8 text-center text-sm text-slate-500 font-medium">¿Nuevo por aquí? <Link to="/register" className="text-blue-600 hover:underline font-extrabold">Crea una cuenta</Link></p>
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { authService } from '../services/authService';
import EyeIcon from '../components/EyeIcon';
export default function Register() {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
const { login } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Las contraseñas no coinciden. Por favor, revísalas.');
return;
}
try {
const data = await authService.register(username, email, password);
const userData = { id: data.user_id, username: username, email: email };
login(userData, data.token);
navigate('/');
} catch (err) {
setError(err.response?.data?.detail || 'Error al registrar el usuario.');
}
};
return (
<div className="flex-1 flex items-center justify-center">
<div className="max-w-md w-full bg-white p-10 rounded-3xl shadow-sm border border-slate-200">
<div className="text-center mb-8">
<h2 className="text-3xl font-black text-slate-800 tracking-tight">Crear Cuenta</h2>
<p className="text-slate-500 mt-2">Inicia sesión para guardar tu progreso</p>
</div>
{error && (
<div className="bg-red-50 text-red-600 p-4 rounded-2xl text-sm font-bold mb-6 border border-red-100 text-center">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-bold text-slate-700 ml-1">Nombre de usuario</label>
<input
type="text" required autoComplete="username"
className="w-full px-5 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
value={username} onChange={(e) => setUsername(e.target.value)}
placeholder="Ej: usuario99"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-bold text-slate-700 ml-1">Email</label>
<input
type="email" required autoComplete="email"
className="w-full px-5 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
value={email} onChange={(e) => setEmail(e.target.value)}
placeholder="tu@email.com"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-bold text-slate-700 ml-1">Contraseña</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
required autoComplete="new-password"
className="w-full pl-5 pr-12 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
value={password} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
/>
<button
type="button" onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors focus:outline-none"
>
<EyeIcon isOpen={showPassword} />
</button>
</div>
</div>
<div className="space-y-1">
<label className="text-sm font-bold text-slate-700 ml-1">Confirmar contraseña</label>
<div className="relative">
<input
type={showConfirmPassword ? "text" : "password"}
required autoComplete="new-password"
className="w-full pl-5 pr-12 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
/>
<button
type="button" onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors focus:outline-none"
>
<EyeIcon isOpen={showConfirmPassword} />
</button>
</div>
</div>
<button type="submit" className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl transition-all shadow-sm active:scale-95 mt-2">
Registrarse
</button>
</form>
<p className="mt-8 text-center text-sm text-slate-500 font-medium">
¿Ya tienes cuenta? <Link to="/login" className="text-blue-600 hover:underline font-extrabold">Inicia sesión aquí</Link>
</p>
</div>
</div>
);
}
+16 -10
View File
@@ -1,16 +1,22 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import MainLayout from '../components/layout/MainLayout';
import 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>
);
}
+18
View File
@@ -0,0 +1,18 @@
import api from '../lib/api';
export const authService = {
login: async (email, password) => {
const response = await api.post('/auth/login', { email, password });
return response.data;
},
register: async (username, email, password) => {
const response = await api.post('/auth/register', { username, email, password });
return response.data;
},
getCurrentUser: async () => {
const response = await api.get('/auth/me');
return response.data;
}
};
+33 -5
View File
@@ -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;
}
};