2 Commits

127 changed files with 6295 additions and 16201 deletions

View File

@@ -1 +0,0 @@
Al funcionar como agente, ignora los problemas de eslint del orden de imports

1
.gitignore vendored
View File

@@ -8,4 +8,3 @@ count.txt
.nitro
.tanstack
.wrangler
diff.txt

View File

@@ -1,7 +0,0 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss"
]
}

1
.vscode/launch.json vendored
View File

@@ -2,7 +2,6 @@
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
// close #40
"version": "0.2.0",
"configurations": [
{

View File

@@ -22,11 +22,5 @@
],
"files.associations": {
"*.css": "tailwindcss"
},
"[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[javascriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"prettier.requireConfig": true
}
}

View File

@@ -1,7 +1,7 @@
FROM oven/bun:1 AS build
WORKDIR /app
COPY . .
RUN bun install
RUN bun install --frozen-lockfile
RUN bunx --bun vite build
FROM nginx:alpine

304
bun.lock
View File

@@ -6,9 +6,7 @@
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
@@ -20,7 +18,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@stepperize/react": "^5.1.9",
"@supabase/supabase-js": "^2.98.0",
"@supabase/supabase-js": "^2.90.1",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5",
@@ -28,29 +26,22 @@
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-router-devtools": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"@types/canvas-confetti": "^1.9.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"motion": "^12.24.7",
"radix-ui": "^1.4.3",
"lucide-react": "^0.561.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0",
"vaul": "^1.1.2",
},
"devDependencies": {
"@tanstack/devtools-vite": "^0.3.11",
"@tanstack/eslint-config": "^0.3.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/bun": "^1.3.6",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
@@ -59,7 +50,6 @@
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-unused-imports": "^4.3.0",
"jsdom": "^27.0.0",
"prettier": "^3.5.3",
@@ -73,7 +63,7 @@
},
},
"packages": {
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
"@acemir/cssom": ["@acemir/cssom@0.9.30", "", {}, "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.1", "", { "dependencies": { "@csstools/css-calc": "^2.1.4", "@csstools/css-color-parser": "^3.1.0", "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", "lru-cache": "^11.2.4" } }, "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ=="],
@@ -81,23 +71,23 @@
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@babel/code-frame": ["@babel/code-frame@7.28.6", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.6", "", {}, "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
"@babel/core": ["@babel/core@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw=="],
"@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
"@babel/generator": ["@babel/generator@7.28.6", "", { "dependencies": { "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw=="],
"@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
@@ -105,25 +95,25 @@
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
"@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
"@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="],
"@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w=="],
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="],
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ=="],
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="],
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
"@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="],
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
@@ -133,7 +123,7 @@
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.26", "", {}, "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.22", "", {}, "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
@@ -213,7 +203,7 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@exodus/bytes": ["@exodus/bytes@1.10.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg=="],
"@exodus/bytes": ["@exodus/bytes@1.8.0", "", { "peerDependencies": { "@exodus/crypto": "^1.0.0-rc.4" }, "optionalPeers": ["@exodus/crypto"] }, "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
@@ -253,20 +243,12 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.7", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A=="],
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
@@ -275,8 +257,6 @@
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
@@ -289,24 +269,12 @@
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-form": ["@radix-ui/react-form@0.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ=="],
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="],
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="],
"@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg=="],
"@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
@@ -317,10 +285,6 @@
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
@@ -329,22 +293,10 @@
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="],
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
"@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="],
"@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
@@ -371,55 +323,55 @@
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.56.0", "", { "os": "android", "cpu": "arm" }, "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.56.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.56.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.55.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.56.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.55.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.56.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.55.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.56.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.55.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.56.0", "", { "os": "linux", "cpu": "arm" }, "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.56.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.56.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.56.0", "", { "os": "linux", "cpu": "none" }, "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.56.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.55.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.56.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.56.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.55.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.56.0", "", { "os": "none", "cpu": "arm64" }, "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.1", "", { "os": "none", "cpu": "arm64" }, "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.56.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.56.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="],
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
@@ -439,19 +391,19 @@
"@stepperize/react": ["@stepperize/react@5.1.9", "", { "dependencies": { "@stepperize/core": "1.2.7" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yBgw1I5Tx6/qZB4xTdVBaPGfTqH5aYS1WFB5vtR8+fwPeqd3YNuOnQ1pJM6w/xV/gvryuy31hbFw080lZc+/hw=="],
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.7.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.53.1", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-zjTUwIsEfT+k9BmXwq1QEFYsb4afBlsI1AXFyWQBgggMzwBFOuu92pGrE5OFx90IOjNl+lUbQoTG7f8S0PkOdg=="],
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.6.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.0", "@typescript-eslint/types": "^8.47.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-JCs+MqoXfXrRPGbGmho/zGS/jMcn3ieKl/A8YImqib76C8kjgZwq5uUFzc30lJkMvcchuRn6/v8IApLxli3Jyw=="],
"@supabase/auth-js": ["@supabase/auth-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg=="],
"@supabase/auth-js": ["@supabase/auth-js@2.90.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng=="],
"@supabase/functions-js": ["@supabase/functions-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg=="],
"@supabase/functions-js": ["@supabase/functions-js@2.90.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.90.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ=="],
"@supabase/realtime-js": ["@supabase/realtime-js@2.98.0", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw=="],
"@supabase/realtime-js": ["@supabase/realtime-js@2.90.1", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w=="],
"@supabase/storage-js": ["@supabase/storage-js@2.98.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ=="],
"@supabase/storage-js": ["@supabase/storage-js@2.90.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg=="],
"@supabase/supabase-js": ["@supabase/supabase-js@2.98.0", "", { "dependencies": { "@supabase/auth-js": "2.98.0", "@supabase/functions-js": "2.98.0", "@supabase/postgrest-js": "2.98.0", "@supabase/realtime-js": "2.98.0", "@supabase/storage-js": "2.98.0" } }, "sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw=="],
"@supabase/supabase-js": ["@supabase/supabase-js@2.90.1", "", { "dependencies": { "@supabase/auth-js": "2.90.1", "@supabase/functions-js": "2.90.1", "@supabase/postgrest-js": "2.90.1", "@supabase/realtime-js": "2.90.1", "@supabase/storage-js": "2.90.1" } }, "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
@@ -497,41 +449,41 @@
"@tanstack/eslint-config": ["@tanstack/eslint-config@0.3.4", "", { "dependencies": { "@eslint/js": "^9.37.0", "@stylistic/eslint-plugin": "^5.4.0", "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-n": "^17.23.1", "globals": "^16.5.0", "typescript-eslint": "^8.46.0", "vue-eslint-parser": "^10.2.0" }, "peerDependencies": { "eslint": "^8.0.0 || ^9.0.0" } }, "sha512-5Ou1XWJRCTx5G8WoCbT7+6nQ4iNdsISzBAc4lXpFy2fEOO7xioOSPvcPIv+r9V0drPPETou2tr6oLGZZ909FKg=="],
"@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
"@tanstack/history": ["@tanstack/history@1.145.7", "", {}, "sha512-gMo/ReTUp0a3IOcZoI3hH6PLDC2R/5ELQ7P2yu9F6aEkA0wSQh+Q4qzMrtcKvF2ut0oE+16xWCGDo/TdYd6cEQ=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="],
"@tanstack/query-devtools": ["@tanstack/query-devtools@5.92.0", "", {}, "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ=="],
"@tanstack/react-devtools": ["@tanstack/react-devtools@0.7.11", "", { "dependencies": { "@tanstack/devtools": "0.7.0" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-a2Lmz8x+JoDrsU6f7uKRcyyY+k8mA/n5mb9h7XJ3Fz/y3+sPV9t7vAW1s5lyNkQyyDt6V1Oim99faLthoJSxMw=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.16", "", { "dependencies": { "@tanstack/query-core": "5.90.16" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ=="],
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.2", "", { "dependencies": { "@tanstack/query-devtools": "5.92.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.14", "react": "^18 || ^19" } }, "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg=="],
"@tanstack/react-router": ["@tanstack/react-router@1.157.15", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.157.15", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-dVHX3Ann1rxLkXCrB9ctNKveGOrkmlKMo5fDIaaPCqqkDN/aC1gZ9O93i0OQVPUNekpkdXijmpHkxw12WqMTRQ=="],
"@tanstack/react-router": ["@tanstack/react-router@1.145.7", "", { "dependencies": { "@tanstack/history": "1.145.7", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.145.7", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-0O+a4TjJSPXd2BsvDPwDPBKRQKYqNIBg5TAg9NzCteqJ0NXRxwohyqCksHqCEEtJe/uItwqmHoqkK4q5MDhEsA=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.157.15", "", { "dependencies": { "@tanstack/router-devtools-core": "1.157.15" }, "peerDependencies": { "@tanstack/react-router": "^1.157.15", "@tanstack/router-core": "^1.157.15", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-WNxsQaoVz1MDINKbWJ7xGYg0xyG9UAnRq7cYNFypDFyX6gqfiQUTxpFMVZfaw1sv+/fI/6E+hd7WChu1rrfBqQ=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.145.7", "", { "dependencies": { "@tanstack/router-devtools-core": "1.145.7" }, "peerDependencies": { "@tanstack/react-router": "^1.145.7", "@tanstack/router-core": "^1.145.7", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-crzHSQ/rcGX7RfuYsmm1XG5quurNMDTIApU7jfwDx5J9HnUxCOSJrbFX0L3w0o0VRCw5xhrL2EdCnW78Ic86hg=="],
"@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="],
"@tanstack/router-core": ["@tanstack/router-core@1.157.15", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-KaYz6s+wYcg92kRQ7HXlTJLhBaBXOYiiqRBv5tsRbKRIqqhWNyeGz5+NfDwaYFHg5XLSDs3DvN0elMtxcj4dTg=="],
"@tanstack/router-core": ["@tanstack/router-core@1.145.7", "", { "dependencies": { "@tanstack/history": "1.145.7", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.1", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-v6jx6JqVUBM0/FcBq1tX22xiPq8Ufc0PDEP582/4deYoq2/RYd+bZstANp3mGSsqdxE/luhoLYuuSQiwi/j1wA=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.157.15", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.157.15", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-udqDYuJUtVfPmk/4yhtOZl1dYlze/rMqaj3v/jQRS8TeGqWYal48Q18hM3A5Bd2YqORvaAkOQsI7JWKYnvxCiQ=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.145.7", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.145.7", "csstype": "^3.0.10", "solid-js": ">=1.9.5" }, "optionalPeers": ["csstype"] }, "sha512-oKeq/6QvN49THCh++FJyPv1X65i20qGS4aJHQTNsl4cu1piW1zWUhab2L3DZVr3G8C40FW3xb6hVw92N/fzZbQ=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.157.15", "", { "dependencies": { "@tanstack/router-core": "1.157.15", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-zGac6tyRFz/X86fk9/CAmS6z8lyZf4p9lhAqLBCKVkFiFPmU4eAJp1ODvs81EtV0uJdRL1/rb+uvgHLGUsmQ0g=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.145.7", "", { "dependencies": { "@tanstack/router-core": "1.145.7", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-xg71c1WTku0ro0rgpJWh3Dt+ognV9qWe2KJHAPzrqfOYdUYu9sGq7Ri4jo8Rk0luXWZrWsrFdBP+9Jx6JH6zWA=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.157.15", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.157.15", "@tanstack/router-generator": "1.157.15", "@tanstack/router-utils": "1.154.7", "@tanstack/virtual-file-routes": "1.154.7", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.157.15", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-EpRYRb35//sVJ8GPBhthqfPt9HNhx1xAaejiQ8i4vkG37et6qaSGAO+Woq91WjnpmxMYs4+sNJpGioPuVLBBqQ=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.145.7", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.145.7", "@tanstack/router-generator": "1.145.7", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.145.7", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-Rimo0NragYKHwjoYX9JBLS8VkZD4D/LqzzLIlX9yz93lmWFRu/DbuS7fDZNqX1Ea8naNvo18DlySszYLzC8XDg=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.154.7", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-61bGx32tMKuEpVRseu2sh1KQe8CfB7793Mch/kyQt0EP3tD7X0sXmimCl3truRiDGUtI0CaSoQV1NPjAII1RBA=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.143.11", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-N24G4LpfyK8dOlnP8BvNdkuxg1xQljkyl6PcrdiPSA301pOjatRT1y8wuCCJZKVVD8gkd0MpCZ0VEjRMGILOtA=="],
"@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.154.7", "", {}, "sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.145.4", "", {}, "sha512-CI75JrfqSluhdGwLssgVeQBaCphgfkMQpi8MCY3UJX1hoGzXa8kHYJcUuIFMOLs1q7zqHy++EVVtMK03osR5wQ=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
"@testing-library/react": ["@testing-library/react@16.3.1", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -545,10 +497,6 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@@ -559,35 +507,35 @@
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
"@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
"@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
"@types/phoenix": ["@types/phoenix@1.6.7", "", {}, "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="],
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.54.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/utils": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.52.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/type-utils": "8.52.0", "@typescript-eslint/utils": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.52.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.54.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.52.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.54.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.54.0", "@typescript-eslint/types": "^8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.52.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.52.0", "@typescript-eslint/types": "^8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0" } }, "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0" } }, "sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.54.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.52.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/utils": "8.52.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.52.0", "", {}, "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.54.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.54.0", "@typescript-eslint/tsconfig-utils": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.52.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.52.0", "@typescript-eslint/tsconfig-utils": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.54.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.52.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
@@ -687,15 +635,15 @@
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"axe-core": ["axe-core@4.11.1", "", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="],
"axe-core": ["axe-core@4.11.0", "", {}, "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.11", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-mwq3W3e/pKSI6TG8lXMiDWvEi1VXYlSBlJlB3l+I0bAb5u1RNUl88udos85eOPNK3m5EXK9uO7d2g08pesTySQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
@@ -709,8 +657,6 @@
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
@@ -721,9 +667,7 @@
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
"canvas-confetti": ["canvas-confetti@1.9.4", "", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001762", "", {}, "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
@@ -747,7 +691,7 @@
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"comment-parser": ["comment-parser@1.4.5", "", {}, "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw=="],
"comment-parser": ["comment-parser@1.4.1", "", {}, "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
@@ -759,7 +703,7 @@
"css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="],
"cssstyle": ["cssstyle@5.3.7", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0", "lru-cache": "^11.2.4" } }, "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ=="],
"cssstyle": ["cssstyle@5.3.6", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0", "lru-cache": "^11.2.4" } }, "sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -767,7 +711,7 @@
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"data-urls": ["data-urls@6.0.1", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^15.1.0" } }, "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ=="],
"data-urls": ["data-urls@6.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.0.0" } }, "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
@@ -795,7 +739,7 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
@@ -803,7 +747,7 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.279", "", {}, "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg=="],
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
@@ -855,9 +799,7 @@
"eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="],
"eslint-plugin-n": ["eslint-plugin-n@17.23.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.5.0", "enhanced-resolve": "^5.17.1", "eslint-plugin-es-x": "^7.8.0", "get-tsconfig": "^4.8.1", "globals": "^15.11.0", "globrex": "^0.1.2", "ignore": "^5.3.2", "semver": "^7.6.3", "ts-declaration-location": "^1.0.6" }, "peerDependencies": { "eslint": ">=8.23.0" } }, "sha512-RhWBeb7YVPmNa2eggvJooiuehdL76/bbfj/OJewyoGT80qn5PXdz8zMOTO6YHOsI7byPt7+Ighh/i/4a5/v7hw=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
"eslint-plugin-n": ["eslint-plugin-n@17.23.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.5.0", "enhanced-resolve": "^5.17.1", "eslint-plugin-es-x": "^7.8.0", "get-tsconfig": "^4.8.1", "globals": "^15.11.0", "globrex": "^0.1.2", "ignore": "^5.3.2", "semver": "^7.6.3", "ts-declaration-location": "^1.0.6" }, "peerDependencies": { "eslint": ">=8.23.0" } }, "sha512-68PealUpYoHOBh332JLLD9Sj7OQUDkFpmcfqt8R9sySfFSeuGJjMTJQvCRRB96zO3A/PELRLkPrzsHmzEFQQ5A=="],
"eslint-plugin-unused-imports": ["eslint-plugin-unused-imports@4.3.0", "", { "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", "eslint": "^9.0.0 || ^8.0.0" }, "optionalPeers": ["@typescript-eslint/eslint-plugin"] }, "sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA=="],
@@ -905,8 +847,6 @@
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"framer-motion": ["framer-motion@12.29.2", "", { "dependencies": { "motion-dom": "^12.29.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-lSNRzBJk4wuIy0emYQ/nfZ7eWhqud2umPKw2QAQki6uKhZPKm2hRQHeQoHTG9MIvfobb+A/LbEWPJU794ZUKrg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -957,10 +897,6 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
@@ -1035,7 +971,7 @@
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"isbot": ["isbot@5.1.34", "", {}, "sha512-aCMIBSKd/XPRYdiCQTLC8QHH4YT8B3JUADu+7COgYIZPvkeoMcUHMRjZLM9/7V8fCj+l7FSREc1lOPNjzogo/A=="],
"isbot": ["isbot@5.1.32", "", {}, "sha512-VNfjM73zz2IBZmdShMfAUg10prm6t7HFUQmNAEOAVS4YH92ZrZcvkMcGX6cIgBJAzWDzPent/EeAtYEHNPNPBQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
@@ -1099,9 +1035,9 @@
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"lru-cache": ["lru-cache@11.2.5", "", {}, "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw=="],
"lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
"lucide-react": ["lucide-react@0.561.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
@@ -1119,12 +1055,6 @@
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"motion": ["motion@12.29.2", "", { "dependencies": { "framer-motion": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-jMpHdAzEDF1QQ055cB+1lOBLdJ6ialVWl6QQzpJI2OvmHequ7zFVHM2mx0HNAy+Tu4omUlApfC+4vnkX0geEOg=="],
"motion-dom": ["motion-dom@12.29.2", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-/k+NuycVV8pykxyiTCoFzIVLA95Nb1BFIVvfSu9L50/6K6qNeAYtkxXILy/LRutt7AzaYDc2myj0wkCVVYAPPA=="],
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@@ -1187,7 +1117,7 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
"prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="],
@@ -1197,8 +1127,6 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
@@ -1231,7 +1159,7 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rollup": ["rollup@4.56.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.56.0", "@rollup/rollup-android-arm64": "4.56.0", "@rollup/rollup-darwin-arm64": "4.56.0", "@rollup/rollup-darwin-x64": "4.56.0", "@rollup/rollup-freebsd-arm64": "4.56.0", "@rollup/rollup-freebsd-x64": "4.56.0", "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", "@rollup/rollup-linux-arm-musleabihf": "4.56.0", "@rollup/rollup-linux-arm64-gnu": "4.56.0", "@rollup/rollup-linux-arm64-musl": "4.56.0", "@rollup/rollup-linux-loong64-gnu": "4.56.0", "@rollup/rollup-linux-loong64-musl": "4.56.0", "@rollup/rollup-linux-ppc64-gnu": "4.56.0", "@rollup/rollup-linux-ppc64-musl": "4.56.0", "@rollup/rollup-linux-riscv64-gnu": "4.56.0", "@rollup/rollup-linux-riscv64-musl": "4.56.0", "@rollup/rollup-linux-s390x-gnu": "4.56.0", "@rollup/rollup-linux-x64-gnu": "4.56.0", "@rollup/rollup-linux-x64-musl": "4.56.0", "@rollup/rollup-openbsd-x64": "4.56.0", "@rollup/rollup-openharmony-arm64": "4.56.0", "@rollup/rollup-win32-arm64-msvc": "4.56.0", "@rollup/rollup-win32-ia32-msvc": "4.56.0", "@rollup/rollup-win32-x64-gnu": "4.56.0", "@rollup/rollup-win32-x64-msvc": "4.56.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg=="],
"rollup": ["rollup@4.55.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.1", "@rollup/rollup-android-arm64": "4.55.1", "@rollup/rollup-darwin-arm64": "4.55.1", "@rollup/rollup-darwin-x64": "4.55.1", "@rollup/rollup-freebsd-arm64": "4.55.1", "@rollup/rollup-freebsd-x64": "4.55.1", "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", "@rollup/rollup-linux-arm-musleabihf": "4.55.1", "@rollup/rollup-linux-arm64-gnu": "4.55.1", "@rollup/rollup-linux-arm64-musl": "4.55.1", "@rollup/rollup-linux-loong64-gnu": "4.55.1", "@rollup/rollup-linux-loong64-musl": "4.55.1", "@rollup/rollup-linux-ppc64-gnu": "4.55.1", "@rollup/rollup-linux-ppc64-musl": "4.55.1", "@rollup/rollup-linux-riscv64-gnu": "4.55.1", "@rollup/rollup-linux-riscv64-musl": "4.55.1", "@rollup/rollup-linux-s390x-gnu": "4.55.1", "@rollup/rollup-linux-x64-gnu": "4.55.1", "@rollup/rollup-linux-x64-musl": "4.55.1", "@rollup/rollup-openbsd-x64": "4.55.1", "@rollup/rollup-openharmony-arm64": "4.55.1", "@rollup/rollup-win32-arm64-msvc": "4.55.1", "@rollup/rollup-win32-ia32-msvc": "4.55.1", "@rollup/rollup-win32-x64-gnu": "4.55.1", "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A=="],
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
@@ -1245,9 +1173,9 @@
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
"seroval": ["seroval@1.4.2", "", {}, "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ=="],
"seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
"seroval-plugins": ["seroval-plugins@1.4.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-X7p4MEDTi+60o2sXZ4bnDBhgsUYDSkQEvzYZuJyFqWg9jcoPsHts5nrg5O956py2wyt28lUrBxk0M0/wU8URpA=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
@@ -1273,7 +1201,7 @@
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="],
"solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
@@ -1301,7 +1229,7 @@
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
"supabase": ["supabase@2.72.8", "", { "dependencies": { "bin-links": "^6.0.0", "https-proxy-agent": "^7.0.2", "node-fetch": "^3.3.2", "tar": "7.5.3" }, "bin": { "supabase": "bin/supabase" } }, "sha512-3Wymv/QjmndLB9ACQA31VvJ7+KXmDqj7s8g7y+ldAcCaHBMbj+I7x0j/UBGkNbtSh0BG7kRicGA3Xc3jQlccNQ=="],
"supabase": ["supabase@2.72.2", "", { "dependencies": { "bin-links": "^6.0.0", "https-proxy-agent": "^7.0.2", "node-fetch": "^3.3.2", "tar": "7.5.2" }, "bin": { "supabase": "bin/supabase" } }, "sha512-vnLeNqLGJnuqs4XkE1dYCGF6dSiRV4/BGExUEexUnyEhiR9+VDvTVt1/LM45hFbG4WF2hiVgePu5JDuwcpY8Tg=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -1315,7 +1243,7 @@
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tar": ["tar@7.5.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ=="],
"tar": ["tar@7.5.2", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
@@ -1367,7 +1295,7 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.54.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.54.0", "@typescript-eslint/parser": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ=="],
"typescript-eslint": ["typescript-eslint@8.52.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.52.0", "@typescript-eslint/parser": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/utils": "8.52.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA=="],
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
@@ -1383,15 +1311,11 @@
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-debounce": ["use-debounce@10.1.0", "", { "peerDependencies": { "react": "*" } }, "sha512-lu87Za35V3n/MyMoEpD5zJv0k7hCn0p+V/fK2kWD+3k2u3kOCwO593UArbczg1fhfs2rqPEnHpULJ3KmGdDzvg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
@@ -1421,7 +1345,7 @@
"which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
"which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
@@ -1439,9 +1363,7 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -1459,8 +1381,6 @@
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-form/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -1473,8 +1393,6 @@
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-toolbar/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
@@ -1491,10 +1409,6 @@
"@tanstack/devtools/@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.3", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.3.3" } }, "sha512-kl0r6N5iIL3t9gGDRAv55VRM3UIyMKVH83esRGq7xBjYsRLe/BeCIN2HqrlJkObUXQMKhy7i8ejuGOn+bDqDBw=="],
"@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -1507,8 +1421,6 @@
"cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"eslint-compat-utils/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -1531,18 +1443,14 @@
"is-bun-module/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"radix-ui/@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
"radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
"radix-ui/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"solid-js/seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="],
"solid-js/seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],

View File

@@ -10,7 +10,6 @@
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -18,11 +17,5 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
"@ss-components": "https://shadcnstudio.com/r/components/{name}.json",
"@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json",
"@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json",
"@supabase": "https://supabase.com/ui/r/{name}.json"
}
"iconLibrary": "lucide"
}

View File

@@ -3,7 +3,6 @@
import { tanstackConfig } from '@tanstack/eslint-config'
import eslintConfigPrettier from 'eslint-config-prettier'
import jsxA11y from 'eslint-plugin-jsx-a11y'
import reactHooks from 'eslint-plugin-react-hooks'
import unusedImports from 'eslint-plugin-unused-imports'
export default [
@@ -25,12 +24,9 @@ export default [
// 3. TUS REGLAS Y CONFIGURACIÓN "PRO"
{
// Opcional: Puedes ser explícito sobre dónde aplicar esto
files: ['**/*.{ts,tsx,js,jsx}'],
plugins: {
'jsx-a11y': jsxA11y,
'unused-imports': unusedImports,
'react-hooks': reactHooks,
},
// Configuración robusta del Resolver (La versión de Copilot)
settings: {
@@ -48,8 +44,7 @@ export default [
// --- REGLAS DE ACCESIBILIDAD (A11Y) ---
// Activamos las recomendadas manualmente
...jsxA11y.configs.recommended.rules,
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// --- ORDEN DE IMPORTS ---
'sort-imports': 'off', // Apagamos el nativo
'import/order': [

View File

@@ -19,9 +19,7 @@
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
@@ -33,7 +31,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@stepperize/react": "^5.1.9",
"@supabase/supabase-js": "^2.98.0",
"@supabase/supabase-js": "^2.90.1",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5",
@@ -41,29 +39,22 @@
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-router-devtools": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"@types/canvas-confetti": "^1.9.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"motion": "^12.24.7",
"radix-ui": "^1.4.3",
"lucide-react": "^0.561.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0",
"vaul": "^1.1.2"
"tw-animate-css": "^1.3.6"
},
"devDependencies": {
"@tanstack/devtools-vite": "^0.3.11",
"@tanstack/eslint-config": "^0.3.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/bun": "^1.3.6",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
@@ -72,7 +63,6 @@
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-unused-imports": "^4.3.0",
"jsdom": "^27.0.0",
"prettier": "^3.5.3",

View File

@@ -1,118 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 192.3 63.4" style="enable-background:new 0 0 192.3 63.4;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#FFFFFF;}
.st2{fill:#FFFFFF;}
</style>
<g>
<g id="Group_1247_1_">
<path id="Path_477_1_" class="st0" d="M50.7,50.6l4.4-7.8h-8.9l-12-21l-4.4,7.8l12,21C41.8,50.6,50.7,50.6,50.7,50.6z"/>
<path id="Path_478_1_" class="st0" d="M34.3,1h-9l4.4,7.8l-12,20.8h9.1l12-20.8L34.3,1z"/>
<path id="Path_479_1_" class="st0" d="M0,40.1l4.4,7.8l4.4-7.8h23.9l-4.4-7.8H4.4L0,40.1z"/>
<path id="Path_480_1_" class="st1" d="M56.7,40.1l4.4-7.8h-9L40.3,11.4l-4.4,7.8l12,20.8H56.7z"/>
<path id="Path_481_1_" class="st1" d="M22.3,1h-8.9l4.4,7.8l-12,20.8h9l12-20.8L22.3,1z"/>
<path id="Path_482_1_" class="st1" d="M5.9,50.6l4.4,7.8l4.4-7.8h23.9l-4.4-7.8H10.5L5.9,50.6z"/>
</g>
<g>
<path class="st1" d="M67.9,3.9c0-0.8,0-1.6-0.1-2.4l1.7-0.1l0.1,0.1v6.3c0,0.7,0.1,1.2,0.5,1.6C70.6,9.8,71,10,71.7,10
c0.5,0,1.1-0.1,1.3-0.5c0.4-0.4,0.5-0.9,0.5-1.6V3.5c0-0.8,0-1.5-0.1-2.2l1.9-0.1v6.7c0,1.1-0.4,2-1.1,2.6
c-0.7,0.5-1.6,0.9-2.7,0.9c-1.1,0-2-0.3-2.7-0.9C68.2,10,67.8,9,67.8,7.9L67.9,3.9L67.9,3.9z"/>
<path class="st1" d="M83,11.3h-1.7V6.6c0-0.7-0.1-1.5-0.3-2.3L82.6,4c0.1,0.4,0.3,0.7,0.3,1.1C83.5,4.4,84.3,4,85.1,4
c0.7,0,1.1,0.1,1.5,0.5C87,5,87.1,5.5,87.1,6.2v5.1h-1.7V6.6c0-0.8-0.4-1.3-1.1-1.3c-0.4,0-0.9,0.1-1.3,0.5L83,11.3L83,11.3z"/>
<path class="st1" d="M95.1,1.6c0,0.3-0.1,0.7-0.4,0.8c-0.3,0.3-0.5,0.4-0.8,0.4S93.3,2.7,93,2.6c-0.1-0.1-0.3-0.4-0.3-0.7
s0.1-0.7,0.4-0.8c0.3-0.1,0.5-0.4,0.9-0.4c0.3,0,0.5,0.1,0.7,0.3S95.1,1.4,95.1,1.6z M93,11.3V6.6c0-0.8,0-1.6-0.1-2.4l1.7-0.3
L94.8,4v7.1L93,11.3L93,11.3z"/>
<path class="st1" d="M106.4,4.3l-2.3,7h-1.9l-2.3-7.1l1.9-0.1l0.9,3.6c0.3,1.1,0.5,1.9,0.5,2.4l0,0c0.1-0.5,0.3-1.3,0.7-2.4
l0.9-3.6L106.4,4.3L106.4,4.3z"/>
<path class="st1" d="M116.7,7.7l-0.3,0.3h-4c0,0.8,0.3,1.3,0.7,1.7c0.4,0.4,0.8,0.5,1.5,0.5c0.5,0,1.2-0.1,1.7-0.5l0.1,1.2
c-0.7,0.4-1.3,0.7-2.4,0.7c-1.1,0-1.9-0.3-2.6-0.9s-0.9-1.5-0.9-2.7s0.3-2,0.9-2.8s1.5-1.1,2.4-1.1c0.8,0,1.5,0.3,2,0.8
c0.5,0.5,0.8,1.2,0.8,2.2C116.7,7.3,116.7,7.5,116.7,7.7z M113.9,5.1c-0.4,0-0.8,0.1-0.9,0.5c-0.3,0.4-0.4,0.9-0.4,1.6l2.6-0.1
c0-0.1,0-0.3,0-0.5c0-0.4-0.1-0.8-0.3-1.1C114.6,5.3,114.3,5.1,113.9,5.1z"/>
<path class="st1" d="M124,11.3h-1.7V6.6c0-0.7-0.1-1.5-0.3-2.3l1.6-0.3c0.1,0.5,0.3,0.9,0.4,1.5c0.5-0.9,1.2-1.5,1.9-1.5
c0.3,0,0.5,0,0.7,0.1l-0.1,1.7c-0.3-0.1-0.5-0.1-0.8-0.1c-0.5,0-1.1,0.1-1.6,0.5C124,6.3,124,11.3,124,11.3z"/>
<path class="st1" d="M135.8,4.4l-0.1,1.3c-0.7-0.4-1.3-0.5-1.9-0.5c-0.4,0-0.7,0.1-0.8,0.3c-0.3,0.1-0.3,0.3-0.3,0.5
c0,0.3,0.1,0.4,0.4,0.7c0.3,0.1,0.5,0.4,0.8,0.5c0.3,0.1,0.7,0.3,1.1,0.4c0.4,0.1,0.7,0.4,0.8,0.7c0.3,0.3,0.4,0.7,0.4,1.1
c0,0.7-0.3,1.2-0.8,1.5c-0.5,0.4-1.2,0.5-2.2,0.5s-1.7-0.1-2.4-0.5l0.1-1.3c0.8,0.4,1.5,0.7,2.3,0.7c0.4,0,0.7-0.1,0.8-0.3
c0.1-0.1,0.3-0.3,0.3-0.5c0-0.3-0.1-0.4-0.4-0.7c-0.3-0.1-0.5-0.4-0.8-0.5s-0.7-0.3-0.9-0.4s-0.7-0.4-0.8-0.7
c-0.3-0.3-0.4-0.7-0.4-1.1c0-0.7,0.3-1.2,0.8-1.6c0.5-0.4,1.2-0.5,2-0.5C134.5,4,135.1,4.2,135.8,4.4z"/>
<path class="st1" d="M143.3,1.6c0,0.3-0.1,0.7-0.4,0.8c-0.3,0.1-0.5,0.4-0.8,0.4c-0.3,0-0.5-0.1-0.8-0.3c-0.1-0.1-0.1-0.4-0.1-0.7
s0.1-0.7,0.4-0.8c0.3-0.3,0.5-0.4,0.9-0.4c0.3,0,0.5,0.1,0.7,0.3S143.3,1.4,143.3,1.6z M141.3,11.3V6.6c0-0.8,0-1.6-0.1-2.4
l1.7-0.3l0.1,0.1v7.1L141.3,11.3L141.3,11.3z"/>
<path class="st1" d="M153,0.7l1.7-0.3l0.1,0.1v8.7c0,0.7,0.1,1.3,0.3,1.9l-1.6,0.1c-0.1-0.1-0.3-0.5-0.4-0.9l0,0
c-0.5,0.7-1.1,0.9-2,0.9c-0.9,0-1.5-0.3-2-0.9c-0.5-0.7-0.8-1.5-0.8-2.6c0-1.2,0.4-2.2,1.1-3c0.7-0.7,1.6-1.1,2.7-1.1
c0.3,0,0.5,0,0.9,0.1V3C153.2,2,153.2,1.4,153,0.7z M150.5,7.7c0,0.8,0.1,1.5,0.4,1.9c0.3,0.5,0.7,0.7,1.2,0.7
c0.4,0,0.8-0.1,1.1-0.4V5c-0.3,0-0.5-0.1-0.7-0.1c-0.7,0-1.1,0.3-1.5,0.7C150.6,6.2,150.5,6.9,150.5,7.7z"/>
<path class="st1" d="M166.1,9.3c0,0.7,0.1,1.3,0.3,2l-1.5,0.1c-0.1-0.3-0.3-0.5-0.4-0.9l0,0c-0.3,0.3-0.5,0.5-0.9,0.7
c-0.4,0.1-0.8,0.3-1.2,0.3c-0.5,0-1.1-0.1-1.3-0.4c-0.4-0.3-0.5-0.7-0.5-1.2c0-0.8,0.4-1.3,1.1-1.7c0.7-0.4,1.6-0.7,2.8-0.7V6.7
c0-0.8-0.4-1.3-1.3-1.3c-0.7,0-1.5,0.3-2.2,0.7l-0.1-1.3c0.9-0.4,1.7-0.5,2.7-0.5s1.6,0.3,2,0.7c0.4,0.4,0.7,0.9,0.7,1.7
c0,0.4,0,0.8,0,1.5C166.1,8.6,166.1,9,166.1,9.3z M162.2,9.4c0,0.3,0.1,0.5,0.3,0.7c0.1,0.1,0.4,0.3,0.7,0.3
c0.4,0,0.8-0.1,1.2-0.5V7.9c-0.7,0-1.1,0.3-1.5,0.4C162.3,8.6,162.2,9,162.2,9.4z"/>
<path class="st1" d="M175.6,0.7l1.7-0.3l0.1,0.1v8.7c0,0.7,0.1,1.3,0.3,1.9l-1.6,0.1c-0.1-0.1-0.3-0.5-0.4-0.9l0,0
c-0.5,0.7-1.1,0.9-2,0.9s-1.5-0.3-2-0.9c-0.5-0.7-0.8-1.5-0.8-2.6c0-1.2,0.4-2.2,1.1-3c0.7-0.7,1.6-1.1,2.7-1.1
c0.3,0,0.5,0,0.9,0.1V3C175.7,2,175.7,1.4,175.6,0.7z M173.1,7.7c0,0.8,0.1,1.5,0.4,1.9c0.3,0.5,0.7,0.7,1.2,0.7
c0.4,0,0.8-0.1,1.1-0.4V5c-0.3,0-0.5-0.1-0.7-0.1c-0.7,0-1.1,0.3-1.5,0.7C173.2,6.2,173.1,6.9,173.1,7.7z"/>
</g>
<g>
<path class="st1" d="M78.8,51.2l0.3,0.3l1.2,11h-2l-0.4-4c-0.1-1.2-0.3-3-0.4-5l0,0c-0.3,1.2-0.7,2.8-1.2,5l-1.2,4h-2.3l-1.1-4
c-0.5-1.9-0.9-3.5-1.2-5l0,0c-0.1,1.2-0.3,2.8-0.4,5l-0.4,4h-1.7l1.2-11.2l2.7-0.1l1.3,4.6c0.4,1.6,0.8,3.2,1.1,4.8l0,0
c0.3-1.6,0.5-3.2,1.1-4.8l1.3-4.4L78.8,51.2z"/>
<path class="st1" d="M89.4,58.5l-0.3,0.3h-4.4c0.1,0.8,0.3,1.5,0.8,1.9c0.5,0.4,0.9,0.7,1.6,0.7s1.3-0.1,2-0.5l0.1,1.3
c-0.7,0.4-1.6,0.7-2.7,0.7c-1.2,0-2.2-0.4-3-1.1c-0.7-0.7-1.1-1.7-1.1-3c0-1.3,0.4-2.3,1.1-3.1c0.7-0.8,1.6-1.2,2.7-1.2
c0.9,0,1.7,0.3,2.3,0.9c0.5,0.5,0.8,1.3,0.8,2.4C89.4,58,89.4,58.4,89.4,58.5z M87.7,50.4l0.1,0.4c-0.7,0.9-1.5,1.9-2.6,2.7
l-0.8-0.1c0.7-1.1,1.1-2.2,1.3-3H87.7z M86.3,55.5c-0.4,0-0.8,0.3-1.1,0.7c-0.3,0.4-0.4,1.1-0.5,1.7l2.8-0.1c0-0.1,0-0.3,0-0.5
c0-0.5-0.1-0.9-0.3-1.2C87,55.7,86.7,55.5,86.3,55.5z"/>
<path class="st1" d="M96.4,62.5l-1.7-3.1L93,62.5h-1.6l-0.1-0.3l2.3-3.8l-2.3-4l2-0.3l1.7,3.4l1.6-3.4l1.6,0.1l0.1,0.3L96,58.4
l2.4,4h-2V62.5z"/>
<path class="st1" d="M103.1,51.8c0,0.4-0.1,0.7-0.4,0.9s-0.5,0.4-0.9,0.4c-0.4,0-0.7-0.1-0.8-0.4c-0.3-0.3-0.3-0.5-0.3-0.8
c0-0.4,0.1-0.7,0.4-0.9c0.3-0.3,0.5-0.4,0.9-0.4c0.3,0,0.5,0.1,0.8,0.3C103,51.1,103.1,51.4,103.1,51.8z M100.8,62.5v-5.2
c0-0.9,0-1.9-0.1-2.7l2-0.3l0.3,0.3v7.9H100.8z"/>
<path class="st1" d="M112.3,55l-0.4,1.6c-0.7-0.4-1.2-0.7-1.9-0.7c-0.5,0-1.1,0.3-1.5,0.7c-0.4,0.4-0.5,1.1-0.5,1.9
c0,0.9,0.1,1.6,0.7,2c0.4,0.5,0.9,0.8,1.7,0.8c0.5,0,1.2-0.1,1.7-0.5l0.1,1.3c-0.7,0.4-1.5,0.7-2.4,0.7c-1.2,0-2.2-0.4-2.8-1.1
s-1.1-1.7-1.1-3c0-1.3,0.4-2.3,1.1-3.1c0.8-0.8,1.7-1.2,2.8-1.2C110.8,54.3,111.6,54.6,112.3,55z"/>
<path class="st1" d="M121.4,58.5c0,1.3-0.4,2.4-1.1,3.1s-1.6,1.1-2.7,1.1c-1.1,0-1.9-0.4-2.6-1.1s-1.1-1.7-1.1-3
c0-1.3,0.4-2.4,1.1-3.1s1.6-1.2,2.7-1.2c1.1,0,2,0.4,2.7,1.1C121.1,56.2,121.4,57.3,121.4,58.5z M116,58.5c0,2,0.5,3,1.6,3
c0.5,0,0.9-0.3,1.2-0.8c0.3-0.5,0.4-1.2,0.4-2.2c0-2-0.5-3.1-1.6-3.1c-0.5,0-0.9,0.3-1.2,0.8C116.3,56.8,116,57.6,116,58.5z"/>
</g>
<g>
<path class="st2" d="M83.5,41v3.8H68.3V25.3c0-2.4-0.1-4.6-0.4-6.5l4.6-0.4L73,19v22.2L83.5,41z"/>
<path class="st2" d="M100.4,39.5c0,1.9,0.3,3.6,0.8,5.1l-3.9,0.4c-0.4-0.7-0.8-1.6-1.1-2.6h-0.1c-0.5,0.7-1.3,1.3-2.3,1.9
c-0.9,0.5-2.2,0.8-3.2,0.8c-1.5,0-2.7-0.4-3.6-1.2c-0.9-0.8-1.3-1.9-1.3-3.4c0-2,0.9-3.6,2.8-4.6c1.9-1.1,4.3-1.6,7.4-1.7v-1.7
c0-2.3-1.2-3.4-3.5-3.4c-1.9,0-3.8,0.5-5.6,1.6l-0.3-3.6c2.4-0.9,4.7-1.5,7.1-1.5c2.3,0,4,0.5,5.2,1.6c1.2,1.1,1.7,2.6,1.7,4.6
c0,0.9,0,2.3,0,4C100.4,37.7,100.4,38.9,100.4,39.5z M90.2,39.8c0,0.7,0.3,1.3,0.7,1.7c0.4,0.5,1.1,0.7,1.7,0.7
c1.2,0,2.2-0.4,3.1-1.3v-5.1c-1.6,0.1-3,0.5-4,1.2C90.8,37.8,90.2,38.7,90.2,39.8z"/>
</g>
<g>
<path class="st2" d="M126.3,37.3c0,2.4-0.8,4.2-2.6,5.5c-1.7,1.3-4,2-6.9,2c-3.1,0-5.5-0.5-7.3-1.5L110,39c0.9,0.5,2,1.1,3.5,1.3
c1.3,0.3,2.6,0.4,3.6,0.4c1.3,0,2.4-0.3,3.2-0.9c0.8-0.5,1.1-1.3,1.1-2.4c0-0.3,0-0.5-0.1-0.8c0-0.3-0.1-0.5-0.3-0.7
s-0.3-0.4-0.4-0.7c-0.1-0.3-0.4-0.4-0.4-0.5c-0.1-0.1-0.3-0.3-0.7-0.5c-0.3-0.3-0.5-0.4-0.7-0.4c-0.1-0.1-0.4-0.3-0.8-0.4
c-0.4-0.3-0.7-0.4-0.8-0.4c-0.1-0.1-0.4-0.3-0.9-0.4c-0.4-0.3-0.7-0.4-0.8-0.4c-0.9-0.4-1.6-0.8-2.2-1.2c-0.5-0.4-1.2-0.8-1.7-1.5
c-0.7-0.5-1.1-1.2-1.3-2c-0.3-0.8-0.4-1.6-0.4-2.7c0-2.4,0.9-4.2,2.7-5.5c1.7-1.3,4.2-2,7-2c2.4,0,4.4,0.4,6.2,1.1l-0.7,4.3
c-1.7-1.1-3.6-1.5-5.6-1.5c-1.5,0-2.6,0.3-3.4,0.9c-0.8,0.7-1.2,1.3-1.2,2.4c0,0.4,0,0.7,0.1,1.1c0.1,0.3,0.3,0.7,0.5,0.9
c0.3,0.3,0.5,0.5,0.7,0.8c0.1,0.1,0.5,0.4,0.9,0.7c0.5,0.3,0.8,0.5,1.1,0.5c0.1,0.1,0.5,0.3,1.2,0.7c0.7,0.3,1.1,0.5,1.2,0.5
c0.8,0.4,1.5,0.8,2.2,1.2c0.5,0.4,1.2,0.8,1.7,1.5c0.7,0.7,1.1,1.3,1.3,2.2C126.1,35.4,126.3,36.3,126.3,37.3z"/>
<path class="st2" d="M143.9,39.1c0,1.9,0.3,3.8,0.8,5.4l-4,0.4c-0.4-0.7-0.8-1.6-1.1-2.7h-0.1c-0.5,0.8-1.3,1.3-2.4,1.9
c-1.1,0.5-2.2,0.8-3.4,0.8c-1.6,0-2.8-0.4-3.8-1.2c-0.9-0.8-1.3-2-1.3-3.5c0-2.2,0.9-3.6,3-4.7c1.9-1.1,4.4-1.6,7.7-1.7v-1.9
c0-2.3-1.2-3.5-3.6-3.5c-2,0-3.9,0.5-5.9,1.7l-0.3-3.8c2.4-1.1,4.8-1.5,7.4-1.5c2.4,0,4.2,0.5,5.4,1.6c1.2,1.1,1.9,2.7,1.9,4.8
c0,0.9,0,2.3,0,4.2C143.9,37.1,143.9,38.3,143.9,39.1z M133.4,39.3c0,0.7,0.3,1.3,0.7,1.9c0.4,0.5,1.1,0.8,1.9,0.8
c1.2,0,2.3-0.4,3.2-1.3v-5.2c-1.7,0.1-3.1,0.5-4.2,1.2S133.4,38.2,133.4,39.3z"/>
<path class="st2" d="M153.7,44.4h-4.8V21.9c0-2-0.1-4.2-0.4-6.5l4.8-0.5l0.5,0.5L153.7,44.4L153.7,44.4z"/>
<path class="st2" d="M163.4,44.4h-4.8V21.9c0-2-0.1-4.2-0.4-6.5l4.8-0.5l0.5,0.5L163.4,44.4L163.4,44.4z"/>
<path class="st2" d="M183.7,34.7l-0.5,0.5h-10.9c0.1,2,0.8,3.6,1.7,4.6c0.9,0.9,2.4,1.5,4,1.5c1.6,0,3.2-0.4,4.8-1.3l0.3,3.2
c-1.7,1.1-3.9,1.6-6.5,1.6c-3,0-5.2-0.8-7-2.6s-2.6-4.2-2.6-7.3c0-3.1,0.8-5.6,2.6-7.5c1.7-1.9,3.9-2.8,6.6-2.8
c2.3,0,4.2,0.7,5.5,2.2c1.3,1.5,2,3.4,2,5.6C183.8,33.5,183.8,34.2,183.7,34.7z M176,27.6c-1.1,0-2,0.5-2.7,1.6
c-0.7,1.1-1.1,2.6-1.2,4.3l6.7-0.3c0-0.3,0-0.8,0-1.3c0-1.2-0.3-2.3-0.8-3.1S176.9,27.6,176,27.6z"/>
</g>
<g>
<path id="Path_483" class="st1" d="M187,39.4h1.5c0.3,0,0.7,0.1,1,0.2c0.3,0.2,0.5,0.5,0.5,0.8c0,0.3-0.1,0.6-0.3,0.8
c-0.1,0.1-0.3,0.2-0.5,0.3c0.4,0.1,0.6,0.3,0.6,0.8c0,0.4,0.1,0.9,0.3,1.3h-0.6c-0.1-0.4-0.2-0.7-0.2-1.1
c-0.1-0.6-0.2-0.8-0.9-0.8h-0.7v1.9H187V39.4 M187.5,41.2h0.9c0.2,0,0.4,0,0.6-0.1c0.2-0.1,0.3-0.3,0.3-0.6c0-0.7-0.6-0.7-0.8-0.7
h-0.9V41.2z"/>
<path id="Path_484" class="st1" d="M191.9,41.5c0,1.9-1.6,3.4-3.4,3.3s-3.4-1.6-3.3-3.4c0-1.9,1.5-3.3,3.4-3.3
C190.4,38.1,191.9,39.6,191.9,41.5z M188.5,37.8c-2.1,0-3.7,1.6-3.8,3.7c0,2.1,1.6,3.7,3.7,3.8c2.1,0,3.7-1.6,3.8-3.7c0,0,0,0,0,0
C192.2,39.4,190.5,37.8,188.5,37.8z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,19 +0,0 @@
// scripts/update-types.ts
/* Uso:
bun run scripts/update-types.ts
*/
import { $ } from "bun";
console.log("🔄 Generando tipos de Supabase...");
try {
// Ejecutamos el comando y capturamos la salida como texto
const output = await $`supabase gen types typescript --linked`.text();
// Escribimos el archivo directamente con Bun (garantiza UTF-8)
await Bun.write("src/types/supabase.ts", output);
console.log("✅ Tipos actualizados correctamente con acentos.");
} catch (error) {
console.error("❌ Error generando tipos:", error);
}

View File

@@ -1,20 +1,9 @@
import { Link, useNavigate } from '@tanstack/react-router'
import { Home, LogOut, Menu, Network, X } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { Home, Menu, Network, X } from 'lucide-react'
import { useState } from 'react'
import { supabaseBrowser } from '@/data/supabase/client'
export default function Header() {
const [isOpen, setIsOpen] = useState(false)
const navigate = useNavigate()
const handleLogout = async () => {
try {
await supabaseBrowser().auth.signOut()
} finally {
void navigate({ to: '/login', replace: true })
}
}
return (
<>
@@ -29,19 +18,13 @@ export default function Header() {
</button>
<h1 className="ml-4 text-xl font-semibold">
<Link to="/">
<img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" />
<img
src="/tanstack-word-logo-white.svg"
alt="TanStack Logo"
className="h-10"
/>
</Link>
</h1>
<button
onClick={handleLogout}
className="ml-auto inline-flex items-center gap-2 rounded-lg p-2 transition-colors hover:bg-gray-700"
aria-label="Logout"
title="Logout"
>
<LogOut size={20} />
<span className="hidden sm:inline">Salir</span>
</button>
</header>
<aside

View File

@@ -1,483 +0,0 @@
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { Pencil, Sparkles } from 'lucide-react'
import { useState, useEffect } from 'react'
import type { AsignaturaDetail } from '@/data'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
export interface BibliografiaEntry {
id: string
tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string
fuenteBibliotecaId?: string
fuenteBiblioteca?: any
}
export interface BibliografiaTabProps {
id: string
bibliografia: Array<BibliografiaEntry>
onSave: (bibliografia: Array<BibliografiaEntry>) => void
isSaving: boolean
}
export interface AsignaturaDatos {
[key: string]: string
}
export interface AsignaturaResponse {
datos: AsignaturaDatos
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function parseContenidoTematicoToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const blocks: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const unidad =
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
? item.unidad
: undefined
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
if (!header) continue
const lines: Array<string> = [header]
const temas = Array.isArray(item.temas) ? item.temas : []
temas.forEach((tema, idx) => {
const temaNombre =
typeof tema === 'string'
? tema
: isRecord(tema) && typeof tema.nombre === 'string'
? tema.nombre
: ''
if (!temaNombre) return
if (unidad != null) {
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
} else {
lines.push(`${idx + 1}. ${temaNombre}`)
}
})
blocks.push(lines.join('\n'))
}
return blocks.join('\n\n').trimEnd()
}
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
contenido_tematico: parseContenidoTematicoToPlainText,
}
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId',
)({
component: AsignaturaDetailPage,
})
export default function AsignaturaDetailPage() {
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: asignaturaApi } = useSubject(asignaturaId)
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
const updateAsignatura = useUpdateAsignatura()
const handlePersistDatoGeneral = (clave: string, value: string) => {
const baseDatos = asignatura?.datos ?? (asignaturaApi as any)?.datos ?? {}
const mergedDatos = { ...baseDatos, [clave]: value }
// Mantener estado local coherente para merges posteriores.
setAsignatura((prev) => ({
...((prev ?? asignaturaApi ?? {}) as any),
datos: mergedDatos,
}))
updateAsignatura.mutate({
asignaturaId,
patch: {
datos: mergedDatos,
},
})
}
/* ---------- sincronizar API ---------- */
useEffect(() => {
if (asignaturaApi) setAsignatura(asignaturaApi)
}, [asignaturaApi])
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
}
function DatosGenerales({
onPersistDato,
}: {
onPersistDato: (clave: string, value: string) => void
}) {
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
// 1. Extraemos la definición de la estructura (los metadatos)
const definicionRaw = data?.estructuras_asignatura?.definicion
const definicion = isRecord(definicionRaw)
? (definicionRaw as Record<string, unknown>)
: null
const propertiesRaw = definicion ? (definicion as any).properties : undefined
const structureProps = isRecord(propertiesRaw)
? (propertiesRaw as Record<string, any>)
: {}
// 2. Extraemos los valores reales (el contenido redactado)
const datosRaw = data?.datos
const valoresActuales = isRecord(datosRaw)
? (datosRaw as Record<string, any>)
: {}
if (isLoading) return <p>Cargando información...</p>
return (
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
{/* Encabezado de la Sección */}
<div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
Datos Generales
</h2>
<p className="mt-1 text-slate-500">
Información oficial estructurada bajo los lineamientos de la SEP.
</p>
</div>
</div>
{/* Grid de Información */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* Columna Principal (Más ancha) */}
<div className="space-y-6 md:col-span-2">
{Object.entries(structureProps).map(
([key, config]: [string, any]) => {
const cardTitle = config.title || key
const description = config.description || ''
const xColumn =
typeof config?.['x-column'] === 'string'
? config['x-column']
: undefined
// Obtenemos el placeholder del arreglo 'examples' de la estructura
const placeholder =
config.examples && config.examples.length > 0
? config.examples[0]
: ''
const valActual = valoresActuales[key]
let currentContent = valActual ?? ''
if (xColumn) {
const rawValue = (data as any)?.[xColumn]
const parser = columnParsers[xColumn]
currentContent = parser
? parser(rawValue)
: String(rawValue ?? '')
}
return (
<InfoCard
asignaturaId={asignaturaId}
key={key}
clave={key}
title={cardTitle}
initialContent={currentContent}
xColumn={xColumn}
placeholder={placeholder}
description={description}
onPersist={(clave, value) => onPersistDato(clave, value)}
/>
)
},
)}
</div>
{/* Columna Lateral (Información Secundaria) */}
<div className="space-y-6">
<div className="space-y-6">
{/* Tarjeta de Requisitos */}
<InfoCard
title="Requisitos y Seriación"
type="requirements"
initialContent={[
{
type: 'Pre-requisito',
code: 'PA-301',
name: 'Programación Avanzada',
},
{
type: 'Co-requisito',
code: 'MAT-201',
name: 'Matemáticas Discretas',
},
]}
/>
{/* Tarjeta de Evaluación */}
<InfoCard
title="Sistema de Evaluación"
type="evaluation"
initialContent={[
{ label: 'Exámenes parciales', value: '30%' },
{ label: 'Proyecto integrador', value: '35%' },
{ label: 'Prácticas de laboratorio', value: '20%' },
{ label: 'Participación', value: '15%' },
]}
/>
</div>
</div>
</div>
</div>
)
}
interface InfoCardProps {
asignaturaId?: string
clave?: string
title: string
initialContent: any
placeholder?: string
description?: string
xColumn?: string
required?: boolean // Nueva prop para el asterisco
type?: 'text' | 'requirements' | 'evaluation'
onEnhanceAI?: (content: any) => void
onPersist?: (clave: string, value: string) => void
}
function InfoCard({
asignaturaId,
clave,
title,
initialContent,
placeholder,
description,
xColumn,
required,
type = 'text',
onPersist,
}: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false)
const [data, setData] = useState(initialContent)
const [tempText, setTempText] = useState(initialContent)
const navigate = useNavigate()
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
useEffect(() => {
setData(initialContent)
setTempText(initialContent)
}, [initialContent])
const handleSave = () => {
console.log('clave, valor:', clave, String(tempText ?? ''))
setData(tempText)
setIsEditing(false)
if (type === 'text' && clave && onPersist) {
onPersist(clave, String(tempText ?? ''))
}
}
const handleIARequest = (campoClave: string) => {
console.log(placeholder)
// Añadimos un timestamp a la state para forzar que la navegación
// genere una nueva ubicación incluso si la ruta y los params son iguales.
navigate({
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
params: { planId, asignaturaId: asignaturaId! },
state: {
activeTab: 'ia',
prefillCampo: campoClave,
prefillContenido: data,
_ts: Date.now(),
} as any,
})
}
return (
<Card className="overflow-hidden transition-all hover:border-slate-300">
<TooltipProvider>
<CardHeader className="border-b bg-slate-50/50 px-5 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<CardTitle className="cursor-help text-sm font-bold text-slate-700">
{title}
</CardTitle>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs">
{description || 'Información del campo'}
</TooltipContent>
</Tooltip>
{required && (
<span
className="text-sm font-bold text-red-500"
title="Requerido"
>
*
</span>
)}
</div>
{!isEditing && (
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
onClick={() => clave && handleIARequest(clave)}
>
<Sparkles className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Mejorar con IA</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400"
onClick={() => {
// Si esta InfoCard proviene de una columna externa (ej: contenido_tematico),
// redirigimos a la pestaña de Contenido en vez de editar inline.
if (xColumn === 'contenido_tematico') {
// Agregamos un timestamp para forzar la actualización
// de la location.state aunque la ruta sea la misma.
navigate({
to: '/planes/$planId/asignaturas/$asignaturaId/contenido',
params: { planId, asignaturaId: asignaturaId! },
})
return
}
setIsEditing(true)
}}
>
<Pencil className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Editar campo</TooltipContent>
</Tooltip>
</div>
)}
</div>
</CardHeader>
</TooltipProvider>
<CardContent className="pt-4">
{isEditing ? (
<div className="space-y-3">
<Textarea
value={tempText}
placeholder={placeholder}
onChange={(e) => setTempText(e.target.value)}
className="min-h-30 text-sm leading-relaxed"
/>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => setIsEditing(false)}
>
Cancelar
</Button>
<Button
size="sm"
className="bg-[#00a878] hover:bg-[#008f66]"
onClick={handleSave}
>
Guardar
</Button>
</div>
</div>
) : (
<div className="text-sm leading-relaxed text-slate-600">
{type === 'text' &&
(data ? (
<p className="whitespace-pre-wrap">{data}</p>
) : (
<p className="text-slate-400 italic">Sin información.</p>
))}
{type === 'requirements' && <RequirementsView items={data} />}
{type === 'evaluation' && <EvaluationView items={data} />}
</div>
)}
</CardContent>
</Card>
)
}
// Vista de Requisitos
function RequirementsView({ items }: { items: Array<any> }) {
return (
<div className="space-y-3">
{items.map((req, i) => (
<div
key={i}
className="rounded-lg border border-slate-100 bg-slate-50 p-3"
>
<p className="text-[10px] font-bold tracking-tight text-slate-400 uppercase">
{req.type}
</p>
<p className="text-sm font-medium text-slate-700">
{req.code} {req.name}
</p>
</div>
))}
</div>
)
}
// Vista de Evaluación
function EvaluationView({ items }: { items: Array<any> }) {
return (
<div className="space-y-2">
{items.map((item, i) => (
<div
key={i}
className="flex justify-between border-b border-slate-50 pb-1.5 text-sm italic"
>
<span className="text-slate-500">{item.label}</span>
<span className="font-bold text-blue-600">{item.value}</span>
</div>
))}
</div>
)
}

View File

@@ -1,198 +1,131 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/label-has-associated-control */
/* eslint-disable jsx-a11y/no-static-element-interactions */
import { useParams } from '@tanstack/react-router'
import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
import { useState } from 'react'
import { useState } from 'react';
import { Plus, Search, BookOpen, Trash2, Library, Edit3, Save } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { cn } from '@/lib/utils';
//import { toast } from 'sonner';
//import { mockLibraryResources } from '@/data/mockMateriaData';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
useCreateBibliografia,
useDeleteBibliografia,
useSubjectBibliografia,
useUpdateBibliografia,
} from '@/data/hooks/useSubjects'
import { cn } from '@/lib/utils'
export const mockLibraryResources = [
{
id: 'lib-1',
titulo: 'Deep Learning',
autor: 'Goodfellow, I., Bengio, Y., & Courville, A.',
editorial: 'MIT Press',
anio: 2016,
isbn: '9780262035613',
disponible: true
},
{
id: 'lib-2',
titulo: 'Artificial Intelligence: A Modern Approach',
autor: 'Russell, S., & Norvig, P.',
editorial: 'Pearson',
anio: 2020,
isbn: '9780134610993',
disponible: true
},
{
id: 'lib-3',
titulo: 'Hands-On Machine Learning',
autor: 'Aurélien Géron',
editorial: 'O\'Reilly Media',
anio: 2019,
isbn: '9781492032649',
disponible: false
}
];
// --- Interfaces ---
export interface BibliografiaEntry {
id: string
tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string
tipo_fuente?: 'MANUAL' | 'BIBLIOTECA'
biblioteca_item_id?: string | null
fuenteBibliotecaId?: string
fuenteBiblioteca?: any
id: string;
tipo: 'BASICA' | 'COMPLEMENTARIA';
cita: string;
fuenteBibliotecaId?: string;
fuenteBiblioteca?: any;
}
export function BibliographyItem() {
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
interface BibliografiaTabProps {
bibliografia: BibliografiaEntry[];
onSave: (bibliografia: BibliografiaEntry[]) => void;
isSaving: boolean;
}
// --- 1. Única fuente de verdad: La Query ---
const { data: bibliografia = [], isLoading } =
useSubjectBibliografia(asignaturaId)
export function BibliographyItem({ bibliografia, onSave, isSaving }: BibliografiaTabProps) {
const [entries, setEntries] = useState<BibliografiaEntry[]>(bibliografia);
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [newEntryType, setNewEntryType] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA');
// --- 2. Mutaciones ---
const { mutate: crearBibliografia } = useCreateBibliografia()
const { mutate: actualizarBibliografia } = useUpdateBibliografia(asignaturaId)
const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId)
// --- 3. Estados de UI (Solo para diálogos y edición) ---
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [newEntryType, setNewEntryType] = useState<'BASICA' | 'COMPLEMENTARIA'>(
'BASICA',
)
console.log('Datos actuales en el front:', bibliografia)
// --- 4. Derivación de datos (Se calculan en cada render) ---
const basicaEntries = bibliografia.filter((e) => e.tipo === 'BASICA')
const complementariaEntries = bibliografia.filter(
(e) => e.tipo === 'COMPLEMENTARIA',
)
// --- Handlers Conectados a la Base de Datos ---
const basicaEntries = entries.filter(e => e.tipo === 'BASICA');
const complementariaEntries = entries.filter(e => e.tipo === 'COMPLEMENTARIA');
const handleAddManual = (cita: string) => {
crearBibliografia(
{
asignatura_id: asignaturaId,
tipo: newEntryType,
cita,
tipo_fuente: 'MANUAL',
},
{
onSuccess: () => setIsAddDialogOpen(false),
},
)
}
const newEntry: BibliografiaEntry = { id: `manual-${Date.now()}`, tipo: newEntryType, cita };
setEntries([...entries, newEntry]);
setIsAddDialogOpen(false);
//toast.success('Referencia manual añadida');
};
const handleAddFromLibrary = (
resource: any,
tipo: 'BASICA' | 'COMPLEMENTARIA',
) => {
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`
crearBibliografia(
{
asignatura_id: asignaturaId,
const handleAddFromLibrary = (resource: any, tipo: 'BASICA' | 'COMPLEMENTARIA') => {
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`;
const newEntry: BibliografiaEntry = {
id: `lib-ref-${Date.now()}`,
tipo,
cita,
tipo_fuente: 'BIBLIOTECA',
biblioteca_item_id: resource.id,
},
{
onSuccess: () => setIsLibraryDialogOpen(false),
},
)
}
fuenteBibliotecaId: resource.id,
fuenteBiblioteca: resource,
};
setEntries([...entries, newEntry]);
setIsLibraryDialogOpen(false);
//toast.success('Añadido desde biblioteca');
};
const handleUpdateCita = (id: string, nuevaCita: string) => {
actualizarBibliografia(
{
id,
updates: { cita: nuevaCita },
},
{
onSuccess: () => setEditingId(null),
},
)
}
const onConfirmDelete = () => {
if (deleteId) {
eliminarBibliografia(deleteId, {
onSuccess: () => setDeleteId(null),
})
}
}
if (isLoading)
return <div className="p-10 text-center">Cargando bibliografía...</div>
const handleUpdateCita = (id: string, cita: string) => {
setEntries(entries.map(e => e.id === id ? { ...e, cita } : e));
};
return (
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500">
<div className="max-w-5xl mx-auto py-10 space-y-8 animate-in fade-in duration-500">
<div className="flex items-center justify-between border-b pb-4">
<div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
Bibliografía
</h2>
<p className="mt-1 text-sm text-slate-500">
{basicaEntries.length} básica {complementariaEntries.length}{' '}
complementaria
<h2 className="text-2xl font-bold text-slate-900 tracking-tight">Bibliografía</h2>
<p className="text-sm text-slate-500 mt-1">
{basicaEntries.length} básica {complementariaEntries.length} complementaria
</p>
</div>
<div className="flex items-center gap-2">
<Dialog
open={isLibraryDialogOpen}
onOpenChange={setIsLibraryDialogOpen}
>
<Dialog open={isLibraryDialogOpen} onOpenChange={setIsLibraryDialogOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
className="border-blue-200 text-blue-700 hover:bg-blue-50"
>
<Library className="mr-2 h-4 w-4" /> Buscar en biblioteca
<Button variant="outline" className="border-blue-200 text-blue-700 hover:bg-blue-50">
<Library className="w-4 h-4 mr-2" /> Buscar en biblioteca
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<LibrarySearchDialog
// CORRECCIÓN: Usamos 'bibliografia' en lugar de 'bibliografia2'
resources={[]} // Aquí deberías pasar el catálogo general, no la bibliografía de la asignatura
onSelect={handleAddFromLibrary}
// CORRECCIÓN: Usamos 'bibliografia' en lugar de 'entries'
existingIds={bibliografia.map(
(e) => e.biblioteca_item_id || '',
)}
/>
<LibrarySearchDialog onSelect={handleAddFromLibrary} existingIds={entries.map(e => e.fuenteBibliotecaId || '')} />
</DialogContent>
</Dialog>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Plus className="mr-2 h-4 w-4" /> Añadir manual
</Button>
<Button variant="outline"><Plus className="w-4 h-4 mr-2" /> Añadir manual</Button>
</DialogTrigger>
<DialogContent>
<AddManualDialog
tipo={newEntryType}
onTypeChange={setNewEntryType}
onAdd={handleAddManual}
/>
<AddManualDialog tipo={newEntryType} onTypeChange={setNewEntryType} onAdd={handleAddManual} />
</DialogContent>
</Dialog>
<Button onClick={() => onSave(entries)} disabled={isSaving} className="bg-blue-600 hover:bg-blue-700">
<Save className="w-4 h-4 mr-2" /> {isSaving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</div>
@@ -200,13 +133,11 @@ export function BibliographyItem() {
{/* BASICA */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<div className="h-4 w-1 rounded-full bg-blue-600" />
<h3 className="font-semibold text-slate-800">
Bibliografía Básica
</h3>
<div className="h-4 w-1 bg-blue-600 rounded-full" />
<h3 className="font-semibold text-slate-800">Bibliografía Básica</h3>
</div>
<div className="grid gap-3">
{basicaEntries.map((entry) => (
{basicaEntries.map(entry => (
<BibliografiaCard
key={entry.id}
entry={entry}
@@ -223,13 +154,11 @@ export function BibliographyItem() {
{/* COMPLEMENTARIA */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<div className="h-4 w-1 rounded-full bg-slate-400" />
<h3 className="font-semibold text-slate-800">
Bibliografía Complementaria
</h3>
<div className="h-4 w-1 bg-slate-400 rounded-full" />
<h3 className="font-semibold text-slate-800">Bibliografía Complementaria</h3>
</div>
<div className="grid gap-3">
{complementariaEntries.map((entry) => (
{complementariaEntries.map(entry => (
<BibliografiaCard
key={entry.id}
entry={entry}
@@ -248,137 +177,70 @@ export function BibliographyItem() {
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar referencia?</AlertDialogTitle>
<AlertDialogDescription>
La referencia será quitada del plan de estudios.
</AlertDialogDescription>
<AlertDialogDescription>La referencia será quitada del plan de estudios.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={onConfirmDelete} className="bg-red-600">
Eliminar
</AlertDialogAction>
<AlertDialogAction onClick={() => { setEntries(entries.filter(e => e.id !== deleteId)); setDeleteId(null); }} className="bg-red-600">Eliminar</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
);
}
// --- Subcomponentes ---
function BibliografiaCard({
entry,
isEditing,
onEdit,
onStopEditing,
onUpdateCita,
onDelete,
}: any) {
const [localCita, setLocalCita] = useState(entry.cita)
function BibliografiaCard({ entry, isEditing, onEdit, onStopEditing, onUpdateCita, onDelete }: any) {
const [localCita, setLocalCita] = useState(entry.cita);
return (
<Card
className={cn(
'group transition-all hover:shadow-md',
isEditing && 'ring-2 ring-blue-500',
)}
>
<Card className={cn("group transition-all hover:shadow-md", isEditing && "ring-2 ring-blue-500")}>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<BookOpen
className={cn(
'mt-1 h-5 w-5',
entry.tipo === 'BASICA' ? 'text-blue-600' : 'text-slate-400',
)}
/>
<div className="min-w-0 flex-1">
<BookOpen className={cn("w-5 h-5 mt-1", entry.tipo === 'BASICA' ? "text-blue-600" : "text-slate-400")} />
<div className="flex-1 min-w-0">
{isEditing ? (
<div className="space-y-2">
<Textarea
value={localCita}
onChange={(e) => setLocalCita(e.target.value)}
className="min-h-[80px]"
/>
<Textarea value={localCita} onChange={(e) => setLocalCita(e.target.value)} className="min-h-[80px]" />
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onStopEditing}>
Cancelar
</Button>
<Button
size="sm"
className="bg-emerald-600"
onClick={() => {
onUpdateCita(entry.id, localCita)
onStopEditing()
}}
>
Guardar
</Button>
<Button variant="ghost" size="sm" onClick={onStopEditing}>Cancelar</Button>
<Button size="sm" className="bg-emerald-600" onClick={() => { onUpdateCita(entry.id, localCita); onStopEditing(); }}>Guardar</Button>
</div>
</div>
) : (
<div onClick={onEdit} className="cursor-pointer">
<p className="text-sm leading-relaxed text-slate-700">
{entry.cita}
</p>
<p className="text-sm leading-relaxed text-slate-700">{entry.cita}</p>
{entry.fuenteBiblioteca && (
<div className="mt-2 flex gap-2">
<Badge
variant="secondary"
className="bg-slate-100 text-[10px] text-slate-600"
>
Biblioteca
</Badge>
{entry.fuenteBiblioteca.disponible && (
<Badge className="border-emerald-100 bg-emerald-50 text-[10px] text-emerald-700">
Disponible
</Badge>
)}
<div className="flex gap-2 mt-2">
<Badge variant="secondary" className="text-[10px] bg-slate-100 text-slate-600">Biblioteca</Badge>
{entry.fuenteBiblioteca.disponible && <Badge className="text-[10px] bg-emerald-50 text-emerald-700 border-emerald-100">Disponible</Badge>}
</div>
)}
</div>
)}
</div>
{!isEditing && (
<div className="flex opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-600"
onClick={onEdit}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-500"
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
<div className="flex opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-600" onClick={onEdit}><Edit3 className="w-4 h-4" /></Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-500" onClick={onDelete}><Trash2 className="w-4 h-4" /></Button>
</div>
)}
</div>
</CardContent>
</Card>
)
);
}
function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
const [cita, setCita] = useState('')
const [cita, setCita] = useState('');
return (
<div className="space-y-4 py-4">
<DialogHeader>
<DialogTitle>Referencia Manual</DialogTitle>
</DialogHeader>
<DialogHeader><DialogTitle>Referencia Manual</DialogTitle></DialogHeader>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Tipo
</label>
<label className="text-xs font-bold uppercase text-slate-500">Tipo</label>
<Select value={tipo} onValueChange={onTypeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="BASICA">Básica</SelectItem>
<SelectItem value="COMPLEMENTARIA">Complementaria</SelectItem>
@@ -386,80 +248,44 @@ function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Cita APA
</label>
<Textarea
value={cita}
onChange={(e) => setCita(e.target.value)}
placeholder="Autor, A. (Año). Título..."
className="min-h-[120px]"
/>
<label className="text-xs font-bold uppercase text-slate-500">Cita APA</label>
<Textarea value={cita} onChange={(e) => setCita(e.target.value)} placeholder="Autor, A. (Año). Título..." className="min-h-[120px]" />
</div>
<Button
onClick={() => onAdd(cita)}
disabled={!cita.trim()}
className="w-full bg-blue-600"
>
Añadir a la lista
</Button>
<Button onClick={() => onAdd(cita)} disabled={!cita.trim()} className="w-full bg-blue-600">Añadir a la lista</Button>
</div>
)
);
}
function LibrarySearchDialog({ resources, onSelect, existingIds }: any) {
const [search, setSearch] = useState('')
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA')
const filtered = (resources || []).filter(
(r: any) =>
!existingIds.includes(r.id) &&
r.titulo?.toLowerCase().includes(search.toLowerCase()),
)
console.log(filtered)
console.log(resources)
function LibrarySearchDialog({ onSelect, existingIds }: any) {
const [search, setSearch] = useState('');
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA');
const filtered = mockLibraryResources.filter(r =>
!existingIds.includes(r.id) && r.titulo.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="space-y-4 py-2">
<DialogHeader>
<DialogTitle>Catálogo de Biblioteca</DialogTitle>
</DialogHeader>
<DialogHeader><DialogTitle>Catálogo de Biblioteca</DialogTitle></DialogHeader>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar por título o autor..."
className="pl-10"
/>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Buscar por título o autor..." className="pl-10" />
</div>
<Select value={tipo} onValueChange={(v: any) => setTipo(v)}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BASICA">Básica</SelectItem>
<SelectItem value="COMPLEMENTARIA">Complem.</SelectItem>
</SelectContent>
<Select value={tipo} onValueChange={(v:any) => setTipo(v)}><SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="BASICA">Básica</SelectItem><SelectItem value="COMPLEMENTARIA">Complem.</SelectItem></SelectContent>
</Select>
</div>
<div className="max-h-[300px] space-y-2 overflow-y-auto pr-2">
{filtered.map((res: any) => (
<div
key={res.id}
onClick={() => onSelect(res, tipo)}
className="group flex cursor-pointer items-center justify-between rounded-lg border p-3 hover:bg-slate-50"
>
<div className="max-h-[300px] overflow-y-auto pr-2 space-y-2">
{filtered.map(res => (
<div key={res.id} onClick={() => onSelect(res, tipo)} className="p-3 border rounded-lg hover:bg-slate-50 cursor-pointer flex justify-between items-center group">
<div>
<p className="text-sm font-semibold text-slate-700">
{res.titulo}
</p>
<p className="text-sm font-semibold text-slate-700">{res.titulo}</p>
<p className="text-xs text-slate-500">{res.autor}</p>
</div>
<Plus className="h-4 w-4 text-blue-600 opacity-0 transition-opacity group-hover:opacity-100" />
<Plus className="w-4 h-4 text-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
))}
</div>
</div>
)
);
}

View File

@@ -1,18 +1,10 @@
import { useParams } from '@tanstack/react-router'
import {
Plus,
GripVertical,
ChevronDown,
ChevronRight,
Edit3,
Trash2,
Clock,
} from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
import type { FocusEvent, KeyboardEvent } from 'react'
import { useState } from 'react';
import { Plus, GripVertical, ChevronDown, ChevronRight, Edit3, Trash2, Clock, Save } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
AlertDialog,
AlertDialogAction,
@@ -22,737 +14,264 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { useSubject, useUpdateSubjectContenido } from '@/data/hooks/useSubjects'
import { cn } from '@/lib/utils'
} from '@/components/ui/alert-dialog';
import { cn } from '@/lib/utils';
//import { toast } from 'sonner';
export interface Tema {
id: string
nombre: string
descripcion?: string
horasEstimadas?: number
id: string;
nombre: string;
descripcion?: string;
horasEstimadas?: number;
}
export interface UnidadTematica {
id: string
nombre: string
numero: number
temas: Array<Tema>
id: string;
nombre: string;
numero: number;
temas: Tema[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
const initialData: UnidadTematica[] = [
{
id: 'u1',
numero: 1,
nombre: 'Fundamentos de Inteligencia Artificial',
temas: [
{ id: 't1', nombre: 'Tipos de IA y aplicaciones', horasEstimadas: 6 },
{ id: 't2', nombre: 'Ética en IA', horasEstimadas: 3 },
]
}
function coerceNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return undefined
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : undefined
}
return undefined
}
function coerceString(value: unknown): string | undefined {
if (typeof value === 'string') return value
return undefined
}
function mapTemaValue(value: unknown): ContenidoTemaApi | null {
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed ? trimmed : null
}
if (isRecord(value)) {
const nombre = coerceString(value.nombre)
if (!nombre) return null
const horasEstimadas = coerceNumber(value.horasEstimadas)
const descripcion = coerceString(value.descripcion)
return {
...value,
nombre,
horasEstimadas,
descripcion,
}
}
return null
}
function mapContenidoItem(value: unknown, index: number): ContenidoApi | null {
if (!isRecord(value)) return null
const unidad = coerceNumber(value.unidad) ?? index + 1
const titulo = coerceString(value.titulo) ?? 'Sin título'
let temas: Array<ContenidoTemaApi> = []
if (Array.isArray(value.temas)) {
temas = value.temas
.map(mapTemaValue)
.filter((t): t is ContenidoTemaApi => t !== null)
} else if (typeof value.temas === 'string' && value.temas.trim()) {
temas = value.temas
.split(/\r?\n|,/)
.map((t) => t.trim())
.filter(Boolean)
}
return { unidad, titulo, temas }
}
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
if (value == null) return []
if (typeof value === 'string') {
try {
return mapContenidoTematicoFromDb(JSON.parse(value))
} catch {
return []
}
}
if (Array.isArray(value)) {
return value
.map((item, idx) => mapContenidoItem(item, idx))
.filter((x): x is ContenidoApi => x !== null)
}
if (isRecord(value)) {
if (Array.isArray(value.contenido_tematico)) {
return mapContenidoTematicoFromDb(value.contenido_tematico)
}
if (Array.isArray(value.unidades)) {
return mapContenidoTematicoFromDb(value.unidades)
}
}
return []
}
function serializeUnidadesToApi(
unidades: Array<UnidadTematica>,
): Array<ContenidoApi> {
return unidades
.slice()
.sort((a, b) => a.numero - b.numero)
.map((u, idx) => ({
unidad: u.numero || idx + 1,
titulo: u.nombre || 'Sin título',
temas: u.temas.map((t) => ({
nombre: t.nombre || 'Tema',
horasEstimadas: t.horasEstimadas ?? 0,
descripcion: t.descripcion,
})),
}))
}
// Props del componente
];
export function ContenidoTematico() {
const updateContenido = useUpdateSubjectContenido()
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
const [unidades, setUnidades] = useState<Array<UnidadTematica>>([])
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(new Set())
const unitContainerRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const unitTitleInputRef = useRef<HTMLInputElement | null>(null)
const temaNombreInputElRef = useRef<HTMLInputElement | null>(null)
const [pendingScrollUnitId, setPendingScrollUnitId] = useState<string | null>(
null,
)
const cancelNextBlurRef = useRef(false)
const [deleteDialog, setDeleteDialog] = useState<{
type: 'unidad' | 'tema'
id: string
parentId?: string
} | null>(null)
const [editingUnit, setEditingUnit] = useState<string | null>(null)
const [unitDraftNombre, setUnitDraftNombre] = useState('')
const [unitOriginalNombre, setUnitOriginalNombre] = useState('')
const [editingTema, setEditingTema] = useState<{
unitId: string
temaId: string
} | null>(null)
const [temaDraftNombre, setTemaDraftNombre] = useState('')
const [temaOriginalNombre, setTemaOriginalNombre] = useState('')
const [temaDraftHoras, setTemaDraftHoras] = useState('')
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
const payload = serializeUnidadesToApi(nextUnidades)
await updateContenido.mutateAsync({
subjectId: asignaturaId,
unidades: payload,
})
}
const beginEditUnit = (unitId: string) => {
const unit = unidades.find((u) => u.id === unitId)
const nombre = unit?.nombre ?? ''
setEditingUnit(unitId)
setUnitDraftNombre(nombre)
setUnitOriginalNombre(nombre)
setExpandedUnits((prev) => {
const next = new Set(prev)
next.add(unitId)
return next
})
}
const commitEditUnit = () => {
if (!editingUnit) return
const next = unidades.map((u) =>
u.id === editingUnit ? { ...u, nombre: unitDraftNombre } : u,
)
setUnidades(next)
setEditingUnit(null)
void persistUnidades(next)
}
const cancelEditUnit = () => {
setEditingUnit(null)
setUnitDraftNombre(unitOriginalNombre)
}
const beginEditTema = (unitId: string, temaId: string) => {
const unit = unidades.find((u) => u.id === unitId)
const tema = unit?.temas.find((t) => t.id === temaId)
const nombre = tema?.nombre ?? ''
const horas = tema?.horasEstimadas ?? 0
setEditingTema({ unitId, temaId })
setTemaDraftNombre(nombre)
setTemaOriginalNombre(nombre)
setTemaDraftHoras(String(horas))
setTemaOriginalHoras(horas)
setExpandedUnits((prev) => {
const next = new Set(prev)
next.add(unitId)
return next
})
}
const commitEditTema = () => {
if (!editingTema) return
const parsedHoras = Number.parseInt(temaDraftHoras, 10)
const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
const next = unidades.map((u) => {
if (u.id !== editingTema.unitId) return u
return {
...u,
temas: u.temas.map((t) =>
t.id === editingTema.temaId
? { ...t, nombre: temaDraftNombre, horasEstimadas }
: t,
),
}
})
setUnidades(next)
setEditingTema(null)
void persistUnidades(next)
}
const cancelEditTema = () => {
setEditingTema(null)
setTemaDraftNombre(temaOriginalNombre)
setTemaDraftHoras(String(temaOriginalHoras))
}
const handleTemaEditorBlurCapture = (e: FocusEvent<HTMLDivElement>) => {
if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false
return
}
const nextFocus = e.relatedTarget as Node | null
if (nextFocus && e.currentTarget.contains(nextFocus)) return
commitEditTema()
}
const handleTemaEditorKeyDownCapture = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
if (e.target instanceof HTMLElement) e.target.blur()
return
}
if (e.key === 'Escape') {
e.preventDefault()
cancelNextBlurRef.current = true
cancelEditTema()
if (e.target instanceof HTMLElement) e.target.blur()
}
}
useEffect(() => {
const contenido = mapContenidoTematicoFromDb(
data ? data.contenido_tematico : undefined,
)
const transformed = contenido.map((u, idx) => ({
id: `u-${u.unidad || idx + 1}`,
numero: u.unidad || idx + 1,
nombre: u.titulo || 'Sin título',
temas: Array.isArray(u.temas)
? u.temas.map((t: any, tidx: number) => ({
id: `t-${u.unidad || idx + 1}-${tidx + 1}`,
nombre: typeof t === 'string' ? t : t?.nombre || 'Tema',
horasEstimadas: t?.horasEstimadas || 0,
}))
: [],
}))
setUnidades(transformed)
// Mantener las unidades ya expandidas si existen; si no, expandir la primera.
setExpandedUnits((prev) => {
const validIds = new Set(transformed.map((u) => u.id))
const filtered = new Set(
Array.from(prev).filter((id) => validIds.has(id)),
)
if (filtered.size > 0) return filtered
return transformed.length > 0 ? new Set([transformed[0].id]) : new Set()
})
}, [data])
useEffect(() => {
if (!editingUnit) return
// Foco controlado (evitamos autoFocus por lint/a11y)
setTimeout(() => unitTitleInputRef.current?.focus(), 0)
}, [editingUnit])
useEffect(() => {
if (!editingTema) return
setTimeout(() => temaNombreInputElRef.current?.focus(), 0)
}, [editingTema])
useEffect(() => {
if (!pendingScrollUnitId) return
const el = unitContainerRefs.current.get(pendingScrollUnitId)
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
setPendingScrollUnitId(null)
}, [pendingScrollUnitId, unidades.length])
if (isLoading)
return <div className="p-10 text-center">Cargando contenido...</div>
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos)
const totalHoras = unidades.reduce(
(acc, u) =>
acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0),
0,
)
const [unidades, setUnidades] = useState<UnidadTematica[]>(initialData);
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(new Set(['u1']));
const [deleteDialog, setDeleteDialog] = useState<{ type: 'unidad' | 'tema'; id: string; parentId?: string } | null>(null);
const [editingUnit, setEditingUnit] = useState<string | null>(null);
const [editingTema, setEditingTema] = useState<{ unitId: string; temaId: string } | null>(null);
const [isSaving, setIsSaving] = useState(false);
// --- Lógica de Unidades ---
const toggleUnit = (id: string) => {
const newExpanded = new Set(expandedUnits)
newExpanded.has(id) ? newExpanded.delete(id) : newExpanded.add(id)
setExpandedUnits(newExpanded)
}
const newExpanded = new Set(expandedUnits);
newExpanded.has(id) ? newExpanded.delete(id) : newExpanded.add(id);
setExpandedUnits(newExpanded);
};
const addUnidad = () => {
const newNumero = unidades.length + 1
const newId = `u-${newNumero}`
const newId = `u-${Date.now()}`;
const newUnidad: UnidadTematica = {
id: newId,
nombre: 'Nueva Unidad',
numero: newNumero,
numero: unidades.length + 1,
temas: [],
}
const next = [...unidades, newUnidad]
setUnidades(next)
setExpandedUnits((prev) => {
const n = new Set(prev)
n.add(newId)
return n
})
setPendingScrollUnitId(newId)
};
setUnidades([...unidades, newUnidad]);
setExpandedUnits(new Set([...expandedUnits, newId]));
setEditingUnit(newId);
};
// Abrir edición del título inmediatamente
setEditingUnit(newId)
setUnitDraftNombre(newUnidad.nombre)
setUnitOriginalNombre(newUnidad.nombre)
}
const updateUnidadNombre = (id: string, nombre: string) => {
setUnidades(unidades.map(u => u.id === id ? { ...u, nombre } : u));
};
// --- Lógica de Temas ---
const addTema = (unidadId: string) => {
const unit = unidades.find((u) => u.id === unidadId)
const unitNumero = unit?.numero ?? 0
const newTemaIndex = (unit?.temas.length ?? 0) + 1
const newTemaId = `t-${unitNumero}-${newTemaIndex}`
const newTema: Tema = {
id: newTemaId,
nombre: 'Nuevo tema',
horasEstimadas: 2,
setUnidades(unidades.map(u => {
if (u.id === unidadId) {
const newTemaId = `t-${Date.now()}`;
const newTema: Tema = { id: newTemaId, nombre: 'Nuevo tema', horasEstimadas: 2 };
setEditingTema({ unitId: unidadId, temaId: newTemaId });
return { ...u, temas: [...u.temas, newTema] };
}
return u;
}));
};
const next = unidades.map((u) =>
u.id === unidadId ? { ...u, temas: [...u.temas, newTema] } : u,
)
setUnidades(next)
// Expandir unidad y poner el subtema en edición con foco en el nombre
setExpandedUnits((prev) => {
const n = new Set(prev)
n.add(unidadId)
return n
})
setEditingTema({ unitId: unidadId, temaId: newTemaId })
setTemaDraftNombre(newTema.nombre)
setTemaOriginalNombre(newTema.nombre)
setTemaDraftHoras(String(newTema.horasEstimadas ?? 0))
setTemaOriginalHoras(newTema.horasEstimadas ?? 0)
const updateTema = (unidadId: string, temaId: string, updates: Partial<Tema>) => {
setUnidades(unidades.map(u => {
if (u.id === unidadId) {
return { ...u, temas: u.temas.map(t => t.id === temaId ? { ...t, ...updates } : t) };
}
return u;
}));
};
const handleDelete = () => {
if (!deleteDialog) return
let next: Array<UnidadTematica> = unidades
if (!deleteDialog) return;
if (deleteDialog.type === 'unidad') {
next = unidades
.filter((u) => u.id !== deleteDialog.id)
.map((u, i) => ({ ...u, numero: i + 1 }))
setUnidades(unidades.filter(u => u.id !== deleteDialog.id).map((u, i) => ({ ...u, numero: i + 1 })));
} else if (deleteDialog.parentId) {
next = unidades.map((u) =>
u.id === deleteDialog.parentId
? { ...u, temas: u.temas.filter((t) => t.id !== deleteDialog.id) }
: u,
)
setUnidades(unidades.map(u => u.id === deleteDialog.parentId ? { ...u, temas: u.temas.filter(t => t.id !== deleteDialog.id) } : u));
}
setUnidades(next)
setDeleteDialog(null)
void persistUnidades(next)
setDeleteDialog(null);
//toast.success("Eliminado correctamente");
}
};
const totalHoras = unidades.reduce((acc, u) => acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0), 0);
return (
<div className="animate-in fade-in mx-auto max-w-5xl space-y-6 py-10 duration-500">
<div className="max-w-5xl mx-auto py-10 space-y-6 animate-in fade-in duration-500">
<div className="flex items-center justify-between border-b pb-4">
<div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
Contenido Temático
</h2>
<p className="mt-1 text-sm text-slate-500">
<h2 className="text-2xl font-bold tracking-tight text-slate-900">Contenido Temático</h2>
<p className="text-sm text-slate-500 mt-1">
{unidades.length} unidades {totalHoras} horas estimadas totales
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={addUnidad} className="gap-2">
<Plus className="w-4 h-4" /> Nueva unidad
</Button>
<Button onClick={() => { setIsSaving(true); setTimeout(() => { setIsSaving(false); /*toast.success("Guardado")*/; }, 1000); }} disabled={isSaving} className="bg-blue-600 hover:bg-blue-700">
<Save className="w-4 h-4 mr-2" /> {isSaving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</div>
<div className="space-y-4">
{unidades.map((unidad) => (
<div
key={unidad.id}
ref={(el) => {
if (el) unitContainerRefs.current.set(unidad.id, el)
else unitContainerRefs.current.delete(unidad.id)
}}
>
<Card className="overflow-hidden border-slate-200 shadow-sm">
<Collapsible
open={expandedUnits.has(unidad.id)}
onOpenChange={() => toggleUnit(unidad.id)}
>
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
<Card key={unidad.id} className="overflow-hidden border-slate-200 shadow-sm">
<Collapsible open={expandedUnits.has(unidad.id)} onOpenChange={() => toggleUnit(unidad.id)}>
<CardHeader className="bg-slate-50/50 py-3 border-b border-slate-100">
<div className="flex items-center gap-3">
<GripVertical className="h-4 w-4 cursor-grab text-slate-300" />
<GripVertical className="w-4 h-4 text-slate-300 cursor-grab" />
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-auto p-0">
{expandedUnits.has(unidad.id) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<Button variant="ghost" size="sm" className="p-0 h-auto">
{expandedUnits.has(unidad.id) ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</Button>
</CollapsibleTrigger>
<Badge className="bg-blue-600 font-mono">
Unidad {unidad.numero}
</Badge>
<Badge className="bg-blue-600 font-mono">Unidad {unidad.numero}</Badge>
{editingUnit === unidad.id ? (
<Input
ref={unitTitleInputRef}
value={unitDraftNombre}
onChange={(e) => setUnitDraftNombre(e.target.value)}
onBlur={() => {
if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false
return
}
commitEditUnit()
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
return
}
if (e.key === 'Escape') {
e.preventDefault()
cancelNextBlurRef.current = true
cancelEditUnit()
e.currentTarget.blur()
}
}}
className="h-8 max-w-md bg-white"
value={unidad.nombre}
onChange={(e) => updateUnidadNombre(unidad.id, e.target.value)}
onBlur={() => setEditingUnit(null)}
onKeyDown={(e) => e.key === 'Enter' && setEditingUnit(null)}
className="max-w-md h-8 bg-white"
autoFocus
/>
) : (
<CardTitle
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
onClick={() => beginEditUnit(unidad.id)}
>
<CardTitle className="text-base font-semibold cursor-pointer hover:text-blue-600 transition-colors" onClick={() => setEditingUnit(unidad.id)}>
{unidad.nombre}
</CardTitle>
)}
<div className="ml-auto flex items-center gap-3">
<span className="flex items-center gap-1 text-xs font-medium text-slate-400">
<Clock className="h-3 w-3" />{' '}
{unidad.temas.reduce(
(sum, t) => sum + (t.horasEstimadas || 0),
0,
)}
h
<span className="text-xs font-medium text-slate-400 flex items-center gap-1">
<Clock className="w-3 h-3" /> {unidad.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0)}h
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-500"
onClick={() =>
setDeleteDialog({ type: 'unidad', id: unidad.id })
}
>
<Trash2 className="h-4 w-4" />
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-500" onClick={() => setDeleteDialog({ type: 'unidad', id: unidad.id })}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="bg-white pt-4">
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
<CardContent className="pt-4 bg-white">
<div className="space-y-1 ml-10 border-l-2 border-slate-50 pl-4">
{unidad.temas.map((tema, idx) => (
<TemaRow
key={tema.id}
tema={tema}
index={idx + 1}
isEditing={
!!editingTema &&
editingTema.unitId === unidad.id &&
editingTema.temaId === tema.id
}
draftNombre={temaDraftNombre}
draftHoras={temaDraftHoras}
onBeginEdit={() => beginEditTema(unidad.id, tema.id)}
onDraftNombreChange={setTemaDraftNombre}
onDraftHorasChange={setTemaDraftHoras}
onEditorBlurCapture={handleTemaEditorBlurCapture}
onEditorKeyDownCapture={
handleTemaEditorKeyDownCapture
}
onNombreInputRef={(el) => {
temaNombreInputElRef.current = el
}}
onDelete={() =>
setDeleteDialog({
type: 'tema',
id: tema.id,
parentId: unidad.id,
})
}
isEditing={editingTema?.unitId === unidad.id && editingTema?.temaId === tema.id}
onEdit={() => setEditingTema({ unitId: unidad.id, temaId: tema.id })}
onStopEditing={() => setEditingTema(null)}
onUpdate={(updates) => updateTema(unidad.id, tema.id, updates)}
onDelete={() => setDeleteDialog({ type: 'tema', id: tema.id, parentId: unidad.id })}
/>
))}
<Button
variant="ghost"
size="sm"
className="mt-2 w-full justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => addTema(unidad.id)}
>
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
<Button variant="ghost" size="sm" className="text-blue-600 hover:text-blue-700 hover:bg-blue-50 w-full justify-start mt-2" onClick={() => addTema(unidad.id)}>
<Plus className="w-3 h-3 mr-2" /> Añadir subtema
</Button>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
</div>
))}
</div>
<div className="flex justify-center pt-2">
<Button
variant="outline"
className="gap-2"
onClick={(e) => {
// Evita que Enter vuelva a disparar el click sobre el botón.
e.currentTarget.blur()
addUnidad()
}}
>
<Plus className="h-4 w-4" /> Nueva unidad
</Button>
<DeleteConfirmDialog dialog={deleteDialog} setDialog={setDeleteDialog} onConfirm={handleDelete} />
</div>
<DeleteConfirmDialog
dialog={deleteDialog}
setDialog={setDeleteDialog}
onConfirm={handleDelete}
/>
</div>
)
);
}
// --- Componentes Auxiliares ---
interface TemaRowProps {
tema: Tema
index: number
isEditing: boolean
draftNombre: string
draftHoras: string
onBeginEdit: () => void
onDraftNombreChange: (value: string) => void
onDraftHorasChange: (value: string) => void
onEditorBlurCapture: (e: FocusEvent<HTMLDivElement>) => void
onEditorKeyDownCapture: (e: KeyboardEvent<HTMLDivElement>) => void
onNombreInputRef: (el: HTMLInputElement | null) => void
onDelete: () => void
tema: Tema;
index: number;
isEditing: boolean;
onEdit: () => void;
onStopEditing: () => void;
onUpdate: (updates: Partial<Tema>) => void;
onDelete: () => void;
}
function TemaRow({
tema,
index,
isEditing,
draftNombre,
draftHoras,
onBeginEdit,
onDraftNombreChange,
onDraftHorasChange,
onEditorBlurCapture,
onEditorKeyDownCapture,
onNombreInputRef,
onDelete,
}: TemaRowProps) {
function TemaRow({ tema, index, isEditing, onEdit, onStopEditing, onUpdate, onDelete }: TemaRowProps) {
return (
<div
className={cn(
'group flex items-center gap-3 rounded-md p-2 transition-all',
isEditing ? 'bg-blue-50 ring-1 ring-blue-100' : 'hover:bg-slate-50',
)}
>
<span className="w-4 font-mono text-xs text-slate-400">{index}.</span>
<div className={cn("flex items-center gap-3 p-2 rounded-md group transition-all", isEditing ? "bg-blue-50 ring-1 ring-blue-100" : "hover:bg-slate-50")}>
<span className="text-xs font-mono text-slate-400 w-4">{index}.</span>
{isEditing ? (
<div
className="animate-in slide-in-from-left-2 flex flex-1 items-center gap-2"
onBlurCapture={onEditorBlurCapture}
onKeyDownCapture={onEditorKeyDownCapture}
>
<Input
ref={onNombreInputRef}
value={draftNombre}
onChange={(e) => onDraftNombreChange(e.target.value)}
className="h-8 flex-1 bg-white"
placeholder="Nombre"
/>
<Input
type="number"
value={draftHoras}
onChange={(e) => onDraftHorasChange(e.target.value)}
className="h-8 w-16 bg-white"
/>
<div className="flex-1 flex items-center gap-2 animate-in slide-in-from-left-2">
<Input value={tema.nombre} onChange={(e) => onUpdate({ nombre: e.target.value })} className="h-8 flex-1 bg-white" placeholder="Nombre" autoFocus />
<Input type="number" value={tema.horasEstimadas} onChange={(e) => onUpdate({ horasEstimadas: parseInt(e.target.value) || 0 })} className="h-8 w-16 bg-white" />
<Button size="sm" className="bg-emerald-600 h-8" onClick={onStopEditing}>Listo</Button>
</div>
) : (
<>
<button
type="button"
className="flex flex-1 items-center gap-3 text-left"
onClick={(e) => {
e.stopPropagation()
onBeginEdit()
}}
>
<div className="flex-1 cursor-pointer" onClick={onEdit}>
<p className="text-sm font-medium text-slate-700">{tema.nombre}</p>
<Badge variant="secondary" className="text-[10px] opacity-60">
{tema.horasEstimadas}h
</Badge>
</button>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-blue-600"
onClick={(e) => {
e.stopPropagation()
onBeginEdit()
}}
>
<Edit3 className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-red-500"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<Badge variant="secondary" className="text-[10px] opacity-60">{tema.horasEstimadas}h</Badge>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-600" onClick={onEdit}><Edit3 className="w-3 h-3" /></Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-red-500" onClick={onDelete}><Trash2 className="w-3 h-3" /></Button>
</div>
</>
)}
</div>
)
);
}
interface DeleteDialogState {
type: 'unidad' | 'tema'
id: string
parentId?: string
type: 'unidad' | 'tema';
id: string;
parentId?: string;
}
interface DeleteConfirmDialogProps {
dialog: DeleteDialogState | null
setDialog: (value: DeleteDialogState | null) => void
onConfirm: () => void
dialog: DeleteDialogState | null;
setDialog: (value: DeleteDialogState | null) => void;
onConfirm: () => void;
}
function DeleteConfirmDialog({
dialog,
setDialog,
onConfirm,
}: DeleteConfirmDialogProps) {
return (
<AlertDialog open={!!dialog} onOpenChange={() => setDialog(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Confirmar eliminación?</AlertDialogTitle>
<AlertDialogDescription>
Estás a punto de borrar un {dialog?.type}. Esta acción no se puede
deshacer.
Estás a punto de borrar un {dialog?.type}. Esta acción no se puede deshacer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Eliminar
</AlertDialogAction>
<AlertDialogAction onClick={onConfirm} className="bg-red-600 hover:bg-red-700 text-white">Eliminar</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
);
}

View File

@@ -1,6 +1,8 @@
import { FileCheck, Download, RefreshCw, Loader2 } from 'lucide-react'
import { useState } from 'react'
import { useState } from 'react';
import { FileText, Download, RefreshCw, Calendar, FileCheck, AlertTriangle, Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
@@ -11,78 +13,75 @@ import {
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
} from '@/components/ui/alert-dialog';
import type { DocumentoMateria, Materia, MateriaStructure } from '@/types/materia';
import { cn } from '@/lib/utils';
//import { toast } from 'sonner';
//import { format } from 'date-fns';
//import { es } from 'date-fns/locale';
interface DocumentoSEPTabProps {
pdfUrl: string | null
isLoading: boolean
onDownload: () => void
onRegenerate: () => void
isRegenerating: boolean
documento: DocumentoMateria | null;
materia: Materia;
estructura: MateriaStructure;
datosGenerales: Record<string, any>;
onRegenerate: () => void;
isRegenerating: boolean;
}
export function DocumentoSEPTab({
pdfUrl,
isLoading,
onDownload,
onRegenerate,
isRegenerating,
}: DocumentoSEPTabProps) {
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales, onRegenerate, isRegenerating }: DocumentoSEPTabProps) {
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
// Check completeness
const camposObligatorios = estructura.campos.filter(c => c.obligatorio);
const camposCompletos = camposObligatorios.filter(c => datosGenerales[c.id]?.trim());
const completeness = Math.round((camposCompletos.length / camposObligatorios.length) * 100);
const isComplete = completeness === 100;
const handleRegenerate = () => {
setShowConfirmDialog(false)
onRegenerate()
}
setShowConfirmDialog(false);
onRegenerate();
//toast.success('Regenerando documento...');
};
return (
<div className="animate-fade-in space-y-6">
{/* Header */}
<div className="space-y-6 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
<FileCheck className="text-accent h-6 w-6" />
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
<FileCheck className="w-6 h-6 text-accent" />
Documento SEP
</h2>
<p className="text-muted-foreground mt-1 text-sm">
Previsualización del documento oficial generado
<p className="text-sm text-muted-foreground mt-1">
Previsualización del documento oficial para la SEP
</p>
</div>
<div className="flex items-center gap-2">
{pdfUrl && !isLoading && (
<Button variant="outline" onClick={onDownload}>
<Download className="mr-2 h-4 w-4" />
{documento?.estado === 'listo' && (
<Button variant="outline" onClick={() => console.log("descargando") /*toast.info('Descarga iniciada')*/}>
<Download className="w-4 h-4 mr-2" />
Descargar
</Button>
)}
<AlertDialog
open={showConfirmDialog}
onOpenChange={setShowConfirmDialog}
>
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogTrigger asChild>
<Button disabled={isRegenerating}>
<Button disabled={isRegenerating || !isComplete}>
{isRegenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
<RefreshCw className="w-4 h-4 mr-2" />
)}
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle>
<AlertDialogDescription>
Se generará una nueva versión del documento con la información
actual.
Se creará una nueva versión del documento con los datos actuales de la materia.
La versión anterior quedará en el historial.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleRegenerate}>
@@ -94,24 +93,229 @@ export function DocumentoSEPTab({
</div>
</div>
{/* PDF Preview */}
<Card className="h-[800px] overflow-hidden">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Document preview */}
<div className="lg:col-span-2">
<Card className="card-elevated h-[700px] overflow-hidden">
{documento?.estado === 'listo' ? (
<div className="h-full bg-muted/30 flex flex-col">
{/* Simulated document header */}
<div className="bg-card border-b p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="w-5 h-5 text-primary" />
<span className="font-medium text-foreground">
Programa de Estudios - {materia.clave}
</span>
</div>
<Badge variant="outline">Versión {documento.version}</Badge>
</div>
</div>
{/* Document content simulation */}
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-2xl mx-auto bg-card rounded-lg shadow-lg p-8 space-y-6">
{/* Header */}
<div className="text-center border-b pb-6">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">
Secretaría de Educación Pública
</p>
<h1 className="font-display text-2xl font-bold text-primary mb-1">
{materia.nombre}
</h1>
<p className="text-sm text-muted-foreground">
Clave: {materia.clave} | Créditos: {materia.creditos || 'N/A'}
</p>
</div>
{/* Datos de la institución */}
<div className="space-y-1 text-sm">
<p><strong>Carrera:</strong> {materia.carrera}</p>
<p><strong>Facultad:</strong> {materia.facultad}</p>
<p><strong>Plan de estudios:</strong> {materia.planNombre}</p>
{materia.ciclo && <p><strong>Ciclo:</strong> {materia.ciclo}</p>}
</div>
{/* Campos del documento */}
{estructura.campos.map((campo) => {
const valor = datosGenerales[campo.id];
if (!valor) return null;
return (
<div key={campo.id} className="space-y-2">
<h3 className="font-semibold text-foreground border-b pb-1">
{campo.nombre}
</h3>
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
{valor}
</p>
</div>
);
})}
{/* Footer */}
<div className="border-t pt-6 mt-8 text-center text-xs text-muted-foreground">
<p>Documento generado el {/*format(documento.fechaGeneracion, "d 'de' MMMM 'de' yyyy", { locale: es })*/}</p>
<p className="mt-1">Universidad La Salle</p>
</div>
</div>
</div>
</div>
) : documento?.estado === 'generando' ? (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-12 h-12 mx-auto text-accent animate-spin mb-4" />
<p className="text-muted-foreground">Generando documento...</p>
</div>
</div>
) : pdfUrl ? (
<iframe
src={`${pdfUrl}#toolbar=0`}
className="h-full w-full border-none"
title="Documento SEP"
/>
) : (
<div className="text-muted-foreground flex h-full items-center justify-center">
No se pudo cargar el documento.
<div className="h-full flex items-center justify-center">
<div className="text-center max-w-sm">
<FileText className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground mb-4">
No hay documento generado aún
</p>
{!isComplete && (
<div className="p-4 bg-warning/10 rounded-lg text-sm text-warning-foreground">
<AlertTriangle className="w-4 h-4 inline mr-2" />
Completa todos los campos obligatorios para generar el documento
</div>
)}
</div>
</div>
)}
</Card>
</div>
)
{/* Info sidebar */}
<div className="space-y-4">
{/* Status */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Estado del documento</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{documento && (
<>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Versión</span>
<Badge variant="outline">{documento.version}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Generado</span>
<span className="text-sm">
{/*format(documento.fechaGeneracion, "d MMM yyyy, HH:mm", { locale: es })*/}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Estado</span>
<Badge className={cn(
documento.estado === 'listo' && "bg-success text-success-foreground",
documento.estado === 'generando' && "bg-info text-info-foreground",
documento.estado === 'error' && "bg-destructive text-destructive-foreground"
)}>
{documento.estado === 'listo' && 'Listo'}
{documento.estado === 'generando' && 'Generando'}
{documento.estado === 'error' && 'Error'}
</Badge>
</div>
</>
)}
</CardContent>
</Card>
{/* Completeness */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Completitud de datos</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Campos obligatorios</span>
<span className="font-medium">{camposCompletos.length}/{camposObligatorios.length}</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full transition-all duration-500",
completeness === 100 ? "bg-success" : "bg-accent"
)}
style={{ width: `${completeness}%` }}
/>
</div>
<p className={cn(
"text-xs",
completeness === 100 ? "text-success" : "text-muted-foreground"
)}>
{completeness === 100
? 'Todos los campos obligatorios están completos'
: `Faltan ${camposObligatorios.length - camposCompletos.length} campos por completar`
}
</p>
</div>
{/* Missing fields */}
{!isComplete && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Campos faltantes:</p>
{camposObligatorios.filter(c => !datosGenerales[c.id]?.trim()).map((campo) => (
<div key={campo.id} className="flex items-center gap-2 text-sm">
<AlertTriangle className="w-3 h-3 text-warning" />
<span className="text-foreground">{campo.nombre}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Requirements */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Requisitos SEP</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
<li className="flex items-start gap-2">
<div className={cn(
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5",
datosGenerales['objetivo_general'] ? "bg-success/20" : "bg-muted"
)}>
{datosGenerales['objetivo_general'] && <Check className="w-3 h-3 text-success" />}
</div>
<span className="text-muted-foreground">Objetivo general definido</span>
</li>
<li className="flex items-start gap-2">
<div className={cn(
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5",
datosGenerales['competencias'] ? "bg-success/20" : "bg-muted"
)}>
{datosGenerales['competencias'] && <Check className="w-3 h-3 text-success" />}
</div>
<span className="text-muted-foreground">Competencias especificadas</span>
</li>
<li className="flex items-start gap-2">
<div className={cn(
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5",
datosGenerales['evaluacion'] ? "bg-success/20" : "bg-muted"
)}>
{datosGenerales['evaluacion'] && <Check className="w-3 h-3 text-success" />}
</div>
<span className="text-muted-foreground">Criterios de evaluación</span>
</li>
</ul>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function Check({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
);
}

View File

@@ -1,293 +1,181 @@
import { useParams } from '@tanstack/react-router'
import { format, parseISO } from 'date-fns'
import { es } from 'date-fns/locale'
import {
History,
FileText,
List,
BookMarked,
Sparkles,
FileCheck,
Filter,
Calendar,
Loader2,
Eye,
} from 'lucide-react'
import { useState, useMemo } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useState } from 'react';
import { History, FileText, List, BookMarked, Sparkles, FileCheck, User, Filter, Calendar } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useSubjectHistorial } from '@/data/hooks/useSubjects'
import { cn } from '@/lib/utils'
} from '@/components/ui/dropdown-menu';
import type { CambioMateria } from '@/types/materia';
import { cn } from '@/lib/utils';
import { format, formatDistanceToNow } from 'date-fns';
import { es } from 'date-fns/locale';
const tipoConfig: Record<string, { label: string; icon: any; color: string }> =
{
interface HistorialTabProps {
historial: CambioMateria[];
}
const tipoConfig: Record<string, { label: string; icon: React.ComponentType<{ className?: string }>; color: string }> = {
datos: { label: 'Datos generales', icon: FileText, color: 'text-info' },
contenido: {
label: 'Contenido temático',
icon: List,
color: 'text-accent',
},
bibliografia: {
label: 'Bibliografía',
icon: BookMarked,
color: 'text-success',
},
contenido: { label: 'Contenido temático', icon: List, color: 'text-accent' },
bibliografia: { label: 'Bibliografía', icon: BookMarked, color: 'text-success' },
ia: { label: 'IA', icon: Sparkles, color: 'text-amber-500' },
documento: {
label: 'Documento SEP',
icon: FileCheck,
color: 'text-primary',
},
}
documento: { label: 'Documento SEP', icon: FileCheck, color: 'text-primary' },
};
export function HistorialTab() {
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId/historial',
})
// 1. Obtenemos los datos directamente dentro del componente
const { data: rawData, isLoading } = useSubjectHistorial(asignaturaId)
const [filtros, setFiltros] = useState<Set<string>>(
new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']),
)
// ESTADOS PARA EL MODAL
const [selectedChange, setSelectedChange] = useState<any>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const RenderValue = ({ value }: { value: any }) => {
// 1. Caso: Nulo o vacío
if (
value === null ||
value === undefined ||
value === 'Sin información previa'
) {
return (
<span className="text-muted-foreground italic">Sin información</span>
)
}
// 2. Caso: Es un ARRAY (como tu lista de unidades)
if (Array.isArray(value)) {
return (
<div className="space-y-4">
{value.map((item, index) => (
<div
key={index}
className="rounded-lg border bg-white/50 p-3 shadow-sm"
>
<RenderValue value={item} />
</div>
))}
</div>
)
}
// 3. Caso: Es un OBJETO (como cada unidad con titulo, temas, etc.)
if (typeof value === 'object') {
return (
<div className="grid gap-2">
{Object.entries(value).map(([key, val]) => (
<div key={key} className="flex flex-col">
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
{key.replace(/_/g, ' ')}
</span>
<div className="text-sm text-slate-700">
{/* Llamada recursiva para manejar lo que haya dentro del valor */}
{typeof val === 'object' ? (
<div className="mt-1 border-l-2 border-slate-100 pl-2">
<RenderValue value={val} />
</div>
) : (
String(val)
)}
</div>
</div>
))}
</div>
)
}
// 4. Caso: Texto o número simple
return <span className="text-sm leading-relaxed">{String(value)}</span>
}
const historialTransformado = useMemo(() => {
if (!rawData) return []
return rawData.map((item: any) => ({
id: item.id,
tipo: item.campo === 'contenido_tematico' ? 'contenido' : 'datos',
descripcion: `Se actualizó el campo ${item.campo.replace('_', ' ')}`,
fecha: parseISO(item.cambiado_en),
usuario: item.fuente === 'HUMANO' ? 'Usuario Staff' : 'Sistema IA',
detalles: {
campo: item.campo,
valor_anterior: item.valor_anterior || 'Sin datos previos', // Asumiendo que existe en tu API
valor_nuevo: item.valor_nuevo,
},
}))
}, [rawData])
const openCompareModal = (cambio: any) => {
setSelectedChange(cambio)
setIsModalOpen(true)
}
export function HistorialTab({ historial }: HistorialTabProps) {
const [filtros, setFiltros] = useState<Set<string>>(new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']));
const toggleFiltro = (tipo: string) => {
const newFiltros = new Set(filtros)
if (newFiltros.has(tipo)) newFiltros.delete(tipo)
else newFiltros.add(tipo)
setFiltros(newFiltros)
const newFiltros = new Set(filtros);
if (newFiltros.has(tipo)) {
newFiltros.delete(tipo);
} else {
newFiltros.add(tipo);
}
setFiltros(newFiltros);
};
// 3. Aplicamos filtros y agrupamiento sobre los datos transformados
const filteredHistorial = historialTransformado.filter((cambio) =>
filtros.has(cambio.tipo),
)
const filteredHistorial = historial.filter(cambio => filtros.has(cambio.tipo));
const groupedHistorial = filteredHistorial.reduce(
(groups, cambio) => {
const dateKey = format(cambio.fecha, 'yyyy-MM-dd')
if (!groups[dateKey]) groups[dateKey] = []
groups[dateKey].push(cambio)
return groups
},
{} as Record<string, Array<any>>,
)
const sortedDates = Object.keys(groupedHistorial).sort((a, b) =>
b.localeCompare(a),
)
if (isLoading) {
return (
<div className="flex h-48 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin" />
</div>
)
// Group by date
const groupedHistorial = filteredHistorial.reduce((groups, cambio) => {
const dateKey = format(cambio.fecha, 'yyyy-MM-dd');
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(cambio);
return groups;
}, {} as Record<string, CambioMateria[]>);
const sortedDates = Object.keys(groupedHistorial).sort((a, b) => b.localeCompare(a));
return (
<div className="animate-fade-in space-y-6">
<div className="space-y-6 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
<History className="text-accent h-6 w-6" />
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
<History className="w-6 h-6 text-accent" />
Historial de cambios
</h2>
<p className="text-muted-foreground mt-1 text-sm">
{historialTransformado.length} cambios registrados
<p className="text-sm text-muted-foreground mt-1">
{historial.length} cambios registrados
</p>
</div>
{/* Dropdown de Filtros (Igual al anterior) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Filter className="mr-2 h-4 w-4" />
<Filter className="w-4 h-4 mr-2" />
Filtrar ({filtros.size})
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{Object.entries(tipoConfig).map(([tipo, config]) => (
{Object.entries(tipoConfig).map(([tipo, config]) => {
const Icon = config.icon;
return (
<DropdownMenuCheckboxItem
key={tipo}
checked={filtros.has(tipo)}
onCheckedChange={() => toggleFiltro(tipo)}
>
<config.icon className={cn('mr-2 h-4 w-4', config.color)} />
<Icon className={cn("w-4 h-4 mr-2", config.color)} />
{config.label}
</DropdownMenuCheckboxItem>
))}
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{filteredHistorial.length === 0 ? (
<Card>
<Card className="card-elevated">
<CardContent className="py-12 text-center">
<History className="text-muted-foreground/50 mx-auto mb-4 h-12 w-12" />
<p className="text-muted-foreground">No se encontraron cambios.</p>
<History className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">
{historial.length === 0
? 'No hay cambios registrados aún'
: 'No hay cambios con los filtros seleccionados'
}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-8">
{sortedDates.map((dateKey) => (
{sortedDates.map((dateKey) => {
const cambios = groupedHistorial[dateKey];
const date = new Date(dateKey);
const isToday = format(new Date(), 'yyyy-MM-dd') === dateKey;
const isYesterday = format(new Date(Date.now() - 86400000), 'yyyy-MM-dd') === dateKey;
return (
<div key={dateKey}>
<div className="mb-4 flex items-center gap-3">
<Calendar className="text-muted-foreground h-4 w-4" />
<h3 className="text-foreground font-semibold">
{format(parseISO(dateKey), "EEEE, d 'de' MMMM", {
locale: es,
})}
{/* Date header */}
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-muted">
<Calendar className="w-4 h-4 text-muted-foreground" />
</div>
<div>
<h3 className="font-semibold text-foreground">
{isToday ? 'Hoy' : isYesterday ? 'Ayer' : format(date, "EEEE, d 'de' MMMM", { locale: es })}
</h3>
<p className="text-xs text-muted-foreground">
{cambios.length} {cambios.length === 1 ? 'cambio' : 'cambios'}
</p>
</div>
</div>
<div className="border-border ml-4 space-y-4 border-l-2 pl-6">
{groupedHistorial[dateKey].map((cambio) => {
const config = tipoConfig[cambio.tipo] || tipoConfig.datos
const Icon = config.icon
{/* Timeline */}
<div className="ml-4 border-l-2 border-border pl-6 space-y-4">
{cambios.map((cambio) => {
const config = tipoConfig[cambio.tipo];
const Icon = config.icon;
return (
<div key={cambio.id} className="relative">
<div
className={cn(
'border-background absolute -left-[31px] h-4 w-4 rounded-full border-2',
`bg-current ${config.color}`,
)}
/>
{/* Timeline dot */}
<div className={cn(
"absolute -left-[31px] w-4 h-4 rounded-full border-2 border-background",
`bg-current ${config.color}`
)} />
<Card className="card-interactive">
<CardContent className="py-4">
<div className="flex items-start gap-4">
<div
className={cn(
'bg-muted rounded-lg p-2',
config.color,
)}
>
<Icon className="h-4 w-4" />
<div className={cn(
"p-2 rounded-lg bg-muted flex-shrink-0",
config.color
)}>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1">
<div className="flex justify-between">
<p className="font-medium">
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-medium text-foreground">
{cambio.descripcion}
</p>
{/* BOTÓN PARA VER CAMBIOS */}
<Button
variant="ghost"
size="sm"
className="gap-2 text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => openCompareModal(cambio)}
>
<Eye className="h-4 w-4" />
Ver cambios
</Button>
<span className="text-muted-foreground text-xs">
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{config.label}
</Badge>
{cambio.detalles?.campo && (
<span className="text-xs text-muted-foreground">
Campo: {cambio.detalles.campo}
</span>
)}
</div>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{format(cambio.fecha, 'HH:mm')}
</span>
</div>
<div className="mt-2 flex items-center gap-2">
<Badge
variant="outline"
className="text-[10px]"
>
{config.label}
</Badge>
<span className="text-muted-foreground text-xs italic">
por {cambio.usuario}
<div className="flex items-center gap-2 mt-3 text-xs text-muted-foreground">
<User className="w-3 h-3" />
<span>{cambio.usuario}</span>
<span className="text-muted-foreground/50"></span>
<span>
{formatDistanceToNow(cambio.fecha, { addSuffix: true, locale: es })}
</span>
</div>
</div>
@@ -295,62 +183,14 @@ export function HistorialTab() {
</CardContent>
</Card>
</div>
)
);
})}
</div>
</div>
))}
);
})}
</div>
)}
{/* MODAL DE COMPARACIÓN */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col overflow-hidden">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2 text-xl">
<History className="h-5 w-5 text-blue-500" />
Comparación de cambios
</DialogTitle>
{/* ... info de usuario y fecha */}
</DialogHeader>
<div className="custom-scrollbar mt-4 flex-1 overflow-y-auto pr-2">
<div className="grid h-full grid-cols-2 gap-6">
{/* Lado Antes */}
<div className="flex flex-col space-y-3">
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white pb-2">
<div className="h-2 w-2 rounded-full bg-red-400" />
<span className="text-xs font-bold text-slate-500 uppercase">
Versión Anterior
</span>
</div>
<div className="flex-1 rounded-xl border border-red-100 bg-red-50/30 p-4">
<RenderValue
value={selectedChange?.detalles.valor_anterior}
/>
</div>
</div>
{/* Lado Después */}
<div className="flex flex-col space-y-3">
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white pb-2">
<div className="h-2 w-2 rounded-full bg-emerald-400" />
<span className="text-xs font-bold text-slate-500 uppercase">
Nueva Versión
</span>
</div>
<div className="flex-1 rounded-xl border border-emerald-100 bg-emerald-50/40 p-4">
<RenderValue value={selectedChange?.detalles.valor_nuevo} />
</div>
</div>
</div>
</div>
<div className="mt-4 flex flex-shrink-0 items-center justify-center gap-2 rounded-lg border border-slate-100 bg-slate-50 p-3 text-xs text-slate-500">
Campo modificado:{' '}
<Badge variant="secondary">{selectedChange?.detalles.campo}</Badge>
</div>
</DialogContent>
</Dialog>
</div>
)
);
}

View File

@@ -1,408 +0,0 @@
import { useParams, useRouterState } from '@tanstack/react-router'
import {
Sparkles,
Send,
Target,
UserCheck,
Lightbulb,
FileText,
GraduationCap,
BookOpen,
Check,
X,
} from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react'
import type { IAMessage, IASugerencia } from '@/types/asignatura'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { useSubject } from '@/data'
import { cn } from '@/lib/utils'
// Tipos importados de tu archivo de asignatura
const PRESETS = [
{
id: 'mejorar-objetivo',
label: 'Mejorar objetivo',
icon: Target,
prompt: 'Mejora la redacción del objetivo de esta asignatura...',
},
{
id: 'contenido-tematico',
label: 'Sugerir contenido',
icon: BookOpen,
prompt: 'Genera un desglose de temas para esta asignatura...',
},
{
id: 'actividades',
label: 'Actividades de aprendizaje',
icon: GraduationCap,
prompt: 'Sugiere actividades prácticas para los temas seleccionados...',
},
{
id: 'bibliografia',
label: 'Actualizar bibliografía',
icon: FileText,
prompt: 'Recomienda bibliografía reciente para esta asignatura...',
},
]
interface SelectedField {
key: string
label: string
value: string
}
interface IAAsignaturaTabProps {
asignatura: Record<string, any>
messages: Array<IAMessage>
onSendMessage: (message: string, campoId?: string) => void
onAcceptSuggestion: (sugerencia: IASugerencia) => void
onRejectSuggestion: (messageId: string) => void
}
export function IAAsignaturaTab({
messages,
onSendMessage,
onAcceptSuggestion,
onRejectSuggestion,
}: IAAsignaturaTabProps) {
const routerState = useRouterState()
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: datosGenerales, isLoading: loadingAsig } =
useSubject(asignaturaId)
// ESTADOS PRINCIPALES (Igual que en Planes)
const [input, setInput] = useState('')
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
// 1. Transformar datos de la asignatura para el menú
const availableFields = useMemo(() => {
if (!datosGenerales?.datos) return []
const estructuraProps =
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
return Object.keys(datosGenerales.datos).map((key) => {
const estructuraCampo = estructuraProps[key]
const labelAmigable =
estructuraCampo?.title ||
key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
return {
key,
label: labelAmigable,
value: String(datosGenerales.datos[key] || ''),
}
})
}, [datosGenerales])
// 2. Manejar el estado inicial si viene de "Datos de Asignatura" (Prefill)
useEffect(() => {
const state = routerState.location.state as any
if (state?.prefillCampo && availableFields.length > 0) {
console.log(state?.prefillCampo)
console.log(availableFields)
const field = availableFields.find((f) => f.key === state.prefillCampo)
if (field && !selectedFields.find((sf) => sf.key === field.key)) {
setSelectedFields([field])
// Sincronizamos el texto inicial con el campo pre-seleccionado
setInput(`Mejora el campo ${field.label}: `)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [availableFields])
// Scroll automático
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages, isLoading])
// 3. Lógica para el disparador ":"
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value
setInput(val)
setShowSuggestions(val.endsWith(':'))
}
const toggleField = (field: SelectedField) => {
setSelectedFields((prev) => {
const isSelected = prev.find((f) => f.key === field.key)
// 1. Si ya está seleccionado, lo quitamos (Toggle OFF)
if (isSelected) {
return prev.filter((f) => f.key !== field.key)
}
// 2. Si no está, lo agregamos a la lista (Toggle ON)
const newSelected = [...prev, field]
// 3. Actualizamos el texto del input para reflejar los títulos (labels)
setInput((prevText) => {
// Separamos lo que el usuario escribió antes del disparador ":"
// y lo que viene después (posibles keys/labels previos)
const parts = prevText.split(':')
const beforeColon = parts[0]
// Creamos un string con los labels de todos los campos seleccionados
const labelsPath = newSelected.map((f) => f.label).join(', ')
return `${beforeColon.trim()}: ${labelsPath} `
})
return newSelected
})
// Opcional: mantener abierto si quieres que el usuario elija varios seguidos
// setShowSuggestions(false)
}
const buildPrompt = (userInput: string) => {
if (selectedFields.length === 0) return userInput
const fieldsText = selectedFields
.map((f) => `- ${f.label}: ${f.value || '(vacio)'}`)
.join('\n')
return `${userInput}\n\nCampos a analizar:\n${fieldsText}`.trim()
}
const handleSend = async (promptOverride?: string) => {
const rawText = promptOverride || input
if (!rawText.trim() && selectedFields.length === 0) return
const finalPrompt = buildPrompt(rawText)
setIsLoading(true)
// Llamamos a la función que viene por props
onSendMessage(finalPrompt, selectedFields[0]?.key)
setInput('')
setSelectedFields([])
// Simular carga local para el feedback visual
setTimeout(() => setIsLoading(false), 1200)
}
return (
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
{/* PANEL DE CHAT PRINCIPAL */}
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
{/* Barra superior */}
<div className="shrink-0 border-b bg-white p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
IA de Asignatura
</span>
</div>
</div>
{/* CONTENIDO DEL CHAT */}
<div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6">
{messages?.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`}
>
<Avatar
className={`h-8 w-8 shrink-0 border ${msg.role === 'assistant' ? 'bg-teal-50' : 'bg-slate-200'}`}
>
<AvatarFallback className="text-[10px]">
{msg.role === 'assistant' ? (
<Sparkles size={14} className="text-teal-600" />
) : (
<UserCheck size={14} />
)}
</AvatarFallback>
</Avatar>
<div
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}
>
<div
className={cn(
'rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm',
msg.role === 'user'
? 'rounded-tr-none bg-teal-600 text-white'
: 'rounded-tl-none border bg-white text-slate-700',
)}
>
{msg.content}
</div>
{/* Renderizado de Sugerencias (Homologado con lógica de Asignatura) */}
{msg.sugerencia && !msg.sugerencia.aceptada && (
<div className="animate-in fade-in slide-in-from-top-1 mt-3 w-full">
<div className="rounded-xl border border-teal-100 bg-white p-4 shadow-md">
<p className="mb-2 text-[10px] font-bold text-slate-400 uppercase">
Propuesta para: {msg.sugerencia.campoNombre}
</p>
<div className="mb-4 max-h-40 overflow-y-auto rounded-lg bg-slate-50 p-3 text-xs text-slate-600 italic">
{msg.sugerencia.valorSugerido}
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() =>
onAcceptSuggestion(msg.sugerencia!)
}
className="h-8 bg-teal-600 text-xs hover:bg-teal-700"
>
<Check size={14} className="mr-1" /> Aplicar
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onRejectSuggestion(msg.id)}
className="h-8 text-xs"
>
<X size={14} className="mr-1" /> Descartar
</Button>
</div>
</div>
</div>
)}
{msg.sugerencia?.aceptada && (
<Badge className="mt-2 border-teal-200 bg-teal-100 text-teal-700 hover:bg-teal-100">
<Check className="mr-1 h-3 w-3" /> Sugerencia aplicada
</Badge>
)}
</div>
</div>
))}
{isLoading && (
<div className="flex gap-2 p-4">
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400" />
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.2s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.4s]" />
</div>
)}
</div>
</ScrollArea>
</div>
{/* INPUT FIJO AL FONDO */}
<div className="shrink-0 border-t bg-white p-4">
<div className="relative mx-auto max-w-4xl">
{/* MENÚ DE SUGERENCIAS FLOTANTE */}
{showSuggestions && (
<div className="animate-in slide-in-from-bottom-2 absolute bottom-full z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
<div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold tracking-wider text-slate-500 uppercase">
Seleccionar campo de asignatura
</div>
<div className="max-h-64 overflow-y-auto p-1">
{availableFields.map((field) => (
<button
key={field.key}
onClick={() => toggleField(field)}
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50"
>
<span className="text-slate-700 group-hover:text-teal-700">
{field.label}
</span>
{selectedFields.find((f) => f.key === field.key) && (
<Check size={14} className="text-teal-600" />
)}
</button>
))}
</div>
</div>
)}
{/* CONTENEDOR DEL INPUT */}
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
{/* Visualización de Tags */}
{selectedFields.length > 0 && (
<div className="flex flex-wrap gap-2 px-2 pt-1">
{selectedFields.map((field) => (
<div
key={field.key}
className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-100 px-2 py-0.5 text-[11px] font-semibold text-teal-800"
>
<span className="opacity-70">Campo:</span> {field.label}
<button
onClick={() => toggleField(field)}
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200"
>
<X size={10} />
</button>
</div>
))}
</div>
)}
<div className="flex items-end gap-2">
<Textarea
value={input}
onChange={handleInputChange}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
placeholder={
selectedFields.length > 0
? 'Instrucciones para los campos seleccionados...'
: 'Escribe tu solicitud o ":" para campos...'
}
className="max-h-[120px] min-h-[40px] flex-1 resize-none border-none bg-transparent py-2 text-sm shadow-none focus-visible:ring-0"
/>
<Button
onClick={() => handleSend()}
disabled={
(!input.trim() && selectedFields.length === 0) || isLoading
}
size="icon"
className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700"
>
<Send size={16} className="text-white" />
</Button>
</div>
</div>
</div>
</div>
</div>
{/* PANEL LATERAL (ACCIONES RÁPIDAS) */}
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
</h4>
<div className="space-y-2">
{PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => handleSend(preset.prompt)}
className="group flex w-full items-center gap-3 rounded-xl border bg-white p-3 text-left text-sm shadow-sm transition-all hover:border-teal-500 hover:bg-teal-50"
>
<div className="rounded-lg bg-slate-100 p-2 text-slate-500 group-hover:bg-teal-100 group-hover:text-teal-600">
<preset.icon size={16} />
</div>
<span className="leading-tight font-medium text-slate-700">
{preset.label}
</span>
</button>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,357 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Sparkles, Bot, User, Check, X, RefreshCw, Lightbulb, Wand2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import type { IAMessage, IASugerencia, CampoEstructura } from '@/types/materia';
import { cn } from '@/lib/utils';
//import { toast } from 'sonner';
interface IAMateriaTabProps {
campos: CampoEstructura[];
datosGenerales: Record<string, any>;
messages: IAMessage[];
onSendMessage: (message: string, campoId?: string) => void;
onAcceptSuggestion: (sugerencia: IASugerencia) => void;
onRejectSuggestion: (messageId: string) => void;
}
const quickActions = [
{ id: 'mejorar-objetivos', label: 'Mejorar objetivos', icon: Wand2, prompt: 'Mejora el :objetivo_general para que sea más específico y medible' },
{ id: 'generar-contenido', label: 'Generar contenido temático', icon: Lightbulb, prompt: 'Sugiere un contenido temático completo basado en los objetivos y competencias' },
{ id: 'alinear-perfil', label: 'Alinear con perfil de egreso', icon: RefreshCw, prompt: 'Revisa las :competencias y alinéalas con el perfil de egreso del plan' },
{ id: 'ajustar-biblio', label: 'Recomendar bibliografía', icon: Sparkles, prompt: 'Recomienda bibliografía actualizada basándote en el contenido temático' },
];
export function IAMateriaTab({ campos, datosGenerales, messages, onSendMessage, onAcceptSuggestion, onRejectSuggestion }: IAMateriaTabProps) {
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showFieldSelector, setShowFieldSelector] = useState(false);
const [fieldSelectorPosition, setFieldSelectorPosition] = useState({ top: 0, left: 0 });
const [cursorPosition, setCursorPosition] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const pos = e.target.selectionStart;
setInput(value);
setCursorPosition(pos);
// Check for : character to trigger field selector
const lastChar = value.charAt(pos - 1);
if (lastChar === ':') {
const rect = textareaRef.current?.getBoundingClientRect();
if (rect) {
setFieldSelectorPosition({ top: rect.bottom + 8, left: rect.left });
setShowFieldSelector(true);
}
} else if (showFieldSelector && (lastChar === ' ' || !value.includes(':'))) {
setShowFieldSelector(false);
}
};
const insertFieldMention = (campoId: string) => {
const beforeCursor = input.slice(0, cursorPosition);
const afterCursor = input.slice(cursorPosition);
const lastColonIndex = beforeCursor.lastIndexOf(':');
const newInput = beforeCursor.slice(0, lastColonIndex) + `:${campoId}` + afterCursor;
setInput(newInput);
setShowFieldSelector(false);
textareaRef.current?.focus();
};
const handleSend = async () => {
if (!input.trim() || isLoading) return;
// Extract field mention if any
const fieldMatch = input.match(/:(\w+)/);
const campoId = fieldMatch ? fieldMatch[1] : undefined;
setIsLoading(true);
onSendMessage(input, campoId);
setInput('');
// Simulate AI response delay
setTimeout(() => {
setIsLoading(false);
}, 1500);
};
const handleQuickAction = (prompt: string) => {
setInput(prompt);
textareaRef.current?.focus();
};
const renderMessageContent = (content: string) => {
// Render field mentions as styled badges
return content.split(/(:[\w_]+)/g).map((part, i) => {
if (part.startsWith(':')) {
const campo = campos.find(c => c.id === part.slice(1));
return (
<span key={i} className="field-mention mx-0.5">
{campo?.nombre || part}
</span>
);
}
return part;
});
};
return (
<div className="space-y-6 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
<Sparkles className="w-6 h-6 text-accent" />
IA de la materia
</h2>
<p className="text-sm text-muted-foreground mt-1">
Usa <kbd className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">:</kbd> para mencionar campos específicos
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Chat area */}
<Card className="lg:col-span-2 card-elevated flex flex-col h-[600px]">
<CardHeader className="pb-2 border-b">
<CardTitle className="text-sm font-medium text-muted-foreground">
Conversación
</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-0">
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
<div className="space-y-4">
{messages.length === 0 ? (
<div className="text-center py-12">
<Bot className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">
Inicia una conversación para mejorar tu materia con IA
</p>
</div>
) : (
messages.map((message) => (
<div key={message.id} className={cn(
"flex gap-3",
message.role === 'user' ? "justify-end" : "justify-start"
)}>
{message.role === 'assistant' && (
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4 text-accent" />
</div>
)}
<div className={cn(
"max-w-[80%] rounded-lg px-4 py-3",
message.role === 'user'
? "bg-primary text-primary-foreground"
: "bg-muted"
)}>
<p className="text-sm whitespace-pre-wrap">
{renderMessageContent(message.content)}
</p>
{message.sugerencia && !message.sugerencia.aceptada && (
<div className="mt-3 p-3 bg-background/80 rounded-md border">
<p className="text-xs font-medium text-muted-foreground mb-2">
Sugerencia para: {message.sugerencia.campoNombre}
</p>
<div className="text-sm text-foreground bg-accent/10 p-2 rounded mb-3 max-h-32 overflow-y-auto">
{message.sugerencia.valorSugerido}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => onAcceptSuggestion(message.sugerencia!)}
className="bg-success hover:bg-success/90 text-success-foreground"
>
<Check className="w-3 h-3 mr-1" />
Aplicar
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onRejectSuggestion(message.id)}
>
<X className="w-3 h-3 mr-1" />
Rechazar
</Button>
</div>
</div>
)}
{message.sugerencia?.aceptada && (
<Badge className="mt-2 badge-library">
<Check className="w-3 h-3 mr-1" />
Sugerencia aplicada
</Badge>
)}
</div>
{message.role === 'user' && (
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4 text-primary-foreground" />
</div>
)}
</div>
))
)}
{isLoading && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4 text-accent animate-pulse" />
</div>
<div className="bg-muted rounded-lg px-4 py-3">
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-accent rounded-full animate-bounce [animation-delay:-0.3s]" />
<div className="w-2 h-2 bg-accent rounded-full animate-bounce [animation-delay:-0.15s]" />
<div className="w-2 h-2 bg-accent rounded-full animate-bounce" />
</div>
</div>
</div>
)}
</div>
</ScrollArea>
{/* Input area */}
<div className="p-4 border-t">
<div className="relative">
<Textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="Escribe tu mensaje... Usa : para mencionar campos"
className="min-h-[80px] pr-12 resize-none"
disabled={isLoading}
/>
<Button
size="sm"
onClick={handleSend}
disabled={!input.trim() || isLoading}
className="absolute bottom-3 right-3 h-8 w-8 p-0"
>
<Send className="w-4 h-4" />
</Button>
</div>
{/* Field selector popover */}
{showFieldSelector && (
<div className="absolute z-50 mt-1 w-64 bg-popover border rounded-lg shadow-lg">
<Command>
<CommandInput placeholder="Buscar campo..." />
<CommandList>
<CommandEmpty>No se encontró el campo</CommandEmpty>
<CommandGroup heading="Campos disponibles">
{campos.map((campo) => (
<CommandItem
key={campo.id}
value={campo.id}
onSelect={() => insertFieldMention(campo.id)}
className="cursor-pointer"
>
<span className="font-mono text-xs text-accent mr-2">
:{campo.id}
</span>
<span>{campo.nombre}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
</div>
</CardContent>
</Card>
{/* Sidebar with quick actions and fields */}
<div className="space-y-4">
{/* Quick actions */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Acciones rápidas</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{quickActions.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.id}
variant="outline"
className="w-full justify-start text-left h-auto py-3"
onClick={() => handleQuickAction(action.prompt)}
>
<Icon className="w-4 h-4 mr-2 text-accent flex-shrink-0" />
<span className="text-sm">{action.label}</span>
</Button>
);
})}
</CardContent>
</Card>
{/* Available fields */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Campos de la materia</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[280px]">
<div className="space-y-2">
{campos.map((campo) => {
const hasValue = !!datosGenerales[campo.id];
return (
<div
key={campo.id}
className={cn(
"p-2 rounded-md border cursor-pointer transition-colors hover:bg-muted/50",
hasValue ? "border-success/30" : "border-warning/30"
)}
onClick={() => {
setInput(prev => prev + `:${campo.id} `);
textareaRef.current?.focus();
}}
>
<div className="flex items-center justify-between">
<span className="text-xs font-mono text-accent">:{campo.id}</span>
{hasValue ? (
<Badge variant="outline" className="text-xs text-success border-success/30">
Completo
</Badge>
) : (
<Badge variant="outline" className="text-xs text-warning border-warning/30">
Vacío
</Badge>
)}
</div>
<p className="text-sm text-foreground mt-1">{campo.nombre}</p>
</div>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,396 @@
import { useCallback, useState } from 'react'
import { Link } from '@tanstack/react-router'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import {
ArrowLeft,
GraduationCap,
Edit2, Save,
Pencil
} from 'lucide-react'
import { ContenidoTematico } from './ContenidoTematico'
import { BibliographyItem } from './BibliographyItem'
import { IAMateriaTab } from './IAMateriaTab'
import type {
CampoEstructura,
IAMessage,
IASugerencia,
UnidadTematica,
} from '@/types/materia';
import {
mockMateria,
mockEstructura,
mockDocumentoSep,
mockHistorial
} from '@/data/mockMateriaData';
import { DocumentoSEPTab } from './DocumentoSEPTab'
import { HistorialTab } from './HistorialTab'
export interface BibliografiaEntry {
id: string;
tipo: 'BASICA' | 'COMPLEMENTARIA';
cita: string;
fuenteBibliotecaId?: string;
fuenteBiblioteca?: any;
}
export interface BibliografiaTabProps {
bibliografia: BibliografiaEntry[];
onSave: (bibliografia: BibliografiaEntry[]) => void;
isSaving: boolean;
}
export default function MateriaDetailPage() {
// 1. Asegúrate de tener estos estados en tu componente principal
const [messages, setMessages] = useState<IAMessage[]>([]);
const [datosGenerales, setDatosGenerales] = useState({});
const [campos, setCampos] = useState<CampoEstructura[]>([]);
// 2. Funciones de manejo para la IA
const handleSendMessage = (text: string, campoId?: string) => {
const newMessage: IAMessage = {
id: Date.now().toString(),
role: 'user',
content: text,
timestamp: new Date(),
campoAfectado: campoId
};
setMessages([...messages, newMessage]);
// Aquí llamarías a tu API de OpenAI/Claude
//toast.info("Enviando consulta a la IA...");
};
const handleAcceptSuggestion = (sugerencia: IASugerencia) => {
// Lógica para actualizar el valor del campo en tu estado de datosGenerales
//toast.success(`Sugerencia aplicada a ${sugerencia.campoNombre}`);
};
// Dentro de tu componente principal (donde están los Tabs)
const [bibliografia, setBibliografia] = useState<BibliografiaEntry[]>([
{
id: '1',
tipo: 'BASICA',
cita: 'Russell, S., & Norvig, P. (2020). Artificial Intelligence: A Modern Approach. Pearson.'
}
]);
const [isSaving, setIsSaving] = useState(false);
const handleSaveBibliografia = (data: BibliografiaEntry[]) => {
setIsSaving(true);
// Aquí iría tu llamada a la API
setBibliografia(data);
// Simulamos un guardado
setTimeout(() => {
setIsSaving(false);
//toast.success("Cambios guardados");
}, 1000);
};
const [isRegenerating, setIsRegenerating] = useState(false);
const handleRegenerateDocument = useCallback(() => {
setIsRegenerating(true);
setTimeout(() => {
setIsRegenerating(false);
}, 2000);
}, []);
return (
<div className="w-full">
{/* ================= HEADER ================= */}
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<div className="max-w-7xl mx-auto px-6 py-10">
<Link
to="/planes"
className="flex items-center gap-2 text-sm text-blue-200 hover:text-white mb-4"
>
<ArrowLeft className="w-4 h-4" />
Volver al plan
</Link>
<div className="flex items-start justify-between gap-6">
<div className="space-y-3">
<Badge className="bg-blue-900/50 border border-blue-700">
IA-401
</Badge>
<h1 className="text-3xl font-bold">
Inteligencia Artificial Aplicada
</h1>
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
<span className="flex items-center gap-1">
<GraduationCap className="w-4 h-4" />
Ingeniería en Sistemas Computacionales
</span>
<span>Facultad de Ingeniería</span>
</div>
<p className="text-sm text-blue-300">
Pertenece al plan:{' '}
<span className="underline cursor-pointer">
Licenciatura en Ingeniería en Sistemas Computacionales 2024
</span>
</p>
</div>
<div className="flex flex-col gap-2 items-end">
<Badge variant="secondary">8 créditos</Badge>
<Badge variant="secondary">7° semestre</Badge>
<Badge variant="secondary">Sistemas Inteligentes</Badge>
</div>
</div>
</div>
</section>
{/* ================= TABS ================= */}
<section className="bg-white border-b">
<div className="max-w-7xl mx-auto px-6">
<Tabs defaultValue="datos">
<TabsList className="h-auto bg-transparent p-0 gap-6">
<TabsTrigger value="datos">Datos generales</TabsTrigger>
<TabsTrigger value="contenido">Contenido temático</TabsTrigger>
<TabsTrigger value="bibliografia">Bibliografía</TabsTrigger>
<TabsTrigger value="ia">IA de la materia</TabsTrigger>
<TabsTrigger value="sep">Documento SEP</TabsTrigger>
<TabsTrigger value="historial">Historial</TabsTrigger>
</TabsList>
<Separator className="mt-2" />
{/* ================= TAB: DATOS GENERALES ================= */}
<TabsContent value="datos">
<DatosGenerales />
</TabsContent>
<TabsContent value="contenido">
<ContenidoTematico></ContenidoTematico>
</TabsContent>
<TabsContent value="bibliografia">
<BibliographyItem
bibliografia={bibliografia}
onSave={handleSaveBibliografia}
isSaving={isSaving}
/>
</TabsContent>
<TabsContent value="ia">
<IAMateriaTab
campos={campos}
datosGenerales={datosGenerales}
messages={messages}
onSendMessage={handleSendMessage}
onAcceptSuggestion={handleAcceptSuggestion}
onRejectSuggestion={(id) => console.log("Rechazada") /*toast.error("Sugerencia rechazada")*/}
/>
</TabsContent>
<TabsContent value="sep">
<DocumentoSEPTab
documento={mockDocumentoSep}
materia={mockMateria}
estructura={mockEstructura}
datosGenerales={datosGenerales}
onRegenerate={handleRegenerateDocument}
isRegenerating={isRegenerating}
/>
</TabsContent>
<TabsContent value="historial">
<HistorialTab historial={mockHistorial} />
</TabsContent>
</Tabs>
</div>
</section>
</div>
)
}
/* ================= TAB CONTENT ================= */
function DatosGenerales() {
return (
<div className="max-w-7xl mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500">
{/* Encabezado de la Sección */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b pb-6">
<div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900">Datos Generales</h2>
<p className="text-slate-500 mt-1">
Información oficial estructurada bajo los lineamientos de la SEP.
</p>
</div>
<div className="flex gap-3">
<Button variant="outline" size="sm" className="gap-2">
<Edit2 className="w-4 h-4" /> Editar borrador
</Button>
<Button size="sm" className="gap-2 bg-blue-600 hover:bg-blue-700">
<Save className="w-4 h-4" /> Guardar cambios
</Button>
</div>
</div>
{/* Grid de Información */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Columna Principal (Más ancha) */}
<div className="md:col-span-2 space-y-6">
<div className="md:col-span-2 space-y-6">
<InfoCard
title="Competencias a Desarrollar"
subtitle="Competencias profesionales que se desarrollarán"
isList={true}
initialContent={`• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes\n• Evaluar y optimizar modelos de IA considerando métricas`}
/>
<InfoCard
title="Objetivo General"
initialContent="Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos."
/>
</div>
<div className="space-y-6">
<InfoCard
title="Justificación"
initialContent="La inteligencia artificial es una de las tecnologías más disruptivas..."
/>
</div>
</div>
{/* Columna Lateral (Información Secundaria) */}
<div className="space-y-6">
<div className="space-y-6">
{/* Tarjeta de Requisitos */}
<InfoCard
title="Requisitos y Seriación"
type="requirements"
initialContent={[
{ type: "Pre-requisito", code: "PA-301", name: "Programación Avanzada" },
{ type: "Co-requisito", code: "MAT-201", name: "Matemáticas Discretas" }
]}
/>
{/* Tarjeta de Evaluación */}
<InfoCard
title="Sistema de Evaluación"
type="evaluation"
initialContent={[
{ label: "Exámenes parciales", value: "30%" },
{ label: "Proyecto integrador", value: "35%" },
{ label: "Prácticas de laboratorio", value: "20%" },
{ label: "Participación", value: "15%" },
]}
/>
</div>
</div>
</div>
</div>
)
}
interface InfoCardProps {
title: string,
subtitle?: string
isList?:boolean
initialContent: any // Puede ser string o array de objetos
type?: 'text' | 'list' | 'requirements' | 'evaluation'
}
function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false)
const [data, setData] = useState(initialContent)
// Estado temporal para el área de texto (siempre editamos como texto por simplicidad)
const [tempText, setTempText] = useState(
type === 'text' || type === 'list'
? initialContent
: JSON.stringify(initialContent, null, 2) // O un formato legible
)
const handleSave = () => {
// Aquí podrías parsear el texto de vuelta si es necesario
setData(tempText)
setIsEditing(false)
}
return (
<Card className="transition-all hover:border-slate-300">
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0">
<CardTitle className="text-sm font-bold text-slate-700">{title}</CardTitle>
{!isEditing && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => setIsEditing(true)}>
<Pencil className="h-3 w-3" />
</Button>
)}
</CardHeader>
<CardContent>
{isEditing ? (
<div className="space-y-3">
<Textarea
value={tempText}
onChange={(e) => setTempText(e.target.value)}
className="text-xs min-h-[100px]"
/>
<div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)}>Cancelar</Button>
<Button size="sm" className="bg-[#00a878]" onClick={handleSave}>Guardar</Button>
</div>
</div>
) : (
<div className="text-sm">
{type === 'requirements' && <RequirementsView items={data} />}
{type === 'evaluation' && <EvaluationView items={data} />}
{type === 'text' && <p className="text-slate-600">{data}</p>}
</div>
)}
</CardContent>
</Card>
)
}
// Vista de Requisitos
function RequirementsView({ items }: { items: any[] }) {
return (
<div className="space-y-3">
{items.map((req, i) => (
<div key={i} className="p-3 bg-slate-50 rounded-lg border border-slate-100">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-tight">{req.type}</p>
<p className="text-sm font-medium text-slate-700">{req.code} {req.name}</p>
</div>
))}
</div>
)
}
// Vista de Evaluación
function EvaluationView({ items }: { items: any[] }) {
return (
<div className="space-y-2">
{items.map((item, i) => (
<div key={i} className="flex justify-between text-sm border-b border-slate-50 pb-1.5 italic">
<span className="text-slate-500">{item.label}</span>
<span className="font-bold text-blue-600">{item.value}</span>
</div>
))}
</div>
)
}
function EmptyTab({ title }: { title: string }) {
return (
<div className="py-16 text-center text-muted-foreground">
{title} (pendiente)
</div>
)
}

View File

@@ -0,0 +1,155 @@
import type {
NewSubjectWizardState,
TipoAsignatura,
} from '@/features/asignaturas/new/types'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
ESTRUCTURAS_SEP,
TIPOS_MATERIA,
} from '@/features/asignaturas/new/catalogs'
export function PasoBasicosForm({
wizard,
onChange,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1 sm:col-span-2">
<Label htmlFor="nombre">Nombre de la asignatura</Label>
<Input
id="nombre"
placeholder="Ej. Matemáticas Discretas"
value={wizard.datosBasicos.nombre}
onChange={(e) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, nombre: e.target.value },
}))
}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="clave">Clave (Opcional)</Label>
<Input
id="clave"
placeholder="Ej. MAT-101"
value={wizard.datosBasicos.clave || ''}
onChange={(e) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, clave: e.target.value },
}))
}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="tipo">Tipo</Label>
<Select
value={wizard.datosBasicos.tipo}
onValueChange={(val) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, tipo: val as TipoAsignatura },
}))
}
>
<SelectTrigger
id="tipo"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIPOS_MATERIA.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="creditos">Créditos</Label>
<Input
id="creditos"
type="number"
min={0}
value={wizard.datosBasicos.creditos}
onChange={(e) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
creditos: Number(e.target.value || 0),
},
}))
}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="horas">Horas / Semana</Label>
<Input
id="horas"
type="number"
min={0}
value={wizard.datosBasicos.horasSemana || 0}
onChange={(e) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
horasSemana: Number(e.target.value || 0),
},
}))
}
/>
</div>
<div className="grid gap-1 sm:col-span-2">
<Label htmlFor="estructura">Estructura de la asignatura</Label>
<Select
value={wizard.datosBasicos.estructuraId}
onValueChange={(val) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, estructuraId: val },
}))
}
>
<SelectTrigger
id="estructura"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue placeholder="Selecciona plantilla..." />
</SelectTrigger>
<SelectContent>
{ESTRUCTURAS_SEP.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
</p>
</div>
</div>
)
}

View File

@@ -1,362 +0,0 @@
import { useEffect, useState } from 'react'
import PasoSugerenciasForm from './PasoSugerenciasForm'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { Database } from '@/types/supabase'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useSubjectEstructuras } from '@/data'
import { TIPOS_MATERIA } from '@/features/asignaturas/nueva/catalogs'
import { cn } from '@/lib/utils'
export function PasoBasicosForm({
wizard,
onChange,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
const { data: estructuras } = useSubjectEstructuras()
const [creditosInput, setCreditosInput] = useState<string>(() => {
const c = Number(wizard.datosBasicos.creditos ?? 0)
let newC = c
console.log('antes', newC)
if (Number.isFinite(c) && c > 999) {
newC = 999
}
console.log('desp', newC)
return newC > 0 ? newC.toFixed(2) : ''
})
const [creditosFocused, setCreditosFocused] = useState(false)
useEffect(() => {
if (creditosFocused) return
const c = Number(wizard.datosBasicos.creditos ?? 0)
let newC = c
if (Number.isFinite(c) && c > 999) {
newC = 999
}
setCreditosInput(newC > 0 ? newC.toFixed(2) : '')
}, [wizard.datosBasicos.creditos, creditosFocused])
if (wizard.tipoOrigen !== 'IA_MULTIPLE') {
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1 sm:col-span-2">
<Label htmlFor="nombre">Nombre de la asignatura</Label>
<Input
id="nombre"
placeholder="Ej. Matemáticas Discretas"
maxLength={200}
value={wizard.datosBasicos.nombre}
onChange={(e) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: { ...w.datosBasicos, nombre: e.target.value },
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="codigo">
Código
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Input
id="codigo"
placeholder="Ej. MAT-101"
maxLength={200}
value={wizard.datosBasicos.codigo || ''}
onChange={(e) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: { ...w.datosBasicos, codigo: e.target.value },
}),
)
}
className="placeholder:text-muted-foreground/70 placeholder:italicplaceholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="tipo">Tipo</Label>
<Select
value={(wizard.datosBasicos.tipo ?? '') as string}
onValueChange={(value: string) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
tipo: value as NewSubjectWizardState['datosBasicos']['tipo'],
},
}),
)
}
>
<SelectTrigger
id="tipo"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.tipo
? 'text-muted-foreground font-normal italic opacity-70'
: 'font-medium not-italic',
)}
>
<SelectValue placeholder="Ej. Obligatoria" />
</SelectTrigger>
<SelectContent>
{TIPOS_MATERIA.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="creditos">Créditos</Label>
<Input
id="creditos"
type="text"
inputMode="decimal"
maxLength={6}
pattern="^\\d*(?:[.,]\\d{0,2})?$"
value={creditosInput}
onKeyDown={(e) => {
if (['-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onFocus={() => setCreditosFocused(true)}
onBlur={() => {
setCreditosFocused(false)
const raw = creditosInput.trim()
if (!raw) {
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: 0 },
}))
return
}
const normalized = raw.replace(',', '.')
let asNumber = Number.parseFloat(normalized)
if (!Number.isFinite(asNumber) || asNumber <= 0) {
setCreditosInput('')
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: 0 },
}))
return
}
// Cap to 999
if (asNumber > 999) asNumber = 999
const fixed = asNumber.toFixed(2)
setCreditosInput(fixed)
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: Number(fixed) },
}))
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const nextRaw = e.target.value
if (nextRaw === '') {
setCreditosInput('')
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, creditos: 0 },
}))
return
}
if (!/^\d*(?:[.,]\d{0,2})?$/.test(nextRaw)) return
// If typed number exceeds 999, cap it immediately (prevents entering >999)
const asNumberRaw = Number.parseFloat(nextRaw.replace(',', '.'))
if (Number.isFinite(asNumberRaw) && asNumberRaw > 999) {
// show capped value to the user
const cappedStr = '999.00'
setCreditosInput(cappedStr)
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
creditos: 999,
},
}))
return
}
setCreditosInput(nextRaw)
const asNumber = Number.parseFloat(nextRaw.replace(',', '.'))
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
creditos:
Number.isFinite(asNumber) && asNumber > 0 ? asNumber : 0,
},
}))
}}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 4.50"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="estructura">Estructura de la asignatura</Label>
<Select
value={wizard.datosBasicos.estructuraId as string}
onValueChange={(val) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: { ...w.datosBasicos, estructuraId: val },
}),
)
}
>
<SelectTrigger
id="estructura"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue placeholder="Selecciona plantilla..." />
</SelectTrigger>
<SelectContent>
{estructuras?.map(
(
e: Database['public']['Tables']['estructuras_asignatura']['Row'],
) => (
<SelectItem key={e.id} value={e.id}>
{e.nombre}
</SelectItem>
),
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
</p>
</div>
<div className="grid gap-1">
<Label htmlFor="horasAcademicas">
Horas Académicas
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Input
id="horasAcademicas"
type="number"
min={1}
max={999}
step={1}
inputMode="numeric"
pattern="[0-9]*"
value={wizard.datosBasicos.horasAcademicas ?? ''}
onKeyDown={(e) => {
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
horasAcademicas: (() => {
const raw = e.target.value
if (raw === '') return null
const asNumber = Number(raw)
if (Number.isNaN(asNumber)) return null
// Coerce to positive integer (natural numbers without zero)
const n = Math.floor(Math.abs(asNumber))
const capped = Math.min(n >= 1 ? n : 1, 999)
return capped
})(),
},
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 48"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="horasIndependientes">
Horas Independientes
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Input
id="horasIndependientes"
type="number"
min={1}
max={999}
step={1}
inputMode="numeric"
pattern="[0-9]*"
value={wizard.datosBasicos.horasIndependientes ?? ''}
onKeyDown={(e) => {
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
horasIndependientes: (() => {
const raw = e.target.value
if (raw === '') return null
const asNumber = Number(raw)
if (Number.isNaN(asNumber)) return null
// Coerce to positive integer (natural numbers without zero)
const n = Math.floor(Math.abs(asNumber))
const capped = Math.min(n >= 1 ? n : 1, 999)
return capped
})(),
},
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 24"
/>
</div>
</div>
)
}
return <PasoSugerenciasForm wizard={wizard} onChange={onChange} />
}

View File

@@ -1,308 +0,0 @@
import { RefreshCw, Sparkles, X } from 'lucide-react'
import { useState } from 'react'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { Dispatch, SetStateAction } from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { generate_subject_suggestions, usePlan } from '@/data'
import { AIProgressLoader } from '@/features/asignaturas/nueva/AIProgressLoader'
import { cn } from '@/lib/utils'
export default function PasoSugerenciasForm({
wizard,
onChange,
}: {
wizard: NewSubjectWizardState
onChange: Dispatch<SetStateAction<NewSubjectWizardState>>
}) {
const enfoque = wizard.iaMultiple?.enfoque ?? ''
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 5
const isLoading = wizard.iaMultiple?.isLoading ?? false
const [showConservacionTooltip, setShowConservacionTooltip] = useState(false)
const setIaMultiple = (
patch: Partial<NonNullable<NewSubjectWizardState['iaMultiple']>>,
) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
iaMultiple: {
enfoque: w.iaMultiple?.enfoque ?? '',
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
isLoading: w.iaMultiple?.isLoading ?? false,
...patch,
},
}),
)
const { data: plan } = usePlan(wizard.plan_estudio_id)
const toggleAsignatura = (id: string, checked: boolean) => {
onChange((w) => ({
...w,
sugerencias: w.sugerencias.map((s) =>
s.id === id ? { ...s, selected: checked } : s,
),
}))
}
const onGenerarSugerencias = async () => {
const hadNoSugerenciasBefore = wizard.sugerencias.length === 0
const sugerenciasConservadas = wizard.sugerencias.filter((s) => s.selected)
onChange((w) => ({
...w,
errorMessage: null,
sugerencias: sugerenciasConservadas,
iaMultiple: {
enfoque: w.iaMultiple?.enfoque ?? '',
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
isLoading: true,
},
}))
try {
const cantidad = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
if (!Number.isFinite(cantidad) || cantidad <= 0 || cantidad > 15) {
onChange((w) => ({
...w,
errorMessage: 'La cantidad de sugerencias debe ser entre 1 y 15.',
iaMultiple: {
enfoque: w.iaMultiple?.enfoque ?? '',
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
isLoading: false,
},
}))
return
}
const enfoqueTrim = wizard.iaMultiple?.enfoque.trim() ?? ''
const nuevasSugerencias = await generate_subject_suggestions({
plan_estudio_id: wizard.plan_estudio_id,
enfoque: enfoqueTrim ? enfoqueTrim : undefined,
cantidad_de_sugerencias: cantidad,
sugerencias_conservadas: sugerenciasConservadas.map((s) => ({
nombre: s.nombre,
descripcion: s.descripcion,
})),
})
if (hadNoSugerenciasBefore && nuevasSugerencias.length > 0) {
setShowConservacionTooltip(true)
}
onChange(
(w): NewSubjectWizardState => ({
...w,
sugerencias: [...nuevasSugerencias, ...sugerenciasConservadas],
iaMultiple: {
enfoque: w.iaMultiple?.enfoque ?? '',
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
isLoading: false,
},
}),
)
} catch (err) {
const message =
err instanceof Error ? err.message : 'Error generando sugerencias.'
onChange(
(w): NewSubjectWizardState => ({
...w,
errorMessage: message,
iaMultiple: {
enfoque: w.iaMultiple?.enfoque ?? '',
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
isLoading: false,
},
}),
)
}
}
return (
<>
{/* --- BLOQUE SUPERIOR: PARÁMETROS --- */}
<div className="border-border/60 bg-muted/30 mb-4 rounded-xl border p-4">
<div className="mb-3 flex items-center gap-2">
<Sparkles className="text-primary h-4 w-4" />
<span className="text-sm font-semibold">
Parámetros de sugerencia
</span>
</div>
<div className="flex flex-col gap-3">
<div className="w-full">
<Label className="text-muted-foreground mb-1 block text-xs">
Enfoque (opcional)
</Label>
<Textarea
placeholder="Ej. Enfocado en normativa mexicana y tecnología"
value={enfoque}
maxLength={7000}
rows={4}
onChange={(e) => setIaMultiple({ enfoque: e.target.value })}
/>
</div>
</div>
<div className="mt-3 flex w-full flex-col items-end justify-between gap-3 sm:flex-row">
<div className="w-full sm:w-44">
<Label className="text-muted-foreground mb-1 block text-xs">
Cantidad de sugerencias
</Label>
<Input
placeholder="Ej. 5"
value={cantidadDeSugerencias}
type="number"
min={1}
max={15}
step={1}
inputMode="numeric"
onKeyDown={(e) => {
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onChange={(e) => {
const raw = e.target.value
if (raw === '') return
const asNumber = Number(raw)
if (!Number.isFinite(asNumber)) return
const n = Math.floor(Math.abs(asNumber))
const capped = Math.min(n >= 1 ? n : 1, 15)
setIaMultiple({ cantidadDeSugerencias: capped })
}}
/>
</div>
<Button
type="button"
variant="outline"
className="h-9 gap-1.5"
onClick={onGenerarSugerencias}
disabled={isLoading}
>
<RefreshCw className="h-3.5 w-3.5" />
{wizard.sugerencias.length > 0
? 'Generar más sugerencias'
: 'Generar sugerencias'}
</Button>
</div>
</div>
<AIProgressLoader
isLoading={isLoading}
cantidadDeSugerencias={cantidadDeSugerencias}
/>
{/* --- HEADER LISTA --- */}
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-foreground text-base font-semibold">
Asignaturas sugeridas
</h3>
<p className="text-muted-foreground text-xs">
Basadas en el plan{' '}
{plan ? `${plan.nivel} en ${plan.nombre}` : '...'}
</p>
</div>
<Tooltip open={showConservacionTooltip}>
<TooltipTrigger asChild>
<div className="bg-muted text-foreground inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-sm font-semibold">
<span aria-hidden>📌</span>
{wizard.sugerencias.filter((s) => s.selected).length}{' '}
seleccionadas
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8} className="max-w-xs">
<div className="flex items-start gap-2">
<span className="flex-1 text-sm">
Al generar más sugerencias, se conservarán las asignaturas
seleccionadas.
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => setShowConservacionTooltip(false)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</TooltipContent>
</Tooltip>
</div>
{/* --- LISTA DE ASIGNATURAS --- */}
<div className="max-h-100 space-y-1 overflow-y-auto pr-1">
{wizard.sugerencias.map((asignatura) => {
const isSelected = asignatura.selected
return (
<Label
key={asignatura.id}
aria-checked={isSelected}
className={cn(
'border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950',
)}
>
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
toggleAsignatura(asignatura.id, !!checked)
}
className={cn(
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring mt-0.5 h-5 w-5 shrink-0 border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
// isSelected ? '' : 'invisible',
)}
/>
{/* Contenido de la tarjeta */}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground text-sm font-medium">
{asignatura.nombre}
</span>
{/* Badges de Tipo */}
<span
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
asignatura.tipo === 'OBLIGATORIA'
? 'border-blue-200 bg-transparent text-blue-700 dark:border-blue-800 dark:text-blue-300'
: 'border-yellow-200 bg-transparent text-yellow-700 dark:border-yellow-800 dark:text-yellow-300',
)}
>
{asignatura.tipo}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{asignatura.creditos} cred. · {asignatura.horasAcademicas}h
acad. · {asignatura.horasIndependientes}h indep.
</span>
</div>
<p className="text-muted-foreground mt-1 text-sm">
{asignatura.descripcion}
</p>
</div>
</Label>
)
})}
</div>
</>
)
}

View File

@@ -0,0 +1,286 @@
import * as Icons from 'lucide-react'
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
ARCHIVOS_SISTEMA_MOCK,
FACULTADES,
MATERIAS_MOCK,
PLANES_MOCK,
} from '@/features/asignaturas/new/catalogs'
export function PasoConfiguracionPanel({
wizard,
onChange,
onGenerarIA,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
onGenerarIA: () => void
}) {
if (wizard.modoCreacion === 'MANUAL') {
return (
<Card>
<CardHeader>
<CardTitle>Configuración Manual</CardTitle>
<CardDescription>
La asignatura se creará vacía. Podrás editar el contenido detallado
en la siguiente pantalla.
</CardDescription>
</CardHeader>
</Card>
)
}
if (wizard.modoCreacion === 'IA') {
return (
<div className="grid gap-4">
<div className="grid gap-1">
<Label>Descripción del enfoque</Label>
<Textarea
placeholder="Ej. Asignatura teórica-práctica enfocada en patrones de diseño..."
value={wizard.iaConfig?.descripcionEnfoque}
onChange={(e) =>
onChange((w) => ({
...w,
iaConfig: {
...w.iaConfig!,
descripcionEnfoque: e.target.value,
},
}))
}
className="min-h-25"
/>
</div>
<div className="grid gap-1">
<Label>Notas adicionales</Label>
<Textarea
placeholder="Restricciones, bibliografía sugerida, etc."
value={wizard.iaConfig?.notasAdicionales}
onChange={(e) =>
onChange((w) => ({
...w,
iaConfig: { ...w.iaConfig!, notasAdicionales: e.target.value },
}))
}
/>
</div>
<div className="grid gap-2">
<Label>Archivos de contexto (Opcional)</Label>
<div className="flex flex-col gap-2 rounded-md border p-3">
{ARCHIVOS_SISTEMA_MOCK.map((file) => (
<div key={file.id} className="flex items-center gap-2">
<input
type="checkbox"
id={file.id}
checked={wizard.iaConfig?.archivosExistentesIds.includes(
file.id,
)}
onChange={(e) => {
const checked = e.target.checked
onChange((w) => ({
...w,
iaConfig: {
...w.iaConfig!,
archivosExistentesIds: checked
? [
...(w.iaConfig?.archivosExistentesIds || []),
file.id,
]
: w.iaConfig?.archivosExistentesIds.filter(
(id) => id !== file.id,
) || [],
},
}))
}}
/>
<Label htmlFor={file.id} className="font-normal">
{file.name}
</Label>
</div>
))}
</div>
</div>
<div className="flex justify-end">
<Button onClick={onGenerarIA} disabled={wizard.isLoading}>
{wizard.isLoading ? (
<>
<Icons.Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generando...
</>
) : (
<>
<Icons.Sparkles className="mr-2 h-4 w-4" /> Generar Preview
</>
)}
</Button>
</div>
{wizard.resumen.previewAsignatura && (
<Card className="bg-muted/50 border-dashed">
<CardHeader>
<CardTitle className="text-base">Vista previa generada</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
<p>
<strong>Objetivo:</strong>{' '}
{wizard.resumen.previewAsignatura.objetivo}
</p>
<p className="mt-2">
Se detectaron {wizard.resumen.previewAsignatura.unidades}{' '}
unidades temáticas y{' '}
{wizard.resumen.previewAsignatura.bibliografiaCount} fuentes
bibliográficas.
</p>
</CardContent>
</Card>
)}
</div>
)
}
if (wizard.subModoClonado === 'INTERNO') {
return (
<div className="grid gap-4">
<div className="grid gap-2 sm:grid-cols-3">
<div>
<Label>Facultad</Label>
<Select
onValueChange={(val) =>
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, facultadId: val },
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent>
{FACULTADES.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Plan</Label>
<Select
onValueChange={(val) =>
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, planOrigenId: val },
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
{PLANES_MOCK.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Buscar</Label>
<Input placeholder="Nombre..." />
</div>
</div>
<div className="grid max-h-75 gap-2 overflow-y-auto">
{MATERIAS_MOCK.map((m) => (
<div
key={m.id}
onClick={() =>
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id },
}))
}
className={`hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 ${
wizard.clonInterno?.asignaturaOrigenId === m.id
? 'border-primary bg-primary/5 ring-primary ring-1'
: ''
}`}
>
<div>
<div className="font-medium">{m.nombre}</div>
<div className="text-muted-foreground text-xs">
{m.clave} {m.creditos} créditos
</div>
</div>
{wizard.clonInterno?.asignaturaOrigenId === m.id && (
<Icons.CheckCircle2 className="text-primary h-5 w-5" />
)}
</div>
))}
</div>
</div>
)
}
if (wizard.subModoClonado === 'TRADICIONAL') {
return (
<div className="grid gap-4">
<div className="rounded-lg border border-dashed p-8 text-center">
<Icons.Upload className="text-muted-foreground mx-auto mb-4 h-10 w-10" />
<h3 className="mb-1 text-sm font-medium">
Sube el Word de la asignatura
</h3>
<p className="text-muted-foreground mb-4 text-xs">
Arrastra el archivo o haz clic para buscar (.doc, .docx)
</p>
<Input
type="file"
accept=".doc,.docx"
className="mx-auto max-w-xs"
onChange={(e) =>
onChange((w) => ({
...w,
clonTradicional: {
...w.clonTradicional!,
archivoWordAsignaturaId:
e.target.files?.[0]?.name || 'mock_file',
},
}))
}
/>
</div>
{wizard.clonTradicional?.archivoWordAsignaturaId && (
<div className="flex items-center gap-2 rounded-md bg-green-50 p-3 text-sm text-green-700">
<Icons.FileText className="h-4 w-4" />
Archivo cargado listo para procesar.
</div>
)}
</div>
)
}
return null
}

View File

@@ -1,459 +0,0 @@
import * as Icons from 'lucide-react'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { usePlan, usePlanLineas, useSubjectEstructuras } from '@/data'
import {
FACULTADES,
MATERIAS_MOCK,
PLANES_MOCK,
} from '@/features/asignaturas/nueva/catalogs'
export function PasoDetallesPanel({
wizard,
onChange,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
const { data: estructurasAsignatura } = useSubjectEstructuras()
const { data: plan } = usePlan(wizard.plan_estudio_id)
const { data: lineasPlan } = usePlanLineas(wizard.plan_estudio_id)
if (wizard.tipoOrigen === 'MANUAL') {
return (
<Card>
<CardHeader>
<CardTitle>Configuración Manual</CardTitle>
<CardDescription>
La asignatura se creará vacía. Podrás editar el contenido detallado
en la siguiente pantalla.
</CardDescription>
</CardHeader>
</Card>
)
}
if (wizard.tipoOrigen === 'IA_SIMPLE') {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<Label>Descripción del enfoque académico</Label>
<Textarea
placeholder="Describe el enfoque, alcance y público objetivo. Ej.: Teórica-práctica enfocada en patrones de diseño, con proyectos semanales..."
maxLength={7000}
value={wizard.iaConfig?.descripcionEnfoqueAcademico}
onChange={(e) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
iaConfig: {
...w.iaConfig!,
descripcionEnfoqueAcademico: e.target.value,
},
}),
)
}
className="placeholder:text-muted-foreground/70 min-h-25 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<div className="flex flex-col gap-1">
<Label>
Instrucciones adicionales para la IA
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Textarea
placeholder="Opcional: restricciones y preferencias. Ej.: incluye bibliografía en español, evita contenido avanzado, prioriza evaluación por proyectos..."
maxLength={7000}
value={wizard.iaConfig?.instruccionesAdicionalesIA}
onChange={(e) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
iaConfig: {
...w.iaConfig!,
instruccionesAdicionalesIA: e.target.value,
},
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<ReferenciasParaIA
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
onToggleArchivo={(id, checked) =>
onChange((w): NewSubjectWizardState => {
const prev = w.iaConfig?.archivosReferencia || []
const next = checked
? [...prev, id]
: prev.filter((a) => a !== id)
return {
...w,
iaConfig: {
...w.iaConfig!,
archivosReferencia: next,
},
}
})
}
onToggleRepositorio={(id, checked) =>
onChange((w): NewSubjectWizardState => {
const prev = w.iaConfig?.repositoriosReferencia || []
const next = checked
? [...prev, id]
: prev.filter((r) => r !== id)
return {
...w,
iaConfig: {
...w.iaConfig!,
repositoriosReferencia: next,
},
}
})
}
onFilesChange={(files: Array<UploadedFile>) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
iaConfig: {
...w.iaConfig!,
archivosAdjuntos: files,
},
}),
)
}
/>
</div>
)
}
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
const maxCiclos = Math.max(1, plan?.numero_ciclos ?? 1)
const sugerenciasSeleccionadas = wizard.sugerencias.filter(
(s) => s.selected,
)
const patchSugerencia = (
id: string,
patch: Partial<NewSubjectWizardState['sugerencias'][number]>,
) =>
onChange((w) => ({
...w,
sugerencias: w.sugerencias.map((s) =>
s.id === id ? { ...s, ...patch } : s,
),
}))
return (
<div className="flex flex-col gap-4">
<div className="border-border/60 bg-muted/30 rounded-xl border p-4">
<div className="grid gap-1">
<Label className="text-muted-foreground text-xs">
Estructura de la asignatura
</Label>
<Select
value={wizard.datosBasicos.estructuraId ?? undefined}
onValueChange={(val) =>
onChange(
(w): NewSubjectWizardState => ({
...w,
estructuraId: val,
datosBasicos: { ...w.datosBasicos, estructuraId: val },
}),
)
}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona una estructura" />
</SelectTrigger>
<SelectContent>
{(estructurasAsignatura ?? []).map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="border-border/60 bg-muted/30 rounded-xl border p-4">
<h3 className="text-foreground mx-3 mb-2 text-lg font-semibold">
Materias seleccionadas
</h3>
{sugerenciasSeleccionadas.length === 0 ? (
<div className="text-muted-foreground text-sm">
Selecciona al menos una sugerencia para configurar su descripción,
línea curricular y ciclo.
</div>
) : (
<Accordion type="multiple" className="w-full space-y-2">
{sugerenciasSeleccionadas.map((asig) => (
<AccordionItem
key={asig.id}
value={asig.id}
className="border-border/60 bg-background/40 rounded-lg border border-b-0 px-3"
>
<AccordionTrigger className="hover:bg-accent/30 data-[state=open]:bg-accent/20 data-[state=open]:text-accent-foreground -mx-3 px-3">
{asig.nombre}
</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
<div className="mx-1 grid gap-3 sm:grid-cols-2">
<div className="grid gap-1">
<Label className="text-muted-foreground text-xs">
Descripción
</Label>
<Textarea
value={asig.descripcion}
maxLength={7000}
rows={6}
onChange={(e) =>
patchSugerencia(asig.id, {
descripcion: e.target.value,
})
}
/>
</div>
<div className="grid content-start gap-3">
<div className="grid gap-1">
<Label className="text-muted-foreground text-xs">
Ciclo (opcional)
</Label>
<Input
type="number"
min={1}
max={maxCiclos}
step={1}
inputMode="numeric"
placeholder={`1-${maxCiclos}`}
value={asig.numero_ciclo ?? ''}
onKeyDown={(e) => {
if (
['.', ',', '-', 'e', 'E', '+'].includes(e.key)
) {
e.preventDefault()
}
}}
onChange={(e) => {
const raw = e.target.value
if (raw === '') {
patchSugerencia(asig.id, { numero_ciclo: null })
return
}
const asNumber = Number(raw)
if (!Number.isFinite(asNumber)) return
const n = Math.floor(Math.abs(asNumber))
const capped = Math.min(
Math.max(n >= 1 ? n : 1, 1),
maxCiclos,
)
patchSugerencia(asig.id, { numero_ciclo: capped })
}}
/>
</div>
<div className="grid gap-1">
<Label className="text-muted-foreground text-xs">
Línea curricular (opcional)
</Label>
<Select
value={asig.linea_plan_id ?? '__none__'}
onValueChange={(val) =>
patchSugerencia(asig.id, {
linea_plan_id: val === '__none__' ? null : val,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Sin línea" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Ninguna</SelectItem>
{(lineasPlan ?? []).map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</div>
</div>
)
}
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
return (
<div className="grid gap-4">
<div className="grid gap-2 sm:grid-cols-3">
<div>
<Label>Facultad</Label>
<Select
onValueChange={(val) =>
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, facultadId: val },
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent>
{FACULTADES.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Plan</Label>
<Select
onValueChange={(val) =>
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, planOrigenId: val },
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
{PLANES_MOCK.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Buscar</Label>
<Input placeholder="Nombre..." />
</div>
</div>
<div className="grid max-h-75 gap-2 overflow-y-auto">
{MATERIAS_MOCK.map((m) => (
<div
key={m.id}
role="button"
tabIndex={0}
onClick={() =>
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id },
}))
}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
onChange((w) => ({
...w,
clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id },
}))
}}
className={`hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 ${
wizard.clonInterno?.asignaturaOrigenId === m.id
? 'border-primary bg-primary/5 ring-primary ring-1'
: ''
}`}
>
<div>
<div className="font-medium">{m.nombre}</div>
<div className="text-muted-foreground text-xs">
{m.clave} {m.creditos} créditos
</div>
</div>
{wizard.clonInterno?.asignaturaOrigenId === m.id && (
<Icons.CheckCircle2 className="text-primary h-5 w-5" />
)}
</div>
))}
</div>
</div>
)
}
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
return (
<div className="grid gap-4">
<div className="rounded-lg border border-dashed p-8 text-center">
<Icons.Upload className="text-muted-foreground mx-auto mb-4 h-10 w-10" />
<h3 className="mb-1 text-sm font-medium">
Sube el Word de la asignatura
</h3>
<p className="text-muted-foreground mb-4 text-xs">
Arrastra el archivo o haz clic para buscar (.doc, .docx)
</p>
<Input
type="file"
accept=".doc,.docx"
className="mx-auto max-w-xs"
onChange={(e) =>
onChange((w) => ({
...w,
clonTradicional: {
...w.clonTradicional!,
archivoWordAsignaturaId:
e.target.files?.[0]?.name || 'mock_file',
},
}))
}
/>
</div>
{wizard.clonTradicional?.archivoWordAsignaturaId && (
<div className="flex items-center gap-2 rounded-md bg-green-50 p-3 text-sm text-green-700">
<Icons.FileText className="h-4 w-4" />
Archivo cargado listo para procesar.
</div>
)}
</div>
)
}
return null
}

View File

@@ -1,6 +1,10 @@
import * as Icons from 'lucide-react'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type {
ModoCreacion,
NewSubjectWizardState,
SubModoClonado,
} from '@/features/asignaturas/new/types'
import {
Card,
@@ -17,33 +21,19 @@ export function PasoMetodoCardGroup({
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
const isSelected = (modo: NewSubjectWizardState['tipoOrigen']) =>
wizard.tipoOrigen === modo
const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => {
const key = e.key
if (
key === 'Enter' ||
key === ' ' ||
key === 'Spacebar' ||
key === 'Space'
) {
e.preventDefault()
e.stopPropagation()
cb()
}
}
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
return (
<div className="grid gap-4 sm:grid-cols-3">
<Card
className={isSelected('MANUAL') ? 'ring-ring ring-2' : ''}
onClick={() =>
onChange(
(w): NewSubjectWizardState => ({
onChange((w) => ({
...w,
tipoOrigen: 'MANUAL',
}),
)
modoCreacion: 'MANUAL',
subModoClonado: undefined,
}))
}
role="button"
tabIndex={0}
@@ -61,12 +51,11 @@ export function PasoMetodoCardGroup({
<Card
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
onClick={() =>
onChange(
(w): NewSubjectWizardState => ({
onChange((w) => ({
...w,
tipoOrigen: 'IA',
}),
)
modoCreacion: 'IA',
subModoClonado: undefined,
}))
}
role="button"
tabIndex={0}
@@ -77,94 +66,11 @@ export function PasoMetodoCardGroup({
</CardTitle>
<CardDescription>Generar contenido automático.</CardDescription>
</CardHeader>
{(wizard.tipoOrigen === 'IA' ||
wizard.tipoOrigen === 'IA_SIMPLE' ||
wizard.tipoOrigen === 'IA_MULTIPLE') && (
<CardContent className="flex flex-col gap-3">
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'IA_SIMPLE',
}),
)
}}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'IA_SIMPLE',
}),
),
)
}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
isSelected('IA_SIMPLE')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
>
<Icons.Edit3 className="h-6 w-6 flex-none" />
<div className="flex flex-col">
<span className="text-sm font-medium">Una asignatura</span>
<span className="text-xs opacity-70">
Crear una asignatura con control detallado de metadatos.
</span>
</div>
</div>
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'IA_MULTIPLE',
}),
)
}}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'IA_MULTIPLE',
}),
),
)
}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
isSelected('IA_MULTIPLE')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
>
<Icons.List className="h-6 w-6 flex-none" />
<div className="flex flex-col">
<span className="text-sm font-medium">Varias asignaturas</span>
<span className="text-xs opacity-70">
Generar varias asignaturas a partir de sugerencias de la IA.
</span>
</div>
</div>
</CardContent>
)}
</Card>
<Card
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
onClick={() =>
onChange(
(w): NewSubjectWizardState => ({ ...w, tipoOrigen: 'CLONADO' }),
)
}
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
role="button"
tabIndex={0}
>
@@ -174,34 +80,18 @@ export function PasoMetodoCardGroup({
</CardTitle>
<CardDescription>De otra asignatura o archivo Word.</CardDescription>
</CardHeader>
{(wizard.tipoOrigen === 'CLONADO' ||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
<CardContent className="flex flex-col gap-3">
{wizard.modoCreacion === 'CLONADO' && (
<CardContent>
<div className="flex flex-col gap-3">
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'CLONADO_INTERNO',
}),
)
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' }))
}}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'CLONADO_INTERNO',
}),
),
)
}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
isSelected('CLONADO_INTERNO')
isSubSelected('INTERNO')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
@@ -220,25 +110,10 @@ export function PasoMetodoCardGroup({
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'CLONADO_TRADICIONAL',
}),
)
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' }))
}}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange(
(w): NewSubjectWizardState => ({
...w,
tipoOrigen: 'CLONADO_TRADICIONAL',
}),
),
)
}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
isSelected('CLONADO_TRADICIONAL')
isSubSelected('TRADICIONAL')
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
: 'border-border text-muted-foreground'
}`}
@@ -246,7 +121,10 @@ export function PasoMetodoCardGroup({
<Icons.Upload className="h-6 w-6 flex-none" />
<div className="flex flex-col">
<span className="text-sm font-medium">Desde archivos</span>
<span className="text-xs opacity-70">Subir Word existente</span>
<span className="text-xs opacity-70">
Subir Word existente
</span>
</div>
</div>
</div>
</CardContent>

View File

@@ -1,6 +1,6 @@
import * as Icons from 'lucide-react'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
import {
Card,
@@ -9,45 +9,9 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { usePlan, usePlanLineas, useSubjectEstructuras } from '@/data'
import { formatFileSize } from '@/features/planes/utils/format-file-size'
import { ESTRUCTURAS_SEP } from '@/features/asignaturas/new/catalogs'
export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
const { data: plan } = usePlan(wizard.plan_estudio_id)
const { data: estructuras } = useSubjectEstructuras()
const { data: lineasPlan } = usePlanLineas(wizard.plan_estudio_id)
const estructuraNombre = (() => {
const estructuraId = wizard.datosBasicos.estructuraId
if (!estructuraId) return '—'
const hit = estructuras?.find((e) => e.id === estructuraId)
return hit?.nombre ?? estructuraId
})()
const modoLabel = (() => {
if (wizard.tipoOrigen === 'MANUAL') return 'Manual (Vacía)'
if (wizard.tipoOrigen === 'IA') return 'Generada con IA'
if (wizard.tipoOrigen === 'IA_SIMPLE') return 'Generada con IA (Simple)'
if (wizard.tipoOrigen === 'IA_MULTIPLE') return 'Generación múltiple (IA)'
if (wizard.tipoOrigen === 'CLONADO_INTERNO') return 'Clonada (Sistema)'
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') return 'Clonada (Archivo)'
return '—'
})()
const creditosText =
typeof wizard.datosBasicos.creditos === 'number' &&
Number.isFinite(wizard.datosBasicos.creditos)
? wizard.datosBasicos.creditos.toFixed(2)
: '—'
const archivosRef = wizard.iaConfig?.archivosReferencia ?? []
const repositoriosRef = wizard.iaConfig?.repositoriosReferencia ?? []
const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? []
const materiasSeleccionadas = wizard.sugerencias.filter((s) => s.selected)
const iaMultipleEnfoque = wizard.iaMultiple?.enfoque.trim() ?? ''
const iaMultipleCantidad = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
return (
<Card>
<CardHeader>
@@ -56,238 +20,54 @@ export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
Verifica los datos antes de crear la asignatura.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 text-sm">
<div className="grid gap-2">
<div>
<span className="text-muted-foreground">Plan de estudios: </span>
<span className="font-medium">
{plan?.nombre || wizard.plan_estudio_id || '—'}
</span>
</div>
{plan?.carreras?.nombre ? (
<div>
<span className="text-muted-foreground">Carrera: </span>
<span className="font-medium">{plan.carreras.nombre}</span>
</div>
) : null}
</div>
<div className="bg-muted rounded-md p-3">
<span className="text-muted-foreground">Tipo de origen: </span>
<span className="inline-flex items-center gap-2 font-medium">
{wizard.tipoOrigen === 'MANUAL' && (
<Icons.Pencil className="h-4 w-4" />
)}
{(wizard.tipoOrigen === 'IA' ||
wizard.tipoOrigen === 'IA_SIMPLE' ||
wizard.tipoOrigen === 'IA_MULTIPLE') && (
<Icons.Sparkles className="h-4 w-4" />
)}
{(wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
<Icons.Copy className="h-4 w-4" />
)}
{modoLabel}
</span>
</div>
{wizard.tipoOrigen === 'IA_MULTIPLE' ? (
<>
<div className="border-border/60 bg-muted/30 grid gap-3 rounded-xl border p-4">
<div className="flex flex-col gap-1">
<div className="text-foreground text-base font-semibold">
Configuración
</div>
<div className="text-muted-foreground text-xs">
Se crearán {materiasSeleccionadas.length} asignatura(s) a
partir de tus selecciones.
</div>
</div>
<div className="bg-background/40 border-border/60 rounded-lg border p-3">
<div className="text-muted-foreground text-xs">
Estructura
</div>
<div className="text-foreground mt-1 text-sm font-medium">
{estructuraNombre}
</div>
</div>
</div>
<div className="border-border/60 bg-muted/30 grid gap-3 rounded-xl border p-4">
<div className="flex items-end justify-between gap-2">
<div className="text-foreground text-base font-semibold">
Materias seleccionadas
</div>
<div className="text-muted-foreground text-xs">
{materiasSeleccionadas.length} en total
</div>
</div>
{materiasSeleccionadas.length === 0 ? (
<div className="text-muted-foreground text-sm">
No hay materias seleccionadas.
</div>
) : (
<div className="grid gap-3">
{materiasSeleccionadas.map((m) => {
const lineaNombre = m.linea_plan_id
? (lineasPlan?.find((l) => l.id === m.linea_plan_id)
?.nombre ?? m.linea_plan_id)
: '—'
const cicloText =
typeof m.numero_ciclo === 'number' &&
Number.isFinite(m.numero_ciclo)
? String(m.numero_ciclo)
: '—'
return (
<div
key={m.id}
className="bg-background/40 border-border/60 grid gap-2 rounded-lg border p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-foreground text-sm font-semibold">
{m.nombre}
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-xs">
<span className="bg-accent/30 text-accent-foreground rounded-full px-2 py-0.5">
Línea: {lineaNombre}
</span>
<span className="bg-accent/30 text-accent-foreground rounded-full px-2 py-0.5">
Ciclo: {cicloText}
</span>
</div>
</div>
<div className="text-muted-foreground text-sm whitespace-pre-wrap">
{m.descripcion || '—'}
</div>
</div>
)
})}
</div>
)}
</div>
</>
) : (
<>
<CardContent className="grid gap-4 text-sm">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<span className="text-muted-foreground">Nombre: </span>
<span className="font-medium">
{wizard.datosBasicos.nombre || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Código: </span>
<span className="font-medium">
{wizard.datosBasicos.codigo || '—'}
</span>
<span className="text-muted-foreground">Nombre:</span>
<div className="font-medium">{wizard.datosBasicos.nombre}</div>
</div>
<div>
<span className="text-muted-foreground">Tipo:</span>
<span className="font-medium">
{wizard.datosBasicos.tipo || '—'}
</span>
<div className="font-medium">{wizard.datosBasicos.tipo}</div>
</div>
<div>
<span className="text-muted-foreground">Créditos:</span>
<span className="font-medium">{creditosText}</span>
<div className="font-medium">{wizard.datosBasicos.creditos}</div>
</div>
<div>
<span className="text-muted-foreground">Estructura:</span>
<span className="font-medium">{estructuraNombre}</span>
</div>
<div>
<span className="text-muted-foreground">
Horas académicas:{' '}
</span>
<span className="font-medium">
{wizard.datosBasicos.horasAcademicas ?? '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">
Horas independientes:{' '}
</span>
<span className="font-medium">
{wizard.datosBasicos.horasIndependientes ?? '—'}
</span>
</div>
</div>
<div className="bg-muted/50 rounded-md p-3">
<div className="font-medium">Configuración IA</div>
<div className="mt-2 grid gap-2">
<div>
<span className="text-muted-foreground">
Enfoque académico:{' '}
</span>
<span className="font-medium">
{wizard.iaConfig?.descripcionEnfoqueAcademico || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">
Instrucciones adicionales:{' '}
</span>
<span className="font-medium">
{wizard.iaConfig?.instruccionesAdicionalesIA || '—'}
</span>
</div>
<div className="mt-2">
<div className="font-medium">Archivos de referencia</div>
{archivosRef.length ? (
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{archivosRef.map((id) => (
<li key={id}>{id}</li>
))}
</ul>
) : (
<div className="text-muted-foreground text-xs"></div>
)}
</div>
<div>
<div className="font-medium">
Repositorios de referencia
{
ESTRUCTURAS_SEP.find(
(e) => e.id === wizard.datosBasicos.estructuraId,
)?.label
}
</div>
</div>
{repositoriosRef.length ? (
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{repositoriosRef.map((id) => (
<li key={id}>{id}</li>
))}
</ul>
) : (
<div className="text-muted-foreground text-xs"></div>
)}
</div>
<div>
<div className="font-medium">Archivos adjuntos</div>
{adjuntos.length ? (
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{adjuntos.map((f) => (
<li key={f.id}>
<span className="text-foreground">
{f.file.name}
</span>{' '}
<span>· {formatFileSize(f.file.size)}</span>
</li>
))}
</ul>
) : (
<div className="text-muted-foreground text-xs"></div>
)}
</div>
</div>
</div>
<div className="bg-muted rounded-md p-3">
<span className="text-muted-foreground">Modo de creación:</span>
<div className="flex items-center gap-2 font-medium">
{wizard.modoCreacion === 'MANUAL' && (
<>
<Icons.Pencil className="h-4 w-4" /> Manual (Vacía)
</>
)}
{wizard.modoCreacion === 'IA' && (
<>
<Icons.Sparkles className="h-4 w-4" /> Generada con IA
</>
)}
{wizard.modoCreacion === 'CLONADO' && (
<>
<Icons.Copy className="h-4 w-4" /> Clonada
{wizard.subModoClonado === 'INTERNO'
? ' (Sistema)'
: ' (Archivo)'}
</>
)}
</div>
</div>
</CardContent>
</Card>

View File

@@ -1,477 +1,66 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { AISubjectUnifiedInput } from '@/data'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { TablesInsert } from '@/types/supabase'
import type { RealtimeChannel } from '@supabase/supabase-js'
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
import { Button } from '@/components/ui/button'
import {
supabaseBrowser,
useGenerateSubjectAI,
qk,
useCreateSubjectManual,
subjects_get_maybe,
} from '@/data'
export function WizardControls({
Wizard,
methods,
wizard,
setWizard,
errorMessage,
onPrev,
onNext,
disablePrev,
disableNext,
disableCreate,
isLastStep,
canContinueDesdeMetodo,
canContinueDesdeBasicos,
canContinueDesdeConfig,
onCreate,
}: {
Wizard: any
methods: any
wizard: NewSubjectWizardState
setWizard: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
errorMessage?: string | null
onPrev: () => void
onNext: () => void
disablePrev: boolean
disableNext: boolean
disableCreate: boolean
isLastStep: boolean
canContinueDesdeMetodo: boolean
canContinueDesdeBasicos: boolean
canContinueDesdeConfig: boolean
onCreate: () => void
}) {
const navigate = useNavigate()
const qc = useQueryClient()
const generateSubjectAI = useGenerateSubjectAI()
const createSubjectManual = useCreateSubjectManual()
const [isSpinningIA, setIsSpinningIA] = useState(false)
const cancelledRef = useRef(false)
const realtimeChannelRef = useRef<RealtimeChannel | null>(null)
const watchSubjectIdRef = useRef<string | null>(null)
const watchTimeoutRef = useRef<number | null>(null)
useEffect(() => {
cancelledRef.current = false
return () => {
cancelledRef.current = true
}
}, [])
const stopSubjectWatch = useCallback(() => {
if (watchTimeoutRef.current) {
window.clearTimeout(watchTimeoutRef.current)
watchTimeoutRef.current = null
}
watchSubjectIdRef.current = null
const ch = realtimeChannelRef.current
if (ch) {
realtimeChannelRef.current = null
try {
supabaseBrowser().removeChannel(ch)
} catch {
// noop
}
}
}, [])
useEffect(() => {
return () => {
stopSubjectWatch()
}
}, [stopSubjectWatch])
const handleSubjectReady = (args: {
id: string
plan_estudio_id: string
estado?: unknown
}) => {
if (cancelledRef.current) return
const estado = String(args.estado ?? '').toLowerCase()
if (estado === 'generando') return
stopSubjectWatch()
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false }))
navigate({
to: `/planes/${args.plan_estudio_id}/asignaturas/${args.id}`,
state: { showConfetti: true },
})
}
const beginSubjectWatch = (args: { subjectId: string; planId: string }) => {
stopSubjectWatch()
watchSubjectIdRef.current = args.subjectId
// Timeout de seguridad (mismo límite que teníamos con polling)
watchTimeoutRef.current = window.setTimeout(
() => {
if (cancelledRef.current) return
if (watchSubjectIdRef.current !== args.subjectId) return
stopSubjectWatch()
setIsSpinningIA(false)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage:
'La generación está tardando demasiado. Intenta de nuevo en unos minutos.',
}))
},
6 * 60 * 1000,
)
const supabase = supabaseBrowser()
const channel = supabase.channel(`asignaturas-status-${args.subjectId}`)
realtimeChannelRef.current = channel
channel.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'asignaturas',
filter: `id=eq.${args.subjectId}`,
},
(payload) => {
if (cancelledRef.current) return
const next: any = (payload as any)?.new
if (!next?.id || !next?.plan_estudio_id) return
handleSubjectReady({
id: String(next.id),
plan_estudio_id: String(next.plan_estudio_id),
estado: next.estado,
})
},
)
channel.subscribe((status) => {
if (cancelledRef.current) return
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
stopSubjectWatch()
setIsSpinningIA(false)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage:
'No se pudo suscribir al estado de la asignatura. Intenta de nuevo.',
}))
}
})
}
const uploadAiAttachments = async (args: {
planId: string
files: Array<{ file: File }>
}): Promise<Array<string>> => {
const supabase = supabaseBrowser()
if (!args.files.length) return []
const runId = crypto.randomUUID()
const basePath = `planes/${args.planId}/asignaturas/ai/${runId}`
const keys: Array<string> = []
for (const f of args.files) {
const safeName = (f.file.name || 'archivo').replace(/[\\/]+/g, '_')
const key = `${basePath}/${crypto.randomUUID()}-${safeName}`
const { error } = await supabase.storage
.from('ai-storage')
.upload(key, f.file, {
contentType: f.file.type || undefined,
})
if (error) throw new Error(error.message)
keys.push(key)
}
return keys
}
const handleCreate = async () => {
setWizard((w) => ({
...w,
isLoading: true,
errorMessage: null,
}))
let startedWaiting = false
try {
if (wizard.tipoOrigen === 'IA_SIMPLE') {
if (!wizard.plan_estudio_id) {
throw new Error('Plan de estudio inválido.')
}
if (!wizard.datosBasicos.estructuraId) {
throw new Error('Estructura inválida.')
}
if (!wizard.datosBasicos.nombre.trim()) {
throw new Error('Nombre inválido.')
}
if (wizard.datosBasicos.creditos == null) {
throw new Error('Créditos inválidos.')
}
console.log(`${new Date().toISOString()} - Insertando asignatura IA`)
const supabase = supabaseBrowser()
const placeholder: TablesInsert<'asignaturas'> = {
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.datosBasicos.estructuraId,
nombre: wizard.datosBasicos.nombre,
codigo: wizard.datosBasicos.codigo ?? null,
tipo: wizard.datosBasicos.tipo ?? undefined,
creditos: wizard.datosBasicos.creditos,
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
estado: 'generando',
tipo_origen: 'IA',
}
const { data: inserted, error: insertError } = await supabase
.from('asignaturas')
.insert(placeholder)
.select('id,plan_estudio_id')
.single()
if (insertError) throw new Error(insertError.message)
const subjectId = inserted.id
setIsSpinningIA(true)
// Inicia watch realtime antes de disparar la Edge para no perder updates.
startedWaiting = true
beginSubjectWatch({ subjectId, planId: wizard.plan_estudio_id })
const archivosAdjuntos = await uploadAiAttachments({
planId: wizard.plan_estudio_id,
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
file: x.file,
})),
})
const payload: AISubjectUnifiedInput = {
datosUpdate: {
id: subjectId,
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.datosBasicos.estructuraId,
nombre: wizard.datosBasicos.nombre,
codigo: wizard.datosBasicos.codigo ?? null,
tipo: wizard.datosBasicos.tipo ?? null,
creditos: wizard.datosBasicos.creditos,
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
horas_independientes:
wizard.datosBasicos.horasIndependientes ?? null,
},
iaConfig: {
descripcionEnfoqueAcademico:
wizard.iaConfig?.descripcionEnfoqueAcademico ?? undefined,
instruccionesAdicionalesIA:
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
archivosAdjuntos,
},
}
console.log(
`${new Date().toISOString()} - Disparando Edge IA asignatura (unified)`,
)
await generateSubjectAI.mutateAsync(payload as any)
// Fallback: una lectura puntual por si el UPDATE llegó antes de suscribir.
const latest = await subjects_get_maybe(subjectId)
if (latest) {
handleSubjectReady({
id: latest.id as any,
plan_estudio_id: latest.plan_estudio_id as any,
estado: (latest as any).estado,
})
}
return
}
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
const selected = wizard.sugerencias.filter((s) => s.selected)
if (selected.length === 0) {
throw new Error('Selecciona al menos una sugerencia.')
}
if (!wizard.plan_estudio_id) {
throw new Error('Plan de estudio inválido.')
}
if (!wizard.estructuraId) {
throw new Error('Selecciona una estructura para continuar.')
}
const supabase = supabaseBrowser()
setIsSpinningIA(true)
const archivosAdjuntos = await uploadAiAttachments({
planId: wizard.plan_estudio_id,
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
file: x.file,
})),
})
const placeholders: Array<TablesInsert<'asignaturas'>> = selected.map(
(s): TablesInsert<'asignaturas'> => ({
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.estructuraId,
estado: 'generando',
nombre: s.nombre,
codigo: s.codigo ?? null,
tipo: s.tipo ?? undefined,
creditos: s.creditos ?? 0,
horas_academicas: s.horasAcademicas ?? null,
horas_independientes: s.horasIndependientes ?? null,
linea_plan_id: s.linea_plan_id ?? null,
numero_ciclo: s.numero_ciclo ?? null,
}),
)
const { data: inserted, error: insertError } = await supabase
.from('asignaturas')
.insert(placeholders)
.select('id')
if (insertError) {
throw new Error(insertError.message)
}
const insertedIds = inserted.map((r) => r.id)
if (insertedIds.length !== selected.length) {
throw new Error('No se pudieron crear todas las asignaturas.')
}
// Disparar generación en paralelo (no bloquear navegación)
insertedIds.forEach((id, idx) => {
const s = selected[idx]
const creditosForEdge =
typeof s.creditos === 'number' && s.creditos > 0
? s.creditos
: undefined
const payload: AISubjectUnifiedInput = {
datosUpdate: {
id,
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.estructuraId ?? undefined,
nombre: s.nombre,
codigo: s.codigo ?? null,
tipo: s.tipo ?? null,
creditos: creditosForEdge,
horas_academicas: s.horasAcademicas ?? null,
horas_independientes: s.horasIndependientes ?? null,
numero_ciclo: s.numero_ciclo ?? null,
linea_plan_id: s.linea_plan_id ?? null,
},
iaConfig: {
descripcionEnfoqueAcademico: s.descripcion,
instruccionesAdicionalesIA:
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
archivosAdjuntos,
},
}
void generateSubjectAI.mutateAsync(payload as any).catch((e) => {
console.error('Error generando asignatura IA (multiple):', e)
})
})
// Invalidar la query del listado del plan (una vez) para que la lista
// muestre el estado actualizado y recargue cuando lleguen updates.
qc.invalidateQueries({
queryKey: qk.planAsignaturas(wizard.plan_estudio_id),
})
navigate({
to: `/planes/${wizard.plan_estudio_id}/asignaturas`,
resetScroll: false,
})
setIsSpinningIA(false)
return
}
if (wizard.tipoOrigen === 'MANUAL') {
if (!wizard.plan_estudio_id) {
throw new Error('Plan de estudio inválido.')
}
const asignatura = await createSubjectManual.mutateAsync({
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.datosBasicos.estructuraId!,
nombre: wizard.datosBasicos.nombre,
codigo: wizard.datosBasicos.codigo ?? null,
tipo: wizard.datosBasicos.tipo ?? undefined,
creditos: wizard.datosBasicos.creditos ?? 0,
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
linea_plan_id: null,
numero_ciclo: null,
})
navigate({
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
state: { showConfetti: true },
resetScroll: false,
})
}
} catch (err: any) {
setIsSpinningIA(false)
stopSubjectWatch()
setWizard((w) => ({
...w,
isLoading: false,
errorMessage: err?.message ?? 'Error creando la asignatura',
}))
} finally {
if (!startedWaiting) {
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false }))
}
}
}
const idx = Wizard.utils.getIndex(methods.current.id)
const isLast = idx >= Wizard.steps.length - 1
return (
<div className="flex grow items-center justify-between">
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
Anterior
</Button>
<div className="mx-2 flex-1">
{(errorMessage ?? wizard.errorMessage) && (
<div className="flex-none border-t bg-white p-6">
<div className="flex items-center justify-between">
<div className="flex-1">
{wizard.errorMessage && (
<span className="text-destructive text-sm font-medium">
{errorMessage ?? wizard.errorMessage}
{wizard.errorMessage}
</span>
)}
</div>
<div className="mx-2 flex w-5 items-center justify-center">
<Loader2
className={
wizard.tipoOrigen === 'IA_SIMPLE' && isSpinningIA
? 'text-muted-foreground h-6 w-6 animate-spin'
: 'h-6 w-6 opacity-0'
}
aria-hidden={!(wizard.tipoOrigen === 'IA_SIMPLE' && isSpinningIA)}
/>
</div>
<div className="flex gap-4">
<Button
variant="secondary"
onClick={() => methods.prev()}
disabled={idx === 0 || wizard.isLoading}
>
Anterior
</Button>
{isLastStep ? (
<Button onClick={handleCreate} disabled={disableCreate}>
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
{!isLast ? (
<Button
onClick={() => methods.next()}
disabled={
wizard.isLoading ||
(idx === 0 && !canContinueDesdeMetodo) ||
(idx === 1 && !canContinueDesdeBasicos) ||
(idx === 2 && !canContinueDesdeConfig)
}
>
Siguiente
</Button>
) : (
<Button onClick={onNext} disabled={disableNext}>
Siguiente
<Button onClick={onCreate} disabled={wizard.isLoading}>
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
</Button>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,44 +1,18 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
// import { supabase } from '@/lib/supabase'
import { LoginInput } from '../ui/LoginInput'
import { SubmitButton } from '../ui/SubmitButton'
import { throwIfError } from '@/data/api/_helpers'
import { qk } from '@/data/query/keys'
import { supabaseBrowser } from '@/data/supabase/client'
export function ExternalLoginForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const qc = useQueryClient()
const navigate = useNavigate({ from: '/login' })
const supabase = supabaseBrowser()
const submit = async () => {
setIsLoading(true)
setError(null)
try {
const { error } = await supabase.auth.signInWithPassword({
/* await supabase.auth.signInWithPassword({
email,
password,
})
throwIfError(error)
qc.invalidateQueries({ queryKey: qk.session() })
qc.invalidateQueries({ queryKey: qk.auth })
await navigate({ to: '/dashboard', replace: true })
} catch (e: unknown) {
const anyErr = e as any
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
} finally {
setIsLoading(false)
}
})*/
}
return (
@@ -60,11 +34,7 @@ export function ExternalLoginForm() {
value={password}
onChange={setPassword}
/>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<SubmitButton
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
disabled={isLoading}
/>
<SubmitButton />
</form>
)
}

View File

@@ -1,45 +1,18 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
// import { supabase } from '@/lib/supabase'
import { LoginInput } from '../ui/LoginInput'
import { SubmitButton } from '../ui/SubmitButton'
import { throwIfError } from '@/data/api/_helpers'
import { qk } from '@/data/query/keys'
import { supabaseBrowser } from '@/data/supabase/client'
export function InternalLoginForm() {
const [clave, setClave] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const qc = useQueryClient()
const navigate = useNavigate({ from: '/login' })
const supabase = supabaseBrowser()
const submit = async () => {
setIsLoading(true)
setError(null)
try {
const email = clave.includes('@') ? clave : `${clave}@ulsa.mx`
const { error } = await supabase.auth.signInWithPassword({
email,
/* await supabase.auth.signInWithPassword({
email: `${clave}@ulsa.mx`,
password,
})
throwIfError(error)
qc.invalidateQueries({ queryKey: qk.session() })
qc.invalidateQueries({ queryKey: qk.auth })
await navigate({ to: '/dashboard', replace: true })
} catch (e: unknown) {
const anyErr = e as any
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
} finally {
setIsLoading(false)
}
})*/
}
return (
@@ -57,11 +30,7 @@ export function InternalLoginForm() {
value={password}
onChange={setPassword}
/>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<SubmitButton
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
disabled={isLoading}
/>
<SubmitButton />
</form>
)
}

View File

@@ -1,122 +0,0 @@
import { Check, Loader2 } from 'lucide-react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { useUpdatePlanFields, useUpdateRecommendationApplied } from '@/data'
export const ImprovementCard = ({
suggestions,
onApply,
planId,
currentDatos,
activeChatId,
onApplySuccess,
}: {
suggestions: Array<any>
onApply?: (key: string, value: string) => void
planId: string
currentDatos: any
activeChatId: any
onApplySuccess?: (key: string) => void
}) => {
const [localApplied, setLocalApplied] = useState<Array<string>>([])
const updatePlan = useUpdatePlanFields()
const updateAppliedStatus = useUpdateRecommendationApplied()
const handleApply = (key: string, newValue: string) => {
if (!currentDatos) return
const currentValue = currentDatos[key]
let finalValue: any
if (
typeof currentValue === 'object' &&
currentValue !== null &&
'description' in currentValue
) {
finalValue = { ...currentValue, description: newValue }
} else {
finalValue = newValue
}
const datosActualizados = {
...currentDatos,
[key]: finalValue,
}
updatePlan.mutate(
{
planId: planId as any,
patch: { datos: datosActualizados },
},
{
onSuccess: () => {
setLocalApplied((prev) => [...prev, key])
if (onApplySuccess) onApplySuccess(key)
if (activeChatId) {
updateAppliedStatus.mutate({
conversacionId: activeChatId,
campoAfectado: key,
})
}
if (onApply) onApply(key, newValue)
},
},
)
}
return (
<div className="mt-2 flex w-full flex-col gap-4">
{suggestions.map((sug) => {
const isApplied = sug.applied === true || localApplied.includes(sug.key)
const isUpdating =
updatePlan.isPending &&
updatePlan.variables.patch.datos?.[sug.key] !== undefined
return (
<div
key={sug.key}
className={`rounded-2xl border bg-white p-5 shadow-sm transition-all ${
isApplied ? 'border-teal-200 bg-teal-50/20' : 'border-slate-100'
}`}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-bold text-slate-900">{sug.label}</h3>
<Button
size="sm"
onClick={() => handleApply(sug.key, sug.newValue)}
disabled={isApplied || !!isUpdating}
className={`h-8 rounded-full px-4 text-xs transition-all ${
isApplied
? 'cursor-not-allowed bg-slate-100 text-slate-400'
: 'bg-[#00a189] text-white hover:bg-[#008f7a]'
}`}
>
{isUpdating ? (
<Loader2 size={12} className="animate-spin" />
) : isApplied ? (
<span className="flex items-center gap-1">
<Check size={12} /> Aplicado
</span>
) : (
'Aplicar mejora'
)}
</Button>
</div>
<div
className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${
isApplied
? 'border-teal-100 bg-teal-50/50 text-slate-700'
: 'border-slate-200 bg-slate-50 text-slate-500'
}`}
>
{sug.newValue}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,181 @@
import type { CARRERAS } from '@/features/planes/new/catalogs'
import type { NewPlanWizardState } from '@/features/planes/new/types'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
FACULTADES,
NIVELES,
TIPOS_CICLO,
} from '@/features/planes/new/catalogs'
export function PasoBasicosForm({
wizard,
onChange,
carrerasFiltradas,
}: {
wizard: NewPlanWizardState
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
carrerasFiltradas: typeof CARRERAS
}) {
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1 sm:col-span-2">
<Label htmlFor="nombrePlan">Nombre del plan</Label>
<Input
id="nombrePlan"
placeholder="Ej. Ingeniería en Sistemas 2026"
value={wizard.datosBasicos.nombrePlan}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value },
}))
}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="facultad">Facultad</Label>
<Select
value={wizard.datosBasicos.facultadId}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
facultadId: value,
carreraId: '',
},
}))
}
>
<SelectTrigger
id="facultad"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue placeholder="Selecciona facultad…" />
</SelectTrigger>
<SelectContent>
{FACULTADES.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="carrera">Carrera</Label>
<Select
value={wizard.datosBasicos.carreraId}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, carreraId: value },
}))
}
disabled={!wizard.datosBasicos.facultadId}
>
<SelectTrigger
id="carrera"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue placeholder="Selecciona carrera…" />
</SelectTrigger>
<SelectContent>
{carrerasFiltradas.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="nivel">Nivel</Label>
<Select
value={wizard.datosBasicos.nivel}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, nivel: value },
}))
}
>
<SelectTrigger
id="nivel"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue placeholder="Selecciona nivel…" />
</SelectTrigger>
<SelectContent>
{NIVELES.map((n) => (
<SelectItem key={n} value={n}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
<Select
value={wizard.datosBasicos.tipoCiclo}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
tipoCiclo: value as any,
},
}))
}
>
<SelectTrigger
id="tipoCiclo"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIPOS_CICLO.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="numCiclos">Número de ciclos</Label>
<Input
id="numCiclos"
type="number"
min={1}
value={wizard.datosBasicos.numCiclos}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
numCiclos: Number(e.target.value || 1),
},
}))
}
/>
</div>
</div>
)
}

View File

@@ -1,344 +0,0 @@
import type {
EstructuraPlanRow,
FacultadRow,
NivelPlanEstudio,
TipoCiclo,
} from '@/data/types/domain'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useCatalogosPlanes } from '@/data/hooks/usePlans'
import { NIVELES, TIPOS_CICLO } from '@/features/planes/nuevo/catalogs'
import { cn } from '@/lib/utils'
export function PasoBasicosForm({
wizard,
onChange,
}: {
wizard: NewPlanWizardState
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
}) {
const { data: catalogos } = useCatalogosPlanes()
// Preferir los catálogos remotos si están disponibles; si no, usar los locales
const facultadesList = catalogos?.facultades ?? []
const rawCarreras = catalogos?.carreras ?? []
const estructurasPlanList = catalogos?.estructurasPlan ?? []
const filteredCarreras = rawCarreras.filter((c: any) => {
const facId = wizard.datosBasicos.facultad.id
if (!facId) return true
// soportar ambos shapes: `facultad_id` (BD) o `facultadId` (local)
return c.facultad_id ? c.facultad_id === facId : c.facultadId === facId
})
return (
<div className="flex flex-col gap-2">
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1 sm:col-span-2">
<Label htmlFor="nombrePlan">
Nombre del plan {/* <span className="text-destructive">*</span> */}
</Label>
<Input
id="nombrePlan"
placeholder="Ej. Ingeniería en Sistemas (2026)"
value={wizard.datosBasicos.nombrePlan}
maxLength={200}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(
(w): NewPlanWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
nombrePlan: e.target.value,
},
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="facultad">Facultad</Label>
<Select
value={wizard.datosBasicos.facultad.id}
onValueChange={(value) =>
onChange(
(w): NewPlanWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
facultad: {
id: value,
nombre:
facultadesList.find((f) => f.id === value)?.nombre ||
'',
},
carrera: { id: '', nombre: '' },
},
}),
)
}
>
<SelectTrigger
id="facultad"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.facultad.id
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
>
<SelectValue placeholder="Ej. Facultad de Ingeniería" />
</SelectTrigger>
<SelectContent>
{facultadesList.map((f: FacultadRow) => (
<SelectItem key={f.id} value={f.id}>
{f.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="carrera">Carrera</Label>
<Select
value={wizard.datosBasicos.carrera.id}
onValueChange={(value) =>
onChange(
(w): NewPlanWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
carrera: {
id: value,
nombre:
filteredCarreras.find((c) => c.id === value)?.nombre ||
'',
},
},
}),
)
}
disabled={!wizard.datosBasicos.facultad.id}
>
<SelectTrigger
id="carrera"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.carrera.id
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
>
<SelectValue placeholder="Ej. Ingeniería en Cibernética y Sistemas Computacionales" />
</SelectTrigger>
<SelectContent>
{filteredCarreras.map((c: any) => (
<SelectItem key={c.id} value={c.id}>
{c.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="nivel">Nivel</Label>
<Select
value={wizard.datosBasicos.nivel}
onValueChange={(value: NivelPlanEstudio) =>
onChange(
(w): NewPlanWizardState => ({
...w,
datosBasicos: { ...w.datosBasicos, nivel: value },
}),
)
}
>
<SelectTrigger
id="nivel"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.nivel
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
>
<SelectValue placeholder="Ej. Licenciatura" />
</SelectTrigger>
<SelectContent>
{NIVELES.map((n) => (
<SelectItem key={n} value={n}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
<Select
value={wizard.datosBasicos.tipoCiclo}
onValueChange={(value: TipoCiclo) =>
onChange(
(w): NewPlanWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
tipoCiclo: value as any,
},
}),
)
}
>
<SelectTrigger
id="tipoCiclo"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.tipoCiclo
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
>
<SelectValue placeholder="Ej. Semestre" />
</SelectTrigger>
<SelectContent>
{TIPOS_CICLO.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="numCiclos">Número de ciclos</Label>
<Input
id="numCiclos"
type="number"
min={1}
max={99}
step={1}
inputMode="numeric"
pattern="[0-9]*"
value={wizard.datosBasicos.numCiclos ?? ''}
onKeyDown={(e) => {
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
e.preventDefault()
}
}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange(
(w): NewPlanWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
// Keep undefined when the input is empty so the field stays optional
numCiclos: (() => {
const raw = e.target.value
if (raw === '') return null
const asNumber = Number(raw)
if (Number.isNaN(asNumber)) return null
// Coerce to positive integer (natural numbers without zero)
const n = Math.floor(Math.abs(asNumber))
const capped = Math.min(n >= 1 ? n : 1, 99)
return capped
})(),
},
}),
)
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 8"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="estructuraPlan">Estructura de plan de estudios</Label>
<Select
value={wizard.datosBasicos.estructuraPlanId ?? ''}
onValueChange={(value: string) =>
onChange(
(w): NewPlanWizardState => ({
...w,
datosBasicos: {
...w.datosBasicos,
estructuraPlanId: value,
},
}),
)
}
>
<SelectTrigger
id="tipoCiclo"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.estructuraPlanId
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
>
<SelectValue placeholder="Ej. Plan base SEP/ULSA (2026)" />
</SelectTrigger>
<SelectContent>
{estructurasPlanList.map((t: EstructuraPlanRow) => (
<SelectItem key={t.id} value={t.id}>
{t.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* <Separator className="my-3" />
<div className="grid gap-4 sm:grid-cols-2">
<TemplateSelectorCard
cardTitle="Plantilla de plan de estudios"
cardDescription="Selecciona el Word para tu nuevo plan."
templatesData={PLANTILLAS_ANEXO_1}
selectedTemplateId={wizard.datosBasicos.plantillaPlanId || ''}
selectedVersion={wizard.datosBasicos.plantillaPlanVersion || ''}
onChange={({ templateId, version }) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
plantillaPlanId: templateId,
plantillaPlanVersion: version,
},
}))
}
/>
<TemplateSelectorCard
cardTitle="Plantilla de mapa curricular"
cardDescription="Selecciona el Excel para tu mapa curricular."
templatesData={PLANTILLAS_ANEXO_2}
selectedTemplateId={wizard.datosBasicos.plantillaMapaId || ''}
selectedVersion={wizard.datosBasicos.plantillaMapaVersion || ''}
onChange={({ templateId, version }) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
plantillaMapaId: templateId,
plantillaMapaVersion: version,
},
}))
}
/>
</div> */}
</div>
)
}

View File

@@ -1,189 +0,0 @@
import { useMemo, useState } from 'react'
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
type TemplateData = {
id: string
name: string
versions: Array<string>
}
// Default data (kept for backward compatibility if caller doesn't pass templates)
const DEFAULT_TEMPLATES_DATA: Array<TemplateData> = [
{
id: 'sep-2025',
name: 'Licenciatura RVOE SEP',
versions: ['v2025.2 (Vigente)', 'v2025.1', 'v2024.Final'],
},
{
id: 'interno-mix',
name: 'Estándar Institucional Mixto',
versions: ['v2.0', 'v1.5', 'v1.0-beta'],
},
{
id: 'conacyt',
name: 'Formato Posgrado CONAHCYT',
versions: ['v3.0 (2025)', 'v2.8'],
},
]
interface Props {
cardTitle?: string
cardDescription?: string
templatesData?: Array<TemplateData>
// Controlled selection (optional). If not provided, component manages its own state
selectedTemplateId?: string
selectedVersion?: string
onChange?: (sel: { templateId: string; version: string }) => void
}
export function TemplateSelectorCard({
cardTitle = 'Configuración del Documento',
cardDescription = 'Selecciona la base para tu nuevo plan.',
templatesData = DEFAULT_TEMPLATES_DATA,
selectedTemplateId,
selectedVersion,
onChange,
}: Props) {
const [internalTemplate, setInternalTemplate] = useState<string>('')
const [internalVersion, setInternalVersion] = useState<string>('')
const selectedTemplate = selectedTemplateId ?? internalTemplate
const version = selectedVersion ?? internalVersion
// Buscamos las versiones de la plantilla seleccionada
const currentTemplateData = useMemo(
() => templatesData.find((t) => t.id === selectedTemplate),
[templatesData, selectedTemplate],
)
const availableVersions = currentTemplateData?.versions || []
const handleTemplateChange = (value: string) => {
const template = templatesData.find((t) => t.id === value)
const firstVersion = template?.versions[0] ?? ''
if (onChange) {
onChange({ templateId: value, version: firstVersion })
} else {
setInternalTemplate(value)
setInternalVersion(firstVersion)
}
}
const handleVersionChange = (value: string) => {
if (onChange) {
onChange({ templateId: selectedTemplate, version: value })
} else {
setInternalVersion(value)
}
}
return (
<Card className="w-full max-w-lg gap-2 overflow-hidden">
<CardHeader className="px-4 pb-2 sm:px-6 sm:pb-4">
<CardTitle className="text-lg">{cardTitle}</CardTitle>
<CardDescription>{cardDescription}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{/* SELECT 1: PRIMARIO (Llamativo) */}
<div className="space-y-2">
<Label
htmlFor="template-select"
className="text-foreground text-base font-semibold"
>
Plantilla
</Label>
<Select value={selectedTemplate} onValueChange={handleTemplateChange}>
<SelectTrigger
id="template-select"
className="bg-background border-primary/40 focus:ring-primary/20 focus:border-primary flex h-11 w-full min-w-0 items-center justify-between gap-2 text-base shadow-sm [&>span]:block! [&>span]:truncate! [&>span]:text-left"
>
<SelectValue placeholder="Selecciona una plantilla..." />
</SelectTrigger>
<SelectContent>
{templatesData.map((t) => (
<SelectItem key={t.id} value={t.id} className="font-medium">
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* SELECT 2: SECUNDARIO (Sutil) */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label
htmlFor="version-select"
className={cn(
'text-xs tracking-wider uppercase transition-colors',
!selectedTemplate
? 'text-muted-foreground/50'
: 'text-muted-foreground',
)}
>
Versión
</Label>
</div>
<Select
value={version}
onValueChange={handleVersionChange}
disabled={!selectedTemplate}
>
<SelectTrigger
id="version-select"
className={cn(
'flex h-9 min-w-0 items-center justify-between gap-2 text-sm transition-all duration-300',
/* AQUÍ ESTÁ EL CAMBIO DE ANCHO: */
'w-full max-w-full sm:w-55',
/* Las correcciones vitales para truncado que ya teníamos: */
'min-w-0 [&>span]:block! [&>span]:truncate! [&>span]:text-left',
'[&>span]:block [&>span]:min-w-0 [&>span]:truncate [&>span]:text-left',
!selectedTemplate
? 'bg-muted/50 cursor-not-allowed border-transparent opacity-50'
: 'bg-muted/20 border-border hover:bg-background hover:border-primary/30',
)}
>
<SelectValue
placeholder={
!selectedTemplate
? '— Esperando plantilla —'
: 'Selecciona versión'
}
/>
</SelectTrigger>
<SelectContent>
{availableVersions.map((v) => (
<SelectItem
key={v}
value={v}
className="text-muted-foreground focus:text-foreground text-sm"
>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,11 +1,9 @@
import { FileDropzone } from './FileDropZone'
import ReferenciasParaIA from './ReferenciasParaIA'
import type { UploadedFile } from './FileDropZone'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
import type { NewPlanWizardState } from '@/features/planes/new/types'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
@@ -16,18 +14,20 @@ import {
CARRERAS,
FACULTADES,
PLANES_EXISTENTES,
} from '@/features/planes/nuevo/catalogs'
} from '@/features/planes/new/catalogs'
export function PasoDetallesPanel({
wizard,
onChange,
onGenerarIA,
isLoading,
}: {
wizard: NewPlanWizardState
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
onGenerarIA: () => void
isLoading: boolean
}) {
if (wizard.tipoOrigen === 'MANUAL') {
if (wizard.modoCreacion === 'MANUAL') {
return (
<Card>
<CardHeader>
@@ -40,104 +40,102 @@ export function PasoDetallesPanel({
)
}
if (wizard.tipoOrigen === 'IA') {
if (wizard.modoCreacion === 'IA') {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<Label htmlFor="desc">Descripción del enfoque académico</Label>
<div className="grid gap-4">
<div>
<Label htmlFor="desc">Descripción del enfoque</Label>
<textarea
id="desc"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="Define el perfil de egreso, visión pedagógica y sector profesional. Ej.: Programa semestral orientado a la Industria 4.0, con enfoque en competencias directivas y emprendimiento tecnológico..."
maxLength={7000}
value={wizard.iaConfig?.descripcionEnfoqueAcademico || ''}
placeholder="Describe el enfoque del programa…"
value={wizard.iaConfig?.descripcionEnfoque || ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
onChange((w) => ({
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
descripcionEnfoqueAcademico: e.target.value,
descripcionEnfoque: e.target.value,
},
}))
}
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="notas">
Instrucciones adicionales para la IA
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<div>
<Label htmlFor="poblacion">Población objetivo</Label>
<Input
id="poblacion"
placeholder="Ej. Egresados de bachillerato con perfil STEM"
value={wizard.iaConfig?.poblacionObjetivo || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
poblacionObjetivo: e.target.value,
},
}))
}
/>
</div>
<div>
<Label htmlFor="notas">Notas adicionales</Label>
<textarea
id="notas"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="Opcional: Estándares, estructura y limitaciones. Ej.: Estructura de 9 ciclos, carga pesada en ciencias básicas, sigue normativa CACEI, incluye 15% de materias optativas..."
maxLength={7000}
value={wizard.iaConfig?.instruccionesAdicionalesIA || ''}
placeholder="Lineamientos institucionales, restricciones, etc."
value={wizard.iaConfig?.notasAdicionales || ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
onChange((w) => ({
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
instruccionesAdicionalesIA: e.target.value,
notasAdicionales: e.target.value,
},
}))
}
/>
</div>
<ReferenciasParaIA
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
onToggleArchivo={(id, checked) =>
onChange((w): NewPlanWizardState => {
const prev = w.iaConfig?.archivosReferencia || []
const next = checked
? [...prev, id]
: prev.filter((x) => x !== id)
return {
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
archivosReferencia: next,
},
}
})
}
onToggleRepositorio={(id, checked) =>
onChange((w): NewPlanWizardState => {
const prev = w.iaConfig?.repositoriosReferencia || []
const next = checked
? [...prev, id]
: prev.filter((x) => x !== id)
return {
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
repositoriosReferencia: next,
},
}
})
}
onFilesChange={(files: Array<UploadedFile>) =>
onChange(
(w): NewPlanWizardState => ({
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
archivosAdjuntos: files,
},
}),
)
}
/>
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
Opcional: se pueden adjuntar recursos IA más adelante.
</div>
<Button onClick={onGenerarIA} disabled={isLoading}>
{isLoading ? 'Generando…' : 'Generar borrador con IA'}
</Button>
</div>
{wizard.resumen.previewPlan && (
<Card>
<CardHeader>
<CardTitle>Preview IA</CardTitle>
<CardDescription>
Asignaturas aprox.:{' '}
{wizard.resumen.previewPlan.numAsignaturasAprox}
</CardDescription>
</CardHeader>
<CardContent>
<ul className="text-muted-foreground list-disc pl-5 text-sm">
{wizard.resumen.previewPlan.secciones?.map((s) => (
<li key={s.id}>
<span className="text-foreground font-medium">
{s.titulo}:
</span>{' '}
{s.resumen}
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
)
}
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
if (
wizard.modoCreacion === 'CLONADO' &&
wizard.subModoClonado === 'INTERNO'
) {
return (
<div className="grid gap-4">
<div className="grid gap-3 sm:grid-cols-3">
@@ -146,7 +144,6 @@ export function PasoDetallesPanel({
<select
id="clonFacultad"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
aria-label="Facultad"
value={wizard.datosBasicos.facultadId}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
onChange((w) => ({
@@ -171,7 +168,6 @@ export function PasoDetallesPanel({
<select
id="clonCarrera"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
aria-label="Carrera"
value={wizard.datosBasicos.carreraId}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
onChange((w) => ({
@@ -241,12 +237,15 @@ export function PasoDetallesPanel({
)
}
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
if (
wizard.modoCreacion === 'CLONADO' &&
wizard.subModoClonado === 'TRADICIONAL'
) {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<div className="grid gap-4">
<div>
<Label htmlFor="word">Word del plan (obligatorio)</Label>
{/* <input
<input
id="word"
type="file"
accept=".doc,.docx"
@@ -262,21 +261,6 @@ export function PasoDetallesPanel({
},
}))
}
/> */}
<FileDropzone
acceptedTypes=".doc,.docx"
maxFiles={1}
onFilesChange={(files) => {
const f = files[0] || null
onChange((w) => ({
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoWordPlanId: f,
},
}))
}}
/>
</div>
<div>
@@ -285,32 +269,17 @@ export function PasoDetallesPanel({
id="mapa"
type="file"
accept=".xls,.xlsx"
title="Subir mapa curricular"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => {
const file = e.target.files?.[0] || null
const next = file
? {
id:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? (crypto as any).randomUUID()
: `file-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`,
name: file.name,
size: formatFileSize(file.size),
type: file.name.split('.').pop() || 'file',
}
: null
return {
onChange((w) => ({
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoMapaExcelId: next,
archivoMapaExcelId: e.target.files?.[0]
? `file_${e.target.files[0].name}`
: null,
},
}
})
}))
}
/>
</div>
@@ -320,32 +289,17 @@ export function PasoDetallesPanel({
id="asignaturas"
type="file"
accept=".xls,.xlsx,.csv"
title="Subir listado de asignaturas"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => {
const file = e.target.files?.[0] || null
const next = file
? {
id:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? (crypto as any).randomUUID()
: `file-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`,
name: file.name,
size: formatFileSize(file.size),
type: file.name.split('.').pop() || 'file',
}
: null
return {
onChange((w) => ({
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoAsignaturasExcelId: next,
archivoAsignaturasExcelId: e.target.files?.[0]
? `file_${e.target.files[0].name}`
: null,
},
}
})
}))
}
/>
</div>
@@ -367,9 +321,3 @@ export function PasoDetallesPanel({
</Card>
)
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}

View File

@@ -1,293 +0,0 @@
import { Upload, File, X, FileText } from 'lucide-react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { formatFileSize } from '@/features/planes/utils/format-file-size'
import { cn } from '@/lib/utils'
export interface UploadedFile {
id: string // Necesario para React (key)
file: File // La fuente de verdad (contiene name, size, type)
preview?: string // Opcional: si fueran imágenes
}
interface FileDropzoneProps {
persistentFiles?: Array<UploadedFile>
onFilesChange?: (files: Array<UploadedFile>) => void
acceptedTypes?: string
maxFiles?: number
title?: string
description?: string
autoScrollToDropzone?: boolean
}
export function FileDropzone({
persistentFiles,
onFilesChange,
acceptedTypes = '.doc,.docx,.pdf',
maxFiles = 5,
title = 'Arrastra archivos aquí',
description = 'o haz clic para seleccionar',
autoScrollToDropzone = false,
}: FileDropzoneProps) {
const [isDragging, setIsDragging] = useState(false)
const [files, setFiles] = useState<Array<UploadedFile>>(persistentFiles ?? [])
const onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange)
const bottomRef = useRef<HTMLDivElement>(null)
const prevFilesLengthRef = useRef(files.length)
const addFiles = useCallback(
(incomingFiles: Array<File>) => {
console.log(
'incoming files:',
incomingFiles.map((file) => file.name),
)
setFiles((previousFiles) => {
console.log(
'previous files',
previousFiles.map((f) => f.file.name),
)
// Evitar duplicados por nombre (comprobación global en los archivos existentes)
const existingFileNames = new Set(
previousFiles.map((uploaded) => uploaded.file.name),
)
const uniqueNewFiles = incomingFiles.filter(
(incomingFile) => !existingFileNames.has(incomingFile.name),
)
// Convertir archivos a objetos con ID único para manejo en React
const filesToUpload: Array<UploadedFile> = uniqueNewFiles.map(
(incomingFile) => ({
id:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? (crypto as any).randomUUID()
: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
file: incomingFile,
}),
)
// Calcular espacio disponible respetando el límite máximo
const room = Math.max(0, maxFiles - previousFiles.length)
const nextFiles = [
...previousFiles,
...filesToUpload.slice(0, room),
].slice(0, maxFiles)
return nextFiles
})
},
[maxFiles],
)
// Manejador para cuando se arrastran archivos sobre la zona
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
// Manejador para cuando se sale de la zona de arrastre
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
// Manejador para cuando se sueltan los archivos
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const droppedFiles = Array.from(e.dataTransfer.files)
addFiles(droppedFiles)
},
[addFiles],
)
// Manejador para la selección de archivos mediante el input nativo
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files)
addFiles(selectedFiles)
// Corrección de bug: Limpiar el valor para permitir seleccionar el mismo archivo nuevamente si fue eliminado
e.target.value = ''
}
},
[addFiles],
)
// Función para eliminar un archivo específico por su ID
const removeFile = useCallback((fileId: string) => {
setFiles((previousFiles) => {
console.log(
'previous files',
previousFiles.map((f) => f.file.name),
)
const remainingFiles = previousFiles.filter(
(uploadedFile) => uploadedFile.id !== fileId,
)
return remainingFiles
})
}, [])
// Mantener la referencia actualizada de la función callback externa para evitar loops en useEffect
useEffect(() => {
onFilesChangeRef.current = onFilesChange
}, [onFilesChange])
// Notificar al componente padre cuando cambia la lista de archivos
useEffect(() => {
if (onFilesChangeRef.current) onFilesChangeRef.current(files)
}, [files])
// Scroll automático hacia abajo solo cuando se pasa de 0 a 1 o más archivos
useEffect(() => {
if (
autoScrollToDropzone &&
prevFilesLengthRef.current === 0 &&
files.length > 0
) {
// Usar un pequeño timeout para asegurar que el renderizado se complete
const timer = setTimeout(() => {
bottomRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}, 100)
// Actualizar la referencia
prevFilesLengthRef.current = files.length
return () => clearTimeout(timer)
}
// Mantener sincronizada la referencia en otros casos
prevFilesLengthRef.current = files.length
}, [files.length, autoScrollToDropzone])
// Determinar el icono a mostrar según la extensión del archivo
const getFileIcon = (type: string) => {
switch (type.toLowerCase()) {
case 'pdf':
return <FileText className="text-destructive h-4 w-4" />
case 'doc':
case 'docx':
return <FileText className="text-info h-4 w-4" />
default:
return <File className="text-muted-foreground h-4 w-4" />
}
}
return (
<div className="space-y-3">
{/* Elemento invisible para referencia de scroll */}
<div ref={bottomRef} />
{/* Área principal de dropzone */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'cursor-pointer rounded-xl border-2 border-dashed p-7 text-center transition-all duration-300',
// Siempre usar borde por defecto a menos que se esté arrastrando
'border-border hover:border-primary/50',
isDragging && 'ring-primary ring-2 ring-offset-2',
)}
>
<input
type="file"
accept={acceptedTypes}
multiple
onChange={handleFileInput}
className="hidden"
id="file-upload"
disabled={files.length >= maxFiles}
/>
<label
htmlFor="file-upload"
className="cursor-pointer"
aria-label="Seleccionar archivos"
>
<div className="flex flex-col items-center gap-3">
<div
className={cn(
'flex h-12 w-12 items-center justify-center rounded-xl transition-colors',
isDragging
? 'bg-primary text-primary-foreground'
: 'bg-accent text-accent-foreground',
)}
>
<Upload className="h-6 w-6" />
</div>
<div className="text-center">
<p className="text-foreground text-sm font-medium">{title}</p>
{/* <p className="text-muted-foreground mt-1 text-xs">
{description}
</p> */}
<p className="text-muted-foreground mt-1 text-xs">
Formatos:{' '}
{acceptedTypes
.replace(/\./g, '')
.toUpperCase()
.replace(/,/g, ', ')}
</p>
<div className="mt-2 flex items-center justify-center gap-1.5">
<span
className={cn(
'text-primary text-xl font-bold',
files.length >= maxFiles ? 'text-destructive' : '',
)}
>
{files.length}
</span>
<span
className={cn(
'text-sm font-medium transition-colors',
files.length >= maxFiles
? 'text-destructive'
: 'text-muted-foreground/80',
)}
>
/ {maxFiles} archivos (máximo)
</span>
</div>
</div>
</div>
</label>
</div>
{/* Lista de archivos subidos (Orden inverso: más recientes primero) */}
<div className="h-56 overflow-y-auto">
{files.length > 0 && (
<div className="space-y-2">
{[...files].reverse().map((uploadedFile) => (
<div
key={uploadedFile.id}
className="bg-accent/50 border-border fade-in flex items-center gap-3 rounded-lg border p-3"
>
{getFileIcon(uploadedFile.file.type)}
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{uploadedFile.file.name}
</p>
<p className="text-muted-foreground text-xs">
{formatFileSize(uploadedFile.file.size)}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive h-8 w-8"
onClick={() => removeFile(uploadedFile.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -1,228 +0,0 @@
import { FileText, FolderOpen, Upload } from 'lucide-react'
import { useMemo, useState } from 'react'
import BarraBusqueda from '../../BarraBusqueda'
import { FileDropzone } from './FileDropZone'
import type { UploadedFile } from './FileDropZone'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
TabsContents,
} from '@/components/ui/motion-tabs'
import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs'
import { cn } from '@/lib/utils'
const ReferenciasParaIA = ({
selectedArchivoIds = [],
selectedRepositorioIds = [],
uploadedFiles = [],
onToggleArchivo,
onToggleRepositorio,
onFilesChange,
}: {
selectedArchivoIds?: Array<string>
selectedRepositorioIds?: Array<string>
uploadedFiles?: Array<UploadedFile>
onToggleArchivo?: (id: string, checked: boolean) => void
onToggleRepositorio?: (id: string, checked: boolean) => void
onFilesChange?: (files: Array<UploadedFile>) => void
}) => {
const [busquedaArchivos, setBusquedaArchivos] = useState('')
const [busquedaRepositorios, setBusquedaRepositorios] = useState('')
const cleanText = (text: string) => {
return text
.normalize('NFD') // Descompone "á" en "a" + "´"
.replace(/[\u0300-\u036f]/g, '') // Elimina los símbolos diacríticos
.toLowerCase() // Convierte a minúsculas
}
// Filtrado de archivos y de repositorios
const archivosFiltrados = useMemo(() => {
// Función helper para limpiar texto (quita acentos y hace minúsculas)
const term = cleanText(busquedaArchivos)
return ARCHIVOS.filter((archivo) =>
cleanText(archivo.nombre).includes(term),
)
}, [busquedaArchivos])
const repositoriosFiltrados = useMemo(() => {
const term = cleanText(busquedaRepositorios)
return REPOSITORIOS.filter((repositorio) =>
cleanText(repositorio.nombre).includes(term),
)
}, [busquedaRepositorios])
const tabs = [
{
name: 'Archivos existentes',
value: 'archivos-existentes',
icon: FileText,
content: (
<div className="flex flex-col">
<BarraBusqueda
value={busquedaArchivos}
onChange={setBusquedaArchivos}
placeholder="Buscar archivo existente..."
className="m-1 mb-1.5"
/>
<div className="flex h-96 flex-col gap-0.5 overflow-y-auto">
{archivosFiltrados.map((archivo) => (
<Label
key={archivo.id}
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950"
>
<Checkbox
checked={selectedArchivoIds.includes(archivo.id)}
onCheckedChange={(checked) =>
onToggleArchivo?.(archivo.id, !!checked)
}
className={cn(
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
selectedArchivoIds.includes(archivo.id) ? '' : 'invisible',
)}
/>
<FileText className="text-muted-foreground h-4 w-4" />
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{archivo.nombre}
</p>
<p className="text-muted-foreground text-xs">
{archivo.tamaño}
</p>
</div>
</Label>
))}
</div>
</div>
),
},
{
name: 'Repositorios',
value: 'repositorios',
icon: FolderOpen,
content: (
<div className="flex flex-col">
<BarraBusqueda
value={busquedaRepositorios}
onChange={setBusquedaRepositorios}
placeholder="Buscar repositorio..."
className="m-1 mb-1.5"
/>
<div className="flex h-96 flex-col gap-0.5 overflow-y-auto">
{repositoriosFiltrados.map((repositorio) => (
<Label
key={repositorio.id}
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950"
>
<Checkbox
checked={selectedRepositorioIds.includes(repositorio.id)}
onCheckedChange={(checked) =>
onToggleRepositorio?.(repositorio.id, !!checked)
}
className={cn(
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
selectedRepositorioIds.includes(repositorio.id)
? ''
: 'invisible',
)}
/>
<FolderOpen className="text-muted-foreground h-4 w-4" />
<div className="min-w-0 flex-1">
<p className="text-foreground text-sm font-medium">
{repositorio.nombre}
</p>
<p className="text-muted-foreground text-xs">
{repositorio.descripcion} · {repositorio.cantidadArchivos}{' '}
archivos
</p>
</div>
</Label>
))}
</div>
</div>
),
},
{
name: 'Subir archivos',
value: 'subir-archivos',
icon: Upload,
content: (
<div className="p-1">
<FileDropzone
persistentFiles={uploadedFiles}
onFilesChange={onFilesChange}
title="Sube archivos de referencia"
description="Documentos que serán usados como contexto para la generación"
autoScrollToDropzone={true}
/>
</div>
),
},
]
return (
<div className="flex w-full flex-col gap-1">
<Label>
Referencias para la IA{' '}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
(Opcional)
</span>
</Label>
<Tabs defaultValue="archivos-existentes" className="gap-4">
<TabsList className="w-full">
{tabs.map(({ icon: Icon, name, value }) => (
<TabsTrigger
key={value}
value={value}
className="flex items-center gap-1 px-2.5 sm:px-3"
>
<Icon />
<span className="hidden sm:inline">{name}</span>
</TabsTrigger>
))}
</TabsList>
<TabsContents className="bg-background mx-1 -mt-2 mb-1 h-full rounded-sm">
{tabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="animate-in fade-in duration-300 ease-out"
>
{tab.content}
</TabsContent>
))}
</TabsContents>
</Tabs>
</div>
)
}
export default ReferenciasParaIA

View File

@@ -1,7 +1,10 @@
import * as Icons from 'lucide-react'
import type { TipoOrigen } from '@/data/types/domain'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
import type {
NewPlanWizardState,
ModoCreacion,
SubModoClonado,
} from '@/features/planes/new/types'
import {
Card,
@@ -18,41 +21,18 @@ export function PasoModoCardGroup({
wizard: NewPlanWizardState
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
}) {
const isSelected = (m: TipoOrigen) => wizard.tipoOrigen === m
const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => {
const key = e.key
if (
key === 'Enter' ||
key === ' ' ||
key === 'Spacebar' ||
key === 'Space'
) {
e.preventDefault()
e.stopPropagation()
cb()
}
}
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
return (
<div className="grid gap-4 sm:grid-cols-3">
<Card
className={isSelected('MANUAL') ? 'ring-ring ring-2' : ''}
onClick={() =>
onChange(
(w): NewPlanWizardState => ({
onChange((w) => ({
...w,
tipoOrigen: 'MANUAL',
}),
)
}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange(
(w): NewPlanWizardState => ({
...w,
tipoOrigen: 'MANUAL',
}),
),
)
modoCreacion: 'MANUAL',
subModoClonado: undefined,
}))
}
role="button"
tabIndex={0}
@@ -68,22 +48,11 @@ export function PasoModoCardGroup({
<Card
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
onClick={() =>
onChange(
(w): NewPlanWizardState => ({
onChange((w) => ({
...w,
tipoOrigen: 'IA',
}),
)
}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange(
(w): NewPlanWizardState => ({
...w,
tipoOrigen: 'IA',
}),
),
)
modoCreacion: 'IA',
subModoClonado: undefined,
}))
}
role="button"
tabIndex={0}
@@ -99,15 +68,8 @@ export function PasoModoCardGroup({
</Card>
<Card
className={isSelected('OTRO') ? 'ring-ring ring-2' : ''}
onClick={() =>
onChange((w): NewPlanWizardState => ({ ...w, tipoOrigen: 'OTRO' }))
}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange((w): NewPlanWizardState => ({ ...w, tipoOrigen: 'OTRO' })),
)
}
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
role="button"
tabIndex={0}
>
@@ -117,34 +79,17 @@ export function PasoModoCardGroup({
</CardTitle>
<CardDescription>Desde un plan existente o archivos.</CardDescription>
</CardHeader>
{(wizard.tipoOrigen === 'OTRO' ||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
{wizard.modoCreacion === 'CLONADO' && (
<CardContent className="flex flex-col gap-3">
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange(
(w): NewPlanWizardState => ({
...w,
tipoOrigen: 'CLONADO_INTERNO',
}),
)
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' }))
}}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange(
(w): NewPlanWizardState => ({
...w,
tipoOrigen: 'CLONADO_INTERNO',
}),
),
)
}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
isSelected('CLONADO_INTERNO')
isSubSelected('INTERNO')
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
: 'border-border text-muted-foreground'
} `}
@@ -158,25 +103,10 @@ export function PasoModoCardGroup({
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
onChange(
(w): NewPlanWizardState => ({
...w,
tipoOrigen: 'CLONADO_TRADICIONAL',
}),
)
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' }))
}}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange(
(w): NewPlanWizardState => ({
...w,
tipoOrigen: 'CLONADO_TRADICIONAL',
}),
),
)
}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
isSelected('CLONADO_TRADICIONAL')
isSubSelected('TRADICIONAL')
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
: 'border-border text-muted-foreground'
} `}

View File

@@ -1,5 +1,4 @@
import type { UploadedFile } from './PasoDetallesPanel/FileDropZone'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
import type { NewPlanWizardState } from '@/features/planes/new/types'
import {
Card,
@@ -8,14 +7,10 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
PLANES_EXISTENTES,
ARCHIVOS,
REPOSITORIOS,
} from '@/features/planes/nuevo/catalogs'
import { formatFileSize } from '@/features/planes/utils/format-file-size'
export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
const modo = wizard.modoCreacion
const sub = wizard.subModoClonado
return (
<Card>
<CardHeader>
@@ -26,14 +21,6 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
</CardHeader>
<CardContent>
<div className="grid gap-2 text-sm">
{(() => {
// Precompute common derived values to avoid unnecessary optional chaining warnings
const archivosRef = wizard.iaConfig?.archivosReferencia ?? []
const repositoriosRef =
wizard.iaConfig?.repositoriosReferencia ?? []
const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? []
const contenido = (
<>
<div>
<span className="text-muted-foreground">Nombre: </span>
<span className="font-medium">
@@ -41,12 +28,10 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
</span>
</div>
<div>
<span className="text-muted-foreground">
Facultad/Carrera:{' '}
</span>
<span className="text-muted-foreground">Facultad/Carrera: </span>
<span className="font-medium">
{wizard.datosBasicos.facultad.nombre || '—'} /{' '}
{wizard.datosBasicos.carrera.nombre || '—'}
{wizard.datosBasicos.facultadId || '—'} /{' '}
{wizard.datosBasicos.carreraId || '—'}
</span>
</div>
<div>
@@ -58,128 +43,22 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
<div>
<span className="text-muted-foreground">Ciclos: </span>
<span className="font-medium">
{wizard.datosBasicos.numCiclos} (
{wizard.datosBasicos.tipoCiclo})
{wizard.datosBasicos.numCiclos} ({wizard.datosBasicos.tipoCiclo})
</span>
</div>
<div className="mt-2">
<span className="text-muted-foreground">Modo: </span>
<span className="font-medium">
{wizard.tipoOrigen === 'MANUAL' && 'Manual'}
{wizard.tipoOrigen === 'IA' && 'Generado con IA'}
{wizard.tipoOrigen === 'CLONADO_INTERNO' &&
{modo === 'MANUAL' && 'Manual'}
{modo === 'IA' && 'Generado con IA'}
{modo === 'CLONADO' &&
sub === 'INTERNO' &&
'Clonado desde plan del sistema'}
{wizard.tipoOrigen === 'CLONADO_TRADICIONAL' &&
{modo === 'CLONADO' &&
sub === 'TRADICIONAL' &&
'Importado desde documentos tradicionales'}
</span>
</div>
{wizard.tipoOrigen === 'CLONADO_INTERNO' && (
<div className="mt-2">
<span className="text-muted-foreground">Plan origen: </span>
<span className="font-medium">
{(() => {
const p = PLANES_EXISTENTES.find(
(x) => x.id === wizard.clonInterno?.planOrigenId,
)
return (
p?.nombre || wizard.clonInterno?.planOrigenId || '—'
)
})()}
</span>
</div>
)}
{wizard.tipoOrigen === 'CLONADO_TRADICIONAL' && (
<div className="mt-2">
<div className="font-medium">Documentos adjuntos</div>
<ul className="text-muted-foreground list-disc pl-5 text-xs">
<li>
<span className="text-foreground">Word del plan:</span>{' '}
{wizard.clonTradicional?.archivoWordPlanId?.name || '—'}
</li>
<li>
<span className="text-foreground">
Mapa curricular:
</span>{' '}
{wizard.clonTradicional?.archivoMapaExcelId?.name ||
'—'}
</li>
<li>
<span className="text-foreground">Asignaturas:</span>{' '}
{wizard.clonTradicional?.archivoAsignaturasExcelId
?.name || '—'}
</li>
</ul>
</div>
)}
{wizard.tipoOrigen === 'IA' && (
<div className="bg-muted/50 mt-2 rounded-md p-3">
<div>
<span className="text-muted-foreground">Enfoque: </span>
<span className="font-medium">
{wizard.iaConfig?.descripcionEnfoqueAcademico || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Notas: </span>
<span className="font-medium">
{wizard.iaConfig?.instruccionesAdicionalesIA || '—'}
</span>
</div>
{archivosRef.length > 0 && (
<div className="mt-2">
<div className="font-medium">Archivos existentes</div>
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{archivosRef.map((id) => {
const a = ARCHIVOS.find((x) => x.id === id)
return (
<li key={id}>
<span className="text-foreground">
{a?.nombre || id}
</span>{' '}
{a?.tamaño ? <span>· {a.tamaño}</span> : null}
</li>
)
})}
</ul>
</div>
)}
{repositoriosRef.length > 0 && (
<div className="mt-2">
<div className="font-medium">Repositorios</div>
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{repositoriosRef.map((id) => {
const r = REPOSITORIOS.find((x) => x.id === id)
return (
<li key={id}>
<span className="text-foreground">
{r?.nombre || id}
</span>{' '}
{r?.cantidadArchivos ? (
<span>· {r.cantidadArchivos} archivos</span>
) : null}
</li>
)
})}
</ul>
</div>
)}
{adjuntos.length > 0 && (
<div className="mt-2">
<div className="font-medium">Adjuntos</div>
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{adjuntos.map((f: UploadedFile) => (
<li key={f.id}>
<span className="text-foreground">
{f.file.name}
</span>{' '}
<span>· {formatFileSize(f.file.size)}</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
{wizard.resumen.previewPlan && (
<div className="bg-muted mt-2 rounded-md p-3">
<div className="font-medium">Preview IA</div>
@@ -189,10 +68,6 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
</div>
</div>
)}
</>
)
return contenido
})()}
</div>
</CardContent>
</Card>

View File

@@ -1,306 +1,39 @@
import { useNavigate } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { AIGeneratePlanInput } from '@/data'
import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
// import type { Database } from '@/types/supabase'
import type { RealtimeChannel } from '@supabase/supabase-js'
import { Button } from '@/components/ui/button'
import { plans_get_maybe } from '@/data/api/plans.api'
import {
useCreatePlanManual,
useDeletePlanEstudio,
useGeneratePlanAI,
} from '@/data/hooks/usePlans'
import { supabaseBrowser } from '@/data/supabase/client'
export function WizardControls({
errorMessage,
onPrev,
onNext,
onCreate,
disablePrev,
disableNext,
disableCreate,
isLastStep,
wizard,
setWizard,
}: {
errorMessage?: string | null
onPrev: () => void
onNext: () => void
onCreate: () => void
disablePrev: boolean
disableNext: boolean
disableCreate: boolean
isLastStep: boolean
wizard: NewPlanWizardState
setWizard: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
}) {
const navigate = useNavigate()
const generatePlanAI = useGeneratePlanAI()
const createPlanManual = useCreatePlanManual()
const deletePlan = useDeletePlanEstudio()
const [isSpinningIA, setIsSpinningIA] = useState(false)
const cancelledRef = useRef(false)
const realtimeChannelRef = useRef<RealtimeChannel | null>(null)
const watchPlanIdRef = useRef<string | null>(null)
const watchTimeoutRef = useRef<number | null>(null)
useEffect(() => {
cancelledRef.current = false
return () => {
cancelledRef.current = true
}
}, [])
const stopPlanWatch = useCallback(() => {
if (watchTimeoutRef.current) {
window.clearTimeout(watchTimeoutRef.current)
watchTimeoutRef.current = null
}
watchPlanIdRef.current = null
const ch = realtimeChannelRef.current
if (ch) {
realtimeChannelRef.current = null
try {
supabaseBrowser().removeChannel(ch)
} catch {
// noop
}
}
}, [])
useEffect(() => {
return () => {
stopPlanWatch()
}
}, [stopPlanWatch])
const checkPlanStateAndAct = useCallback(
async (planId: string) => {
if (cancelledRef.current) return
if (watchPlanIdRef.current !== planId) return
const plan = await plans_get_maybe(planId as any)
if (!plan) return
const clave = String(plan.estados_plan?.clave ?? '').toUpperCase()
if (clave.startsWith('GENERANDO')) return
if (clave.startsWith('BORRADOR')) {
stopPlanWatch()
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false }))
navigate({
to: `/planes/${plan.id}`,
state: { showConfetti: true },
})
return
}
if (clave.startsWith('FALLID')) {
stopPlanWatch()
setIsSpinningIA(false)
deletePlan
.mutateAsync(plan.id)
.catch(() => {
// Si falla el borrado, igual mostramos el error.
})
.finally(() => {
setWizard((w) => ({
...w,
isLoading: false,
errorMessage: 'La generación del plan falló',
}))
})
}
},
[deletePlan, navigate, setWizard, stopPlanWatch],
)
const beginPlanWatch = useCallback(
(planId: string) => {
stopPlanWatch()
watchPlanIdRef.current = planId
watchTimeoutRef.current = window.setTimeout(
() => {
if (cancelledRef.current) return
if (watchPlanIdRef.current !== planId) return
stopPlanWatch()
setIsSpinningIA(false)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage:
'La generación está tardando demasiado. Intenta de nuevo en unos minutos.',
}))
},
6 * 60 * 1000,
)
const supabase = supabaseBrowser()
const channel = supabase.channel(`planes-status-${planId}`)
realtimeChannelRef.current = channel
channel.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'planes_estudio',
filter: `id=eq.${planId}`,
},
() => {
void checkPlanStateAndAct(planId)
},
)
channel.subscribe((status) => {
const st = status as
| 'SUBSCRIBED'
| 'TIMED_OUT'
| 'CLOSED'
| 'CHANNEL_ERROR'
if (cancelledRef.current) return
if (st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
stopPlanWatch()
setIsSpinningIA(false)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage:
'No se pudo suscribir al estado del plan. Intenta de nuevo.',
}))
}
})
// Fallback inmediato por si el plan ya cambió antes de suscribir.
void checkPlanStateAndAct(planId)
},
[checkPlanStateAndAct, setWizard, stopPlanWatch],
)
const handleCreate = async () => {
// Start loading
setWizard(
(w: NewPlanWizardState): NewPlanWizardState => ({
...w,
isLoading: true,
errorMessage: null,
}),
)
try {
if (wizard.tipoOrigen === 'IA') {
const tipoCicloSafe = (wizard.datosBasicos.tipoCiclo ||
'Semestre') as any
const numCiclosSafe =
typeof wizard.datosBasicos.numCiclos === 'number'
? wizard.datosBasicos.numCiclos
: 1
const aiInput: AIGeneratePlanInput = {
datosBasicos: {
nombrePlan: wizard.datosBasicos.nombrePlan,
carreraId: wizard.datosBasicos.carrera.id,
facultadId: wizard.datosBasicos.facultad.id,
nivel: wizard.datosBasicos.nivel as string,
tipoCiclo: tipoCicloSafe,
numCiclos: numCiclosSafe,
estructuraPlanId: wizard.datosBasicos.estructuraPlanId as string,
},
iaConfig: {
descripcionEnfoqueAcademico:
wizard.iaConfig?.descripcionEnfoqueAcademico || '',
instruccionesAdicionalesIA:
wizard.iaConfig?.instruccionesAdicionalesIA || '',
archivosReferencia: wizard.iaConfig?.archivosReferencia || [],
repositoriosIds: wizard.iaConfig?.repositoriosReferencia || [],
archivosAdjuntos: wizard.iaConfig?.archivosAdjuntos || [],
},
}
console.log(`${new Date().toISOString()} - Enviando a generar plan IA`)
setIsSpinningIA(true)
const resp: any = await generatePlanAI.mutateAsync(aiInput as any)
const planId = resp?.plan?.id ?? resp?.id
console.log(`${new Date().toISOString()} - Plan IA generado`, resp)
if (!planId) {
throw new Error('No se pudo obtener el id del plan generado por IA')
}
// Inicia realtime; los efectos navegan o marcan error.
beginPlanWatch(String(planId))
return
}
if (wizard.tipoOrigen === 'MANUAL') {
// Crear plan vacío manualmente usando el hook
const plan = await createPlanManual.mutateAsync({
carreraId: wizard.datosBasicos.carrera.id,
estructuraId: wizard.datosBasicos.estructuraPlanId as string,
nombre: wizard.datosBasicos.nombrePlan,
nivel: wizard.datosBasicos.nivel as NivelPlanEstudio,
tipoCiclo: wizard.datosBasicos.tipoCiclo as TipoCiclo,
numCiclos: (wizard.datosBasicos.numCiclos as number) || 1,
datos: {},
})
// Navegar al nuevo plan
navigate({
to: `/planes/${plan.id}`,
state: { showConfetti: true },
})
return
}
} catch (err: any) {
setIsSpinningIA(false)
stopPlanWatch()
setWizard((w) => ({
...w,
isLoading: false,
errorMessage: err?.message ?? 'Error generando el plan',
}))
} finally {
// Si entramos en watch realtime, el loading se corta desde checkPlanStateAndAct.
}
}
return (
<div className="flex grow items-center justify-between">
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
Anterior
</Button>
<div className="mx-2 flex-1">
<div className="flex items-center justify-between">
<div className="flex-1">
{errorMessage && (
<span className="text-destructive text-sm font-medium">
{errorMessage}
</span>
)}
</div>
<div className="mx-2 flex w-5 items-center justify-center">
<Loader2
className={
wizard.tipoOrigen === 'IA' && isSpinningIA
? 'text-muted-foreground h-6 w-6 animate-spin'
: 'h-6 w-6 opacity-0'
}
aria-hidden={!(wizard.tipoOrigen === 'IA' && isSpinningIA)}
/>
</div>
<div className="flex gap-4">
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
Anterior
</Button>
{isLastStep ? (
<Button onClick={handleCreate} disabled={disableCreate}>
<Button onClick={onCreate} disabled={disableCreate}>
Crear plan
</Button>
) : (
@@ -309,5 +42,6 @@ export function WizardControls({
</Button>
)}
</div>
</div>
)
}

View File

@@ -1,32 +0,0 @@
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
const CheckboxCardDemo = () => {
return (
<div className="space-y-2">
<Label className="border-border hover:border-primary/30 hover:bg-accent/50 flex cursor-pointer items-center items-start gap-2 gap-3 rounded-lg border p-3 transition-colors has-[[aria-checked=true]]:border-blue-600 has-[[aria-checked=true]]:bg-blue-50 dark:has-[[aria-checked=true]]:border-blue-900 dark:has-[[aria-checked=true]]:bg-blue-950">
<Checkbox
defaultChecked
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
<div className="grid gap-1.5 font-normal">
<p className="text-sm leading-none font-medium">Auto Start</p>
<p className="text-muted-foreground text-sm">
Starting with your OS.
</p>
</div>
</Label>
<Label className="hover:bg-accent/50 flex items-start gap-2 rounded-lg border p-3 has-[[aria-checked=true]]:border-blue-600 has-[[aria-checked=true]]:bg-blue-50 dark:has-[[aria-checked=true]]:border-blue-900 dark:has-[[aria-checked=true]]:bg-blue-950">
<Checkbox className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" />
<div className="grid gap-1.5 font-normal">
<p className="text-sm leading-none font-medium">Auto update</p>
<p className="text-muted-foreground text-sm">
Download and install new version
</p>
</div>
</Label>
</div>
)
}
export default CheckboxCardDemo

View File

@@ -1,76 +0,0 @@
import { BookIcon, GiftIcon, HeartIcon } from 'lucide-react'
import CheckboxCardDemo from '../checkbox/checkbox-13'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
const tabs = [
{
name: 'Explore',
value: 'explore',
icon: BookIcon,
content: (
<>
<CheckboxCardDemo />
</>
),
},
{
name: 'Favorites',
value: 'favorites',
icon: HeartIcon,
content: (
<>
All your{' '}
<span className="text-foreground font-semibold">favorites</span> are
saved here. Revisit articles, collections, and moments you love, any
time you want a little inspiration.
</>
),
},
{
name: 'Surprise',
value: 'surprise',
icon: GiftIcon,
content: (
<>
<span className="text-foreground font-semibold">Surprise!</span>{' '}
Here&apos;s something unexpecteda fun fact, a quirky tip, or a daily
challenge. Come back for a new surprise every day!
</>
),
},
]
const TabsWithIconDemo = () => {
return (
<div className="w-full">
<Tabs defaultValue="explore" className="gap-4">
<TabsList className="w-full">
{tabs.map(({ icon: Icon, name, value }) => (
<TabsTrigger
key={value}
value={value}
className="flex items-center gap-1 px-2.5 sm:px-3"
>
<Icon />
{name}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="animate-in fade-in duration-300 ease-out"
>
<p className="text-muted-foreground text-sm">{tab.content}</p>
</TabsContent>
))}
</Tabs>
</div>
)
}
export default TabsWithIconDemo

View File

@@ -1,72 +0,0 @@
import { Tabs, TabsContent, TabsContents, TabsList, TabsTrigger } from '@/components/ui/motion-tabs'
const tabs = [
{
name: 'Explore',
value: 'explore',
content: (
<>
Discover <span className='text-foreground font-semibold'>fresh ideas</span>, trending topics, and hidden gems
curated just for you. Start exploring and let your curiosity lead the way!
</>
)
},
{
name: 'Favorites',
value: 'favorites',
content: (
<>
All your <span className='text-foreground font-semibold'>favorites</span> are saved here. Revisit articles,
collections, and moments you love, any time you want a little inspiration.
</>
)
},
{
name: 'Surprise Me',
value: 'surprise',
content: (
<>
<span className='text-foreground font-semibold'>Surprise!</span> Here&apos;s something unexpecteda fun fact, a
quirky tip, or a daily challenge. Come back for a new surprise every day!
</>
)
}
]
const AnimatedTabsDemo = () => {
return (
<div className='w-full max-w-md'>
<Tabs defaultValue='explore' className='gap-4'>
<TabsList>
{tabs.map(tab => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.name}
</TabsTrigger>
))}
</TabsList>
<TabsContents className='bg-background mx-1 -mt-2 mb-1 h-full rounded-sm'>
{tabs.map(tab => (
<TabsContent key={tab.value} value={tab.value}>
<p className='text-muted-foreground text-sm'>{tab.content}</p>
</TabsContent>
))}
</TabsContents>
</Tabs>
<p className='text-muted-foreground mt-4 text-center text-xs'>
Inspired by{' '}
<a
className='hover:text-foreground underline'
href='https://animate-ui.com/docs/components/tabs'
target='_blank'
rel='noopener noreferrer'
>
Animate UI
</a>
</p>
</div>
)
}
export default AnimatedTabsDemo

View File

@@ -1,44 +0,0 @@
import { Link, useRouter } from '@tanstack/react-router'
import { FileQuestion, Home, ArrowLeft } from 'lucide-react'
import { Button } from './button'
interface NotFoundPageProps {
title?: string
message?: string
children?: React.ReactNode
}
export function NotFoundPage({
title = 'Página no encontrada',
message = 'Lo sentimos, no pudimos encontrar lo que buscabas. Es posible que la página haya sido movida o eliminada.',
children,
}: NotFoundPageProps) {
const router = useRouter()
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center p-4 text-center">
<div className="bg-muted mb-6 rounded-full p-6">
<FileQuestion className="text-muted-foreground h-12 w-12" />
</div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">{title}</h1>
<p className="text-muted-foreground mb-8 max-w-125">{message}</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={() => router.history.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Regresar
</Button>
<Button asChild>
<Link to="/">
<Home className="mr-2 h-4 w-4" />
Ir al inicio
</Link>
</Button>
{children}
</div>
</div>
)
}

View File

@@ -1,14 +1,13 @@
interface Props {
text?: string
disabled?: boolean
}
export function SubmitButton({ text = 'Iniciar sesión', disabled }: Props) {
export function SubmitButton({ text = 'Iniciar sesión' }: Props) {
return (
<button
type="submit"
disabled={disabled}
className="w-full rounded-lg bg-[#7b0f1d] py-2 font-semibold text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
className="w-full bg-[#7b0f1d] text-white py-2 rounded-lg
font-semibold hover:opacity-90 transition"
>
{text}
</button>

View File

@@ -1,64 +0,0 @@
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -1,30 +0,0 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -1,250 +0,0 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -1,133 +0,0 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -1,48 +0,0 @@
// src/components/ui/lateral-confetti.tsx
import confetti from 'canvas-confetti'
export function lateralConfetti() {
// 1. Reset para limpiar cualquier configuración vieja pegada en memoria
confetti.reset()
const duration = 1500
const end = Date.now() + duration
// 2. Colores vibrantes (cálidos primero)
const vibrantColors = [
'#FF0000', // Rojo puro
'#fcff42', // Amarillo
'#88ff5a', // Verde
'#26ccff', // Azul
'#a25afd', // Morado
]
;(function frame() {
const commonSettings = {
particleCount: 5,
spread: 55,
// origin: { x: 0.5 }, // No necesario si definimos origin abajo, pero útil en otros contextos
colors: vibrantColors,
zIndex: 99999,
}
// Cañón izquierdo
confetti({
...commonSettings,
angle: 60,
origin: { x: 0, y: 0.6 },
})
// Cañón derecho
confetti({
...commonSettings,
angle: 120,
origin: { x: 1, y: 0.6 },
})
if (Date.now() < end) {
requestAnimationFrame(frame)
}
})()
}

View File

@@ -1,549 +0,0 @@
import * as React from 'react'
import type { Transition } from 'motion/react'
import { AnimatePresence, motion } from 'motion/react'
import { cn } from '@/lib/utils'
type MotionHighlightMode = 'children' | 'parent'
type Bounds = {
top: number
left: number
width: number
height: number
}
type MotionHighlightContextType<T extends string> = {
mode: MotionHighlightMode
activeValue: T | null
setActiveValue: (value: T | null) => void
setBounds: (bounds: DOMRect) => void
clearBounds: () => void
id: string
hover: boolean
className?: string
activeClassName?: string
setActiveClassName: (className: string) => void
transition?: Transition
disabled?: boolean
enabled?: boolean
exitDelay?: number
forceUpdateBounds?: boolean
}
const MotionHighlightContext = React.createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MotionHighlightContextType<any> | undefined
>(undefined)
function useMotionHighlight<T extends string>(): MotionHighlightContextType<T> {
const context = React.useContext(MotionHighlightContext)
if (!context) {
throw new Error('useMotionHighlight must be used within a MotionHighlightProvider')
}
return context as unknown as MotionHighlightContextType<T>
}
type BaseMotionHighlightProps<T extends string> = {
mode?: MotionHighlightMode
value?: T | null
defaultValue?: T | null
onValueChange?: (value: T | null) => void
className?: string
transition?: Transition
hover?: boolean
disabled?: boolean
enabled?: boolean
exitDelay?: number
}
type ParentModeMotionHighlightProps = {
boundsOffset?: Partial<Bounds>
containerClassName?: string
forceUpdateBounds?: boolean
}
type ControlledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> &
ParentModeMotionHighlightProps & {
mode: 'parent'
controlledItems: true
children: React.ReactNode
}
type ControlledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & {
mode?: 'children' | undefined
controlledItems: true
children: React.ReactNode
}
type UncontrolledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> &
ParentModeMotionHighlightProps & {
mode: 'parent'
controlledItems?: false
itemsClassName?: string
children: React.ReactElement | React.ReactElement[]
}
type UncontrolledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & {
mode?: 'children'
controlledItems?: false
itemsClassName?: string
children: React.ReactElement | React.ReactElement[]
}
type MotionHighlightProps<T extends string> = React.ComponentProps<'div'> &
(
| ControlledParentModeMotionHighlightProps<T>
| ControlledChildrenModeMotionHighlightProps<T>
| UncontrolledParentModeMotionHighlightProps<T>
| UncontrolledChildrenModeMotionHighlightProps<T>
)
function MotionHighlight<T extends string>({ ref, ...props }: MotionHighlightProps<T>) {
const {
children,
value,
defaultValue,
onValueChange,
className,
transition = { type: 'spring', stiffness: 350, damping: 35 },
hover = false,
enabled = true,
controlledItems,
disabled = false,
exitDelay = 0.2,
mode = 'children'
} = props
const localRef = React.useRef<HTMLDivElement>(null)
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement)
const [activeValue, setActiveValue] = React.useState<T | null>(value ?? defaultValue ?? null)
const [boundsState, setBoundsState] = React.useState<Bounds | null>(null)
const [activeClassNameState, setActiveClassNameState] = React.useState<string>('')
const safeSetActiveValue = React.useCallback(
(id: T | null) => {
setActiveValue(prev => (prev === id ? prev : id))
if (id !== activeValue) onValueChange?.(id as T)
},
[activeValue, onValueChange]
)
const safeSetBounds = React.useCallback(
(bounds: DOMRect) => {
if (!localRef.current) return
const boundsOffset = (props as ParentModeMotionHighlightProps)?.boundsOffset ?? {
top: 0,
left: 0,
width: 0,
height: 0
}
const containerRect = localRef.current.getBoundingClientRect()
const newBounds: Bounds = {
top: bounds.top - containerRect.top + (boundsOffset.top ?? 0),
left: bounds.left - containerRect.left + (boundsOffset.left ?? 0),
width: bounds.width + (boundsOffset.width ?? 0),
height: bounds.height + (boundsOffset.height ?? 0)
}
setBoundsState(prev => {
if (
prev &&
prev.top === newBounds.top &&
prev.left === newBounds.left &&
prev.width === newBounds.width &&
prev.height === newBounds.height
) {
return prev
}
return newBounds
})
},
[props]
)
const clearBounds = React.useCallback(() => {
setBoundsState(prev => (prev === null ? prev : null))
}, [])
React.useEffect(() => {
if (value !== undefined) setActiveValue(value)
else if (defaultValue !== undefined) setActiveValue(defaultValue)
}, [value, defaultValue])
const id = React.useId()
React.useEffect(() => {
if (mode !== 'parent') return
const container = localRef.current
if (!container) return
const onScroll = () => {
if (!activeValue) return
const activeEl = container.querySelector<HTMLElement>(`[data-value="${activeValue}"][data-highlight="true"]`)
if (activeEl) safeSetBounds(activeEl.getBoundingClientRect())
}
container.addEventListener('scroll', onScroll, { passive: true })
return () => container.removeEventListener('scroll', onScroll)
}, [mode, activeValue, safeSetBounds])
const render = React.useCallback(
(children: React.ReactNode) => {
if (mode === 'parent') {
return (
<div
ref={localRef}
data-slot='motion-highlight-container'
className={cn('relative', (props as ParentModeMotionHighlightProps)?.containerClassName)}
>
<AnimatePresence initial={false}>
{boundsState && (
<motion.div
data-slot='motion-highlight'
animate={{
top: boundsState.top,
left: boundsState.left,
width: boundsState.width,
height: boundsState.height,
opacity: 1
}}
initial={{
top: boundsState.top,
left: boundsState.left,
width: boundsState.width,
height: boundsState.height,
opacity: 0
}}
exit={{
opacity: 0,
transition: {
...transition,
delay: (transition?.delay ?? 0) + (exitDelay ?? 0)
}
}}
transition={transition}
className={cn('bg-muted absolute z-0', className, activeClassNameState)}
/>
)}
</AnimatePresence>
{children}
</div>
)
}
return children
},
[mode, props, boundsState, transition, exitDelay, className, activeClassNameState]
)
return (
<MotionHighlightContext.Provider
value={{
mode,
activeValue,
setActiveValue: safeSetActiveValue,
id,
hover,
className,
transition,
disabled,
enabled,
exitDelay,
setBounds: safeSetBounds,
clearBounds,
activeClassName: activeClassNameState,
setActiveClassName: setActiveClassNameState,
forceUpdateBounds: (props as ParentModeMotionHighlightProps)?.forceUpdateBounds
}}
>
{enabled
? controlledItems
? render(children)
: render(
React.Children.map(children, (child, index) => (
<MotionHighlightItem key={index} className={props?.itemsClassName}>
{child}
</MotionHighlightItem>
))
)
: children}
</MotionHighlightContext.Provider>
)
}
function getNonOverridingDataAttributes(
element: React.ReactElement,
dataAttributes: Record<string, unknown>
): Record<string, unknown> {
return Object.keys(dataAttributes).reduce<Record<string, unknown>>((acc, key) => {
if ((element.props as Record<string, unknown>)[key] === undefined) {
acc[key] = dataAttributes[key]
}
return acc
}, {})
}
type ExtendedChildProps = React.ComponentProps<'div'> & {
id?: string
ref?: React.Ref<HTMLElement>
'data-active'?: string
'data-value'?: string
'data-disabled'?: boolean
'data-highlight'?: boolean
'data-slot'?: string
}
type MotionHighlightItemProps = React.ComponentProps<'div'> & {
children: React.ReactElement
id?: string
value?: string
className?: string
transition?: Transition
activeClassName?: string
disabled?: boolean
exitDelay?: number
asChild?: boolean
forceUpdateBounds?: boolean
}
function MotionHighlightItem({
ref,
children,
id,
value,
className,
transition,
disabled = false,
activeClassName,
exitDelay,
asChild = false,
forceUpdateBounds,
...props
}: MotionHighlightItemProps) {
const itemId = React.useId()
const {
activeValue,
setActiveValue,
mode,
setBounds,
clearBounds,
hover,
enabled,
className: contextClassName,
transition: contextTransition,
id: contextId,
disabled: contextDisabled,
exitDelay: contextExitDelay,
forceUpdateBounds: contextForceUpdateBounds,
setActiveClassName
} = useMotionHighlight()
const element = children as React.ReactElement<ExtendedChildProps>
const childValue = id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId
const isActive = activeValue === childValue
const isDisabled = disabled === undefined ? contextDisabled : disabled
const itemTransition = transition ?? contextTransition
const localRef = React.useRef<HTMLDivElement>(null)
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement)
React.useEffect(() => {
if (mode !== 'parent') return
let rafId: number
let previousBounds: Bounds | null = null
const shouldUpdateBounds = forceUpdateBounds === true || (contextForceUpdateBounds && forceUpdateBounds !== false)
const updateBounds = () => {
if (!localRef.current) return
const bounds = localRef.current.getBoundingClientRect()
if (shouldUpdateBounds) {
if (
previousBounds &&
previousBounds.top === bounds.top &&
previousBounds.left === bounds.left &&
previousBounds.width === bounds.width &&
previousBounds.height === bounds.height
) {
rafId = requestAnimationFrame(updateBounds)
return
}
previousBounds = bounds
rafId = requestAnimationFrame(updateBounds)
}
setBounds(bounds)
}
if (isActive) {
updateBounds()
setActiveClassName(activeClassName ?? '')
} else if (!activeValue) clearBounds()
if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId)
}, [
mode,
isActive,
activeValue,
setBounds,
clearBounds,
activeClassName,
setActiveClassName,
forceUpdateBounds,
contextForceUpdateBounds
])
if (!React.isValidElement(children)) return children
const dataAttributes = {
'data-active': isActive ? 'true' : 'false',
'aria-selected': isActive,
'data-disabled': isDisabled,
'data-value': childValue,
'data-highlight': true
}
const commonHandlers = hover
? {
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(childValue)
element.props.onMouseEnter?.(e)
},
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(null)
element.props.onMouseLeave?.(e)
}
}
: {
onClick: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(childValue)
element.props.onClick?.(e)
}
}
if (asChild) {
if (mode === 'children') {
return React.cloneElement(
element,
{
key: childValue,
ref: localRef,
className: cn('relative', element.props.className),
...getNonOverridingDataAttributes(element, {
...dataAttributes,
'data-slot': 'motion-highlight-item-container'
}),
...commonHandlers,
...props
},
<>
<AnimatePresence initial={false}>
{isActive && !isDisabled && (
<motion.div
layoutId={`transition-background-${contextId}`}
data-slot='motion-highlight'
className={cn('bg-muted absolute inset-0 z-0', contextClassName, activeClassName)}
transition={itemTransition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: {
...itemTransition,
delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0)
}
}}
{...dataAttributes}
/>
)}
</AnimatePresence>
<div data-slot='motion-highlight-item' className={cn('relative z-[1]', className)} {...dataAttributes}>
{children}
</div>
</>
)
}
return React.cloneElement(element, {
ref: localRef,
...getNonOverridingDataAttributes(element, {
...dataAttributes,
'data-slot': 'motion-highlight-item'
}),
...commonHandlers
})
}
return enabled ? (
<div
key={childValue}
ref={localRef}
data-slot='motion-highlight-item-container'
className={cn(mode === 'children' && 'relative', className)}
{...dataAttributes}
{...props}
{...commonHandlers}
>
{mode === 'children' && (
<AnimatePresence initial={false}>
{isActive && !isDisabled && (
<motion.div
layoutId={`transition-background-${contextId}`}
data-slot='motion-highlight'
className={cn('bg-muted absolute inset-0 z-0', contextClassName, activeClassName)}
transition={itemTransition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: {
...itemTransition,
delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0)
}
}}
{...dataAttributes}
/>
)}
</AnimatePresence>
)}
{React.cloneElement(element, {
className: cn('relative z-[1]', element.props.className),
...getNonOverridingDataAttributes(element, {
...dataAttributes,
'data-slot': 'motion-highlight-item'
})
})}
</div>
) : (
children
)
}
export {
MotionHighlight,
MotionHighlightItem,
useMotionHighlight,
type MotionHighlightProps,
type MotionHighlightItemProps
}

View File

@@ -1,261 +0,0 @@
'use client'
import * as React from 'react'
import { motion, type Transition, type HTMLMotionProps } from 'motion/react'
import { cn } from '@/lib/utils'
import { MotionHighlight, MotionHighlightItem } from '@/components/ui/motion-highlight'
type TabsContextType<T extends string> = {
activeValue: T
handleValueChange: (value: T) => void
registerTrigger: (value: T, node: HTMLElement | null) => void
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TabsContext = React.createContext<TabsContextType<any> | undefined>(undefined)
function useTabs<T extends string = string>(): TabsContextType<T> {
const context = React.useContext(TabsContext)
if (!context) {
throw new Error('useTabs must be used within a TabsProvider')
}
return context
}
type BaseTabsProps = React.ComponentProps<'div'> & {
children: React.ReactNode
}
type UnControlledTabsProps<T extends string = string> = BaseTabsProps & {
defaultValue?: T
value?: never
onValueChange?: never
}
type ControlledTabsProps<T extends string = string> = BaseTabsProps & {
value: T
onValueChange?: (value: T) => void
defaultValue?: never
}
type TabsProps<T extends string = string> = UnControlledTabsProps<T> | ControlledTabsProps<T>
function Tabs<T extends string = string>({
defaultValue,
value,
onValueChange,
children,
className,
...props
}: TabsProps<T>) {
const [activeValue, setActiveValue] = React.useState<T | undefined>(defaultValue ?? undefined)
const triggersRef = React.useRef(new Map<string, HTMLElement>())
const initialSet = React.useRef(false)
const isControlled = value !== undefined
React.useEffect(() => {
if (!isControlled && activeValue === undefined && triggersRef.current.size > 0 && !initialSet.current) {
const firstTab = Array.from(triggersRef.current.keys())[0]
setActiveValue(firstTab as T)
initialSet.current = true
}
}, [activeValue, isControlled])
const registerTrigger = (value: string, node: HTMLElement | null) => {
if (node) {
triggersRef.current.set(value, node)
if (!isControlled && activeValue === undefined && !initialSet.current) {
setActiveValue(value as T)
initialSet.current = true
}
} else {
triggersRef.current.delete(value)
}
}
const handleValueChange = (val: T) => {
if (!isControlled) setActiveValue(val)
else onValueChange?.(val)
}
return (
<TabsContext.Provider
value={{
activeValue: (value ?? activeValue)!,
handleValueChange,
registerTrigger
}}
>
<div data-slot='tabs' className={cn('flex flex-col gap-2', className)} {...props}>
{children}
</div>
</TabsContext.Provider>
)
}
type TabsListProps = React.ComponentProps<'div'> & {
children: React.ReactNode
activeClassName?: string
transition?: Transition
}
function TabsList({
children,
className,
activeClassName,
transition = {
type: 'spring',
stiffness: 200,
damping: 25
},
...props
}: TabsListProps) {
const { activeValue } = useTabs()
return (
<MotionHighlight
controlledItems
className={cn('bg-background rounded-sm shadow-sm', activeClassName)}
value={activeValue}
transition={transition}
>
<div
role='tablist'
data-slot='tabs-list'
className={cn(
'bg-muted text-muted-foreground inline-flex h-10 w-fit items-center justify-center rounded-lg p-[4px]',
className
)}
{...props}
>
{children}
</div>
</MotionHighlight>
)
}
type TabsTriggerProps = HTMLMotionProps<'button'> & {
value: string
children: React.ReactNode
}
function TabsTrigger({ ref, value, children, className, ...props }: TabsTriggerProps) {
const { activeValue, handleValueChange, registerTrigger } = useTabs()
const localRef = React.useRef<HTMLButtonElement | null>(null)
React.useImperativeHandle(ref, () => localRef.current as HTMLButtonElement)
React.useEffect(() => {
registerTrigger(value, localRef.current)
return () => registerTrigger(value, null)
}, [value, registerTrigger])
return (
<MotionHighlightItem value={value} className='size-full'>
<motion.button
ref={localRef}
data-slot='tabs-trigger'
role='tab'
onClick={() => handleValueChange(value)}
data-state={activeValue === value ? 'active' : 'inactive'}
className={cn(
'ring-offset-background focus-visible:ring-ring data-[state=active]:text-foreground z-[1] inline-flex size-full cursor-pointer items-center justify-center rounded-sm px-2 py-1 text-sm font-medium whitespace-nowrap transition-transform focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
className
)}
{...props}
>
{children}
</motion.button>
</MotionHighlightItem>
)
}
type TabsContentsProps = React.ComponentProps<'div'> & {
children: React.ReactNode
transition?: Transition
}
function TabsContents({
children,
className,
transition = {
type: 'spring',
stiffness: 300,
damping: 30,
bounce: 0,
restDelta: 0.01
},
...props
}: TabsContentsProps) {
const { activeValue } = useTabs()
const childrenArray = React.Children.toArray(children)
const activeIndex = childrenArray.findIndex(
(child): child is React.ReactElement<{ value: string }> =>
React.isValidElement(child) &&
typeof child.props === 'object' &&
child.props !== null &&
'value' in child.props &&
child.props.value === activeValue
)
return (
<div data-slot='tabs-contents' className={cn('overflow-hidden', className)} {...props}>
<motion.div className='-mx-2 flex' animate={{ x: activeIndex * -100 + '%' }} transition={transition}>
{childrenArray.map((child, index) => (
<div key={index} className='w-full shrink-0 px-2'>
{child}
</div>
))}
</motion.div>
</div>
)
}
type TabsContentProps = HTMLMotionProps<'div'> & {
value: string
children: React.ReactNode
}
function TabsContent({ children, value, className, ...props }: TabsContentProps) {
const { activeValue } = useTabs()
const isActive = activeValue === value
return (
<motion.div
role='tabpanel'
data-slot='tabs-content'
className={cn('overflow-hidden', className)}
initial={{ filter: 'blur(0px)' }}
animate={{ filter: isActive ? 'blur(0px)' : 'blur(2px)' }}
exit={{ filter: 'blur(0px)' }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
{...props}
>
{children}
</motion.div>
)
}
export {
Tabs,
TabsList,
TabsTrigger,
TabsContents,
TabsContent,
useTabs,
type TabsContextType,
type TabsProps,
type TabsListProps,
type TabsTriggerProps,
type TabsContentsProps,
type TabsContentProps
}

View File

@@ -1,13 +0,0 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -1,41 +0,0 @@
import { useState } from 'react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
export function StepWithTooltip({
title,
desc,
}: {
title: string
desc: string
}) {
const [isOpen, setIsOpen] = useState(false)
return (
<TooltipProvider delayDuration={0}>
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
<TooltipTrigger asChild>
<span
className="cursor-help decoration-dotted underline-offset-4 hover:underline"
onClick={(e) => {
e.stopPropagation()
setIsOpen((prev) => !prev)
}}
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
{title}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-50 text-xs">
<p>{desc}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -1,52 +0,0 @@
import * as Icons from 'lucide-react'
import { CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent } from '@/components/ui/dialog'
export function WizardLayout({
title,
onClose,
headerSlot,
footerSlot,
children,
}: {
title: string
onClose: () => void
headerSlot?: React.ReactNode
footerSlot?: React.ReactNode
children: React.ReactNode
}) {
return (
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
<DialogContent
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
onInteractOutside={(e) => {
e.preventDefault()
}}
>
<div className="z-10 flex-none border-b bg-white">
<CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-4">
<CardTitle>{title}</CardTitle>
<button
onClick={onClose}
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none"
>
<Icons.X className="h-4 w-4" />
<span className="sr-only">Cerrar</span>
</button>
</CardHeader>
{headerSlot ? <div className="px-6 pb-6">{headerSlot}</div> : null}
</div>
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
{children}
</div>
{footerSlot ? (
<div className="flex-none border-t bg-white p-6">{footerSlot}</div>
) : null}
</DialogContent>
</Dialog>
)
}

View File

@@ -1,66 +0,0 @@
import { CircularProgress } from '@/components/CircularProgress'
import { StepWithTooltip } from '@/components/wizard/StepWithTooltip'
export function WizardResponsiveHeader({
wizard,
methods,
titleOverrides,
}: {
wizard: any
methods: any
titleOverrides?: Record<string, string>
}) {
const idx = wizard.utils.getIndex(methods.current.id)
const totalSteps = wizard.steps.length
const currentIndex = idx + 1
const hasNextStep = idx < totalSteps - 1
const nextStep = wizard.steps[currentIndex]
const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title
return (
<>
<div className="block sm:hidden">
<div className="flex items-center gap-5">
<CircularProgress current={currentIndex} total={totalSteps} />
<div className="flex flex-col justify-center">
<h2 className="text-lg font-bold text-slate-900">
<StepWithTooltip
title={resolveTitle(methods.current)}
desc={methods.current.description}
/>
</h2>
{hasNextStep && nextStep ? (
<p className="text-sm text-slate-400">
Siguiente: {resolveTitle(nextStep)}
</p>
) : (
<p className="text-sm font-medium text-green-500">
¡Último paso!
</p>
)}
</div>
</div>
</div>
<div className="hidden sm:block">
<wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
{wizard.steps.map((step: any) => (
<wizard.Stepper.Step
key={step.id}
of={step.id}
className="whitespace-nowrap"
>
<wizard.Stepper.Title>
<StepWithTooltip
title={resolveTitle(step)}
desc={step.description}
/>
</wizard.Stepper.Title>
</wizard.Stepper.Step>
))}
</wizard.Stepper.Navigation>
</div>
</>
)
}

View File

@@ -1,56 +1,45 @@
import type { Database } from '../types/database'
import type {
PostgrestError,
AuthError,
SupabaseClient,
} from '@supabase/supabase-js'
import type { PostgrestError, AuthError, SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "../types/database";
export class ApiError extends Error {
constructor(
message: string,
public readonly code?: string,
public readonly details?: unknown,
public readonly hint?: string,
public readonly hint?: string
) {
super(message)
this.name = 'ApiError'
super(message);
this.name = "ApiError";
}
}
export function throwIfError(error: PostgrestError | AuthError | null): void {
if (!error) return
const anyErr = error as any
if (!error) return;
const anyErr = error as any;
throw new ApiError(
anyErr.message ?? 'Error inesperado',
anyErr.message ?? "Error inesperado",
anyErr.code,
anyErr.details,
anyErr.hint,
)
anyErr.hint
);
}
export function requireData<T>(
data: T | null | undefined,
message = 'Respuesta vacía',
): T {
if (data === null || data === undefined) throw new ApiError(message)
return data
export function requireData<T>(data: T | null | undefined, message = "Respuesta vacía"): T {
if (data === null || data === undefined) throw new ApiError(message);
return data;
}
export async function getUserIdOrThrow(
supabase: SupabaseClient<Database>,
): Promise<string> {
const { data, error } = await supabase.auth.getUser()
throwIfError(error)
if (!data?.user?.id) throw new ApiError('No hay sesión activa (auth).')
return data.user.id
export async function getUserIdOrThrow(supabase: SupabaseClient<Database>): Promise<string> {
const { data, error } = await supabase.auth.getUser();
throwIfError(error);
if (!data?.user?.id) throw new ApiError("No hay sesión activa (auth).");
return data.user.id;
}
export function buildRange(
limit?: number,
offset?: number,
): { from?: number; to?: number } {
if (!limit) return {}
const from = Math.max(0, offset ?? 0)
const to = from + Math.max(1, limit) - 1
return { from, to }
export function buildRange(limit?: number, offset?: number): { from?: number; to?: number } {
if (!limit) return {};
const from = Math.max(0, offset ?? 0);
const to = from + Math.max(1, limit) - 1;
return { from, to };
}

View File

@@ -1,238 +1,81 @@
import { supabaseBrowser } from '../supabase/client'
import { invokeEdge } from '../supabase/invokeEdge'
import type { InteraccionIA, UUID } from '../types/domain'
import { invokeEdge } from "../supabase/invokeEdge";
import type { InteraccionIA, UUID } from "../types/domain";
const EDGE = {
ai_plan_improve: 'ai_plan_improve',
ai_plan_chat: 'ai_plan_chat',
ai_subject_improve: 'ai_subject_improve',
ai_subject_chat: 'ai_subject_chat',
ai_plan_improve: "ai_plan_improve",
ai_plan_chat: "ai_plan_chat",
ai_subject_improve: "ai_subject_improve",
ai_subject_chat: "ai_subject_chat",
library_search: 'library_search',
} as const
library_search: "library_search",
} as const;
export async function ai_plan_improve(payload: {
planId: UUID
sectionKey: string // ej: "perfil_de_egreso" o tu key interna
prompt: string
context?: Record<string, any>
planId: UUID;
sectionKey: string; // ej: "perfil_de_egreso" o tu key interna
prompt: string;
context?: Record<string, any>;
fuentes?: {
archivosIds?: Array<UUID>
vectorStoresIds?: Array<UUID>
usarMCP?: boolean
conversacionId?: string
}
archivosIds?: UUID[];
vectorStoresIds?: UUID[];
usarMCP?: boolean;
conversacionId?: string;
};
}): Promise<{ interaccion: InteraccionIA; propuesta: any }> {
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(
EDGE.ai_plan_improve,
payload,
)
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(EDGE.ai_plan_improve, payload);
}
export async function ai_plan_chat(payload: {
planId: UUID
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
planId: UUID;
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
fuentes?: {
archivosIds?: Array<UUID>
vectorStoresIds?: Array<UUID>
usarMCP?: boolean
conversacionId?: string
}
archivosIds?: UUID[];
vectorStoresIds?: UUID[];
usarMCP?: boolean;
conversacionId?: string;
};
}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> {
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(
EDGE.ai_plan_chat,
payload,
)
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(EDGE.ai_plan_chat, payload);
}
export async function ai_subject_improve(payload: {
subjectId: UUID
sectionKey: string
prompt: string
context?: Record<string, any>
subjectId: UUID;
sectionKey: string;
prompt: string;
context?: Record<string, any>;
fuentes?: {
archivosIds?: Array<UUID>
vectorStoresIds?: Array<UUID>
usarMCP?: boolean
conversacionId?: string
}
archivosIds?: UUID[];
vectorStoresIds?: UUID[];
usarMCP?: boolean;
conversacionId?: string;
};
}): Promise<{ interaccion: InteraccionIA; propuesta: any }> {
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(
EDGE.ai_subject_improve,
payload,
)
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(EDGE.ai_subject_improve, payload);
}
export async function ai_subject_chat(payload: {
subjectId: UUID
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
subjectId: UUID;
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
fuentes?: {
archivosIds?: Array<UUID>
vectorStoresIds?: Array<UUID>
usarMCP?: boolean
conversacionId?: string
}
archivosIds?: UUID[];
vectorStoresIds?: UUID[];
usarMCP?: boolean;
conversacionId?: string;
};
}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> {
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(
EDGE.ai_subject_chat,
payload,
)
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(EDGE.ai_subject_chat, payload);
}
/** Biblioteca (Edge; adapta a tu API real) */
export type LibraryItem = {
id: string
titulo: string
autor?: string
isbn?: string
citaSugerida?: string
disponibilidad?: string
}
export async function library_search(payload: {
query: string
limit?: number
}): Promise<Array<LibraryItem>> {
return invokeEdge<Array<LibraryItem>>(EDGE.library_search, payload)
}
export async function create_conversation(planId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
'create-chat-conversation/conversations',
{
method: 'POST',
body: {
plan_estudio_id: planId, // O el nombre que confirmamos que funciona
instanciador: 'alex',
},
},
)
if (error) throw error
return data
}
export async function get_chat_history(conversacionId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/${conversacionId}/messages`,
{ method: 'GET' },
)
if (error) throw error
return data // Retorna Array de mensajes
}
export async function update_conversation_status(
conversacionId: string,
nuevoEstado: 'ARCHIVADA' | 'ACTIVA',
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_plan') // Asegúrate que el nombre de la tabla sea exacto
.update({ estado: nuevoEstado })
.eq('id', conversacionId)
.select()
.single()
if (error) throw error
return data
}
// Modificamos la función de chat para que use la ruta de mensajes
export async function ai_plan_chat_v2(payload: {
conversacionId: string
content: string
campos?: Array<string>
}): Promise<{ reply: string; meta?: any }> {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/${payload.conversacionId}/messages`,
{
method: 'POST',
body: {
content: payload.content,
campos: payload.campos || [],
},
},
)
if (error) throw error
return data
}
export async function getConversationByPlan(planId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_plan')
.select('*')
.eq('plan_estudio_id', planId)
.order('creado_en', { ascending: false })
if (error) throw error
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return data ?? []
}
export async function update_conversation_title(
conversacionId: string,
nuevoTitulo: string,
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_plan')
.update({ nombre: nuevoTitulo }) // Asegúrate que la columna se llame 'title' o 'nombre'
.eq('id', conversacionId)
.select()
.single()
if (error) throw error
return data
}
export async function update_recommendation_applied_status(
conversacionId: string,
campoAfectado: string,
) {
const supabase = supabaseBrowser()
// 1. Obtener el estado actual del JSON
const { data: conv, error: fetchError } = await supabase
.from('conversaciones_plan')
.select('conversacion_json')
.eq('id', conversacionId)
.single()
if (fetchError) throw fetchError
if (!conv.conversacion_json) throw new Error('No se encontró la conversación')
// 2. Transformar el JSON para marcar como aplicada la recomendación específica
// Usamos una transformación inmutable para evitar efectos secundarios
const nuevoJson = (conv.conversacion_json as Array<any>).map((msg) => {
if (msg.user === 'assistant' && Array.isArray(msg.recommendations)) {
return {
...msg,
recommendations: msg.recommendations.map((rec: any) =>
rec.campo_afectado === campoAfectado
? { ...rec, aplicada: true }
: rec,
),
}
}
return msg
})
// 3. Actualizar la base de datos con el nuevo JSON
const { data, error: updateError } = await supabase
.from('conversaciones_plan')
.update({ conversacion_json: nuevoJson })
.eq('id', conversacionId)
.select()
.single()
if (updateError) throw updateError
return data
id: string;
titulo: string;
autor?: string;
isbn?: string;
citaSugerida?: string;
disponibilidad?: string;
};
export async function library_search(payload: { query: string; limit?: number }): Promise<LibraryItem[]> {
return invokeEdge<LibraryItem[]>(EDGE.library_search, payload);
}

View File

@@ -1,27 +0,0 @@
// document.api.ts
const DOCUMENT_PDF_URL =
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
interface GeneratePdfParams {
plan_estudio_id: string
}
export async function fetchPlanPdf({
plan_estudio_id,
}: GeneratePdfParams): Promise<Blob> {
const response = await fetch(DOCUMENT_PDF_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ plan_estudio_id }),
})
if (!response.ok) {
throw new Error('Error al generar el PDF')
}
// n8n devuelve el archivo → lo tratamos como blob
return await response.blob()
}

View File

@@ -1,32 +1,32 @@
import { invokeEdge } from '../supabase/invokeEdge'
import type { UUID } from '../types/domain'
import { invokeEdge } from "../supabase/invokeEdge";
import type { UUID } from "../types/domain";
/**
* Metadata “canónica” para UI (archivo OpenAI + espejo en Supabase)
* Se apoya en tu tabla `archivos`.
*/
export type AppFile = {
id: UUID // id interno (tabla archivos)
openai_file_id: string // id OpenAI
nombre: string
mime_type: string | null
bytes: number | null
id: UUID; // id interno (tabla archivos)
openai_file_id: string; // id OpenAI
nombre: string;
mime_type: string | null;
bytes: number | null;
// espejo Supabase para preview/descarga
ruta_storage: string | null // "bucket/path"
signed_url?: string | null
ruta_storage: string | null; // "bucket/path"
signed_url?: string | null;
// auditoría/evidencia
temporal: boolean
notas?: string | null
temporal: boolean;
notas?: string | null;
subido_en: string
}
subido_en: string;
};
const EDGE = {
upload: 'openai_files_upload',
remove: 'openai_files_delete',
} as const
upload: "openai_files_upload",
remove: "openai_files_delete",
} as const;
/**
* Sube archivo a OpenAI y (opcional) crea espejo en Storage
@@ -37,28 +37,28 @@ export async function openai_files_upload(payload: {
* Si tu Edge soporta multipart: manda File/Blob directo.
* Si no, manda base64/bytes (según tu implementación).
*/
file: File
file: File;
/** “temporal” = evidencia usada para generar plan/asignatura */
temporal?: boolean
/** “temporal” = evidencia usada para generar plan/materia */
temporal?: boolean;
/** contexto para auditoría */
contexto?: {
planId?: UUID
asignaturaId?: UUID
motivo?: 'WIZARD_PLAN' | 'WIZARD_MATERIA' | 'ADHOC'
}
planId?: UUID;
asignaturaId?: UUID;
motivo?: "WIZARD_PLAN" | "WIZARD_MATERIA" | "ADHOC";
};
/** si quieres forzar espejo para preview siempre */
mirrorToSupabase?: boolean
mirrorToSupabase?: boolean;
}): Promise<AppFile> {
return invokeEdge<AppFile>(EDGE.upload, payload)
return invokeEdge<AppFile>(EDGE.upload, payload);
}
export async function openai_files_delete(payload: {
openaiFileId: string
openaiFileId: string;
/** si quieres borrar también espejo y registro */
hardDelete?: boolean
hardDelete?: boolean;
}): Promise<{ ok: true }> {
return invokeEdge<{ ok: true }>(EDGE.remove, payload)
return invokeEdge<{ ok: true }>(EDGE.remove, payload);
}

View File

@@ -1,9 +1,8 @@
import { supabaseBrowser } from '../supabase/client'
import { invokeEdge } from '../supabase/invokeEdge'
import { supabaseBrowser } from "../supabase/client";
import { invokeEdge } from "../supabase/invokeEdge";
import { buildRange, requireData, throwIfError } from './_helpers'
import { buildRange, requireData, throwIfError } from "./_helpers";
import type { Database } from '../../types/supabase'
import type {
Asignatura,
CambioPlan,
@@ -14,121 +13,89 @@ import type {
PlanEstudio,
TipoCiclo,
UUID,
} from '../types/domain'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
} from "../types/domain";
const EDGE = {
plans_create_manual: 'plans_create_manual',
ai_generate_plan: 'ai-generate-plan',
plans_persist_from_ai: 'plans_persist_from_ai',
plans_clone_from_existing: 'plans_clone_from_existing',
plans_create_manual: "plans_create_manual",
ai_generate_plan: "ai_generate_plan",
plans_persist_from_ai: "plans_persist_from_ai",
plans_clone_from_existing: "plans_clone_from_existing",
plans_import_from_files: 'plans_import_from_files',
plans_import_from_files: "plans_import_from_files",
// plans_update_fields: 'plans_update_fields',
plans_update_map: 'plans_update_map',
plans_transition_state: 'plans_transition_state',
plans_update_fields: "plans_update_fields",
plans_update_map: "plans_update_map",
plans_transition_state: "plans_transition_state",
plans_generate_document: 'plans_generate_document',
plans_get_document: 'plans_get_document',
} as const
plans_generate_document: "plans_generate_document",
plans_get_document: "plans_get_document",
} as const;
export type PlanListFilters = {
search?: string
carreraId?: UUID
facultadId?: UUID // filtra por carreras.facultad_id
estadoId?: UUID
activo?: boolean
search?: string;
carreraId?: UUID;
facultadId?: UUID; // filtra por carreras.facultad_id
estadoId?: UUID;
activo?: boolean;
limit?: number
offset?: number
}
// Helper para limpiar texto (lo movemos fuera para reutilizar o lo dejas en un utils)
const cleanText = (text: string) => {
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
}
limit?: number;
offset?: number;
};
export async function plans_list(
filters: PlanListFilters = {},
): Promise<Paged<PlanEstudio>> {
const supabase = supabaseBrowser()
// 1. Construimos la query base
// NOTA IMPORTANTE: Para filtrar planes basados en facultad (que está en carreras),
// necesitamos hacer un INNER JOIN. En Supabase se usa "!inner".
// Si filters.facultadId existe, forzamos el inner join, si no, lo dejamos normal.
const carreraModifier =
filters.facultadId && filters.facultadId !== 'todas' ? '!inner' : ''
const supabase = supabaseBrowser();
// 1. Construimos la query.
// TypeScript validará que "planes_estudio" existe en Database
let q = supabase
.from('planes_estudio')
.from("planes_estudio")
.select(
`
*,
carreras${carreraModifier} (
carreras (
*,
facultades (*)
),
estructuras_plan (*),
estados_plan (*)
`,
{ count: 'exact' },
{ count: "exact" },
)
.order('creado_en', { ascending: false })
.order("actualizado_en", { ascending: false });
// 2. Aplicamos filtros dinámicos
// SOLUCIÓN SEARCH: Limpiamos el input y buscamos en la columna generada
if (filters.search?.trim()) {
const cleanTerm = cleanText(filters.search.trim())
// Usamos la columna nueva creada en el Paso 1
q = q.ilike('nombre_search', `%${cleanTerm}%`)
q = q.ilike("nombre", `%${filters.search.trim()}%`);
}
if (filters.carreraId) q = q.eq("carrera_id", filters.carreraId);
if (filters.estadoId) q = q.eq("estado_actual_id", filters.estadoId);
if (typeof filters.activo === "boolean") q = q.eq("activo", filters.activo);
if (filters.carreraId && filters.carreraId !== 'todas') {
q = q.eq('carrera_id', filters.carreraId)
}
if (filters.estadoId && filters.estadoId !== 'todos') {
q = q.eq('estado_actual_id', filters.estadoId)
}
if (typeof filters.activo === 'boolean') {
q = q.eq('activo', filters.activo)
}
// Filtro por facultad (gracias al !inner arriba, esto filtrará los planes)
if (filters.facultadId && filters.facultadId !== 'todas') {
q = q.eq('carreras.facultad_id', filters.facultadId)
}
// filtro por FK “hacia arriba” (PostgREST soporta filtros en recursos embebidos)
if (filters.facultadId) q = q.eq("carreras.facultad_id", filters.facultadId);
// 3. Paginación
const { from, to } = buildRange(filters.limit, filters.offset)
if (from !== undefined && to !== undefined) q = q.range(from, to)
const { from, to } = buildRange(filters.limit, filters.offset);
if (from !== undefined && to !== undefined) q = q.range(from, to);
const { data, error, count } = await q
throwIfError(error)
const { data, error, count } = await q;
throwIfError(error);
return {
// 1. Si data es null, usa [].
// 2. Luego dile a TS que el resultado es tu Array tipado.
data: (data ?? []) as unknown as Array<PlanEstudio>,
count: count ?? 0,
}
};
}
export async function plans_get(planId: UUID): Promise<PlanEstudio> {
console.log('plans_get')
const supabase = supabaseBrowser()
const supabase = supabaseBrowser();
const { data, error } = await supabase
.from('planes_estudio')
.from("planes_estudio")
.select(
`
*,
@@ -137,340 +104,198 @@ export async function plans_get(planId: UUID): Promise<PlanEstudio> {
estados_plan (*)
`,
)
.eq('id', planId)
.single()
.eq("id", planId)
.single();
throwIfError(error)
return requireData(data, 'Plan no encontrado.')
}
/**
* Variante de `plans_get` que NO lanza si no existe (devuelve null).
* Útil para flujos de polling donde el plan puede tardar en aparecer.
*/
export async function plans_get_maybe(
planId: UUID,
): Promise<PlanEstudio | null> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('planes_estudio')
.select(
`
*,
carreras (*, facultades(*)),
estructuras_plan (*),
estados_plan (*)
`,
)
.eq('id', planId)
.maybeSingle()
throwIfError(error)
return (data ?? null) as unknown as PlanEstudio | null
}
export async function plans_delete(planId: UUID): Promise<{ id: UUID }> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('planes_estudio')
.delete()
.eq('id', planId)
.select('id')
.maybeSingle()
throwIfError(error)
// Si por alguna razón no retorna fila (RLS / triggers), devolvemos el id solicitado.
return { id: ((data as any)?.id ?? planId) as UUID }
throwIfError(error);
return requireData(data, "Plan no encontrado.");
}
export async function plan_lineas_list(
planId: UUID,
): Promise<Array<LineaPlan>> {
const supabase = supabaseBrowser()
const supabase = supabaseBrowser();
const { data, error } = await supabase
.from('lineas_plan')
.select('id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en')
.eq('plan_estudio_id', planId)
.order('orden', { ascending: true })
.from("lineas_plan")
.select("id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en")
.eq("plan_estudio_id", planId)
.order("orden", { ascending: true });
throwIfError(error)
return data ?? []
throwIfError(error);
return data ?? [];
}
export async function plan_asignaturas_list(
planId: UUID,
): Promise<Array<Asignatura>> {
const supabase = supabaseBrowser()
const supabase = supabaseBrowser();
const { data, error } = await supabase
.from('asignaturas')
.from("asignaturas")
.select(
'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en',
"id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en",
)
.eq('plan_estudio_id', planId)
.order('numero_ciclo', { ascending: true, nullsFirst: false })
.order('orden_celda', { ascending: true, nullsFirst: false })
.order('nombre', { ascending: true })
.eq("plan_estudio_id", planId)
.order("numero_ciclo", { ascending: true, nullsFirst: false })
.order("orden_celda", { ascending: true, nullsFirst: false })
.order("nombre", { ascending: true });
throwIfError(error)
return data ?? []
throwIfError(error);
return data ?? [];
}
export async function plans_history(
planId: UUID,
page: number = 0,
pageSize: number = 4,
): Promise<{ data: Array<CambioPlan>; count: number }> {
// Cambiamos el retorno
const supabase = supabaseBrowser()
const from = page * pageSize
const to = from + pageSize - 1
const { data, error, count } = await supabase
.from('cambios_plan')
export async function plans_history(planId: UUID): Promise<Array<CambioPlan>> {
const supabase = supabaseBrowser();
const { data, error } = await supabase
.from("cambios_plan")
.select(
'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,response_id',
{ count: 'exact' }, // <--- Pedimos el conteo exacto
"id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,interaccion_ia_id",
)
.eq('plan_estudio_id', planId)
.order('cambiado_en', { ascending: false })
.range(from, to)
.eq("plan_estudio_id", planId)
.order("cambiado_en", { ascending: false });
throwIfError(error)
return {
data: data ?? [],
count: count ?? 0,
}
throwIfError(error);
return data ?? [];
}
/** Wizard: crear plan manual (Edge Function) */
export type PlansCreateManualInput = {
carreraId: UUID
estructuraId: UUID
nombre: string
nivel: NivelPlanEstudio
tipoCiclo: TipoCiclo
numCiclos: number
datos?: Partial<PlanDatosSep> & Record<string, any>
}
carreraId: UUID;
estructuraId: UUID;
nombre: string;
nivel: NivelPlanEstudio;
tipoCiclo: TipoCiclo;
numCiclos: number;
datos?: Partial<PlanDatosSep> & Record<string, any>;
};
export async function plans_create_manual(
input: PlansCreateManualInput,
): Promise<PlanEstudio> {
const supabase = supabaseBrowser()
// 1. Obtener estado 'BORRADOR'
const { data: estado, error: estadoError } = await supabase
.from('estados_plan')
.select('id,clave,orden')
.ilike('clave', 'BORRADOR%')
.order('orden', { ascending: true })
.limit(1)
.maybeSingle()
if (estadoError) {
throw new Error(estadoError.message)
}
// 2. Preparar insert
const planInsert: Database['public']['Tables']['planes_estudio']['Insert'] = {
activo: true,
actualizado_en: new Date().toISOString(),
carrera_id: input.carreraId,
creado_en: new Date().toISOString(),
datos: input.datos || {},
estado_actual_id: estado?.id || null,
estructura_id: input.estructuraId,
nivel: input.nivel,
nombre: input.nombre,
numero_ciclos: input.numCiclos,
tipo_ciclo: input.tipoCiclo,
tipo_origen: 'MANUAL',
}
// 3. Insertar
const { data: nuevoPlan, error: planError } = await supabase
.from('planes_estudio')
.insert([planInsert])
.select(
`
*,
carreras (*, facultades(*)),
estructuras_plan (*),
estados_plan (*)
`,
)
.single()
if (planError) {
throw new Error(planError.message)
}
return nuevoPlan as unknown as PlanEstudio
return invokeEdge<PlanEstudio>(EDGE.plans_create_manual, input);
}
/** Wizard: IA genera preview JSON (Edge Function) */
export type AIGeneratePlanInput = {
datosBasicos: {
nombrePlan: string
carreraId: UUID
facultadId?: UUID
nivel: string
tipoCiclo: TipoCiclo
numCiclos: number
estructuraPlanId: UUID
}
nombrePlan: string;
carreraId: UUID;
facultadId?: UUID;
nivel: string;
tipoCiclo: TipoCiclo;
numCiclos: number;
};
iaConfig: {
descripcionEnfoqueAcademico: string
instruccionesAdicionalesIA?: string
archivosReferencia?: Array<UUID>
repositoriosIds?: Array<UUID>
archivosAdjuntos: Array<UploadedFile>
usarMCP?: boolean
}
}
descripcionEnfoque: string;
poblacionObjetivo?: string;
notasAdicionales?: string;
archivosReferencia?: Array<UUID>;
repositoriosIds?: Array<UUID>;
usarMCP?: boolean;
};
};
export async function ai_generate_plan(
input: AIGeneratePlanInput,
): Promise<any> {
console.log('input ai generate', input)
const edgeFunctionBody = new FormData()
edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos))
edgeFunctionBody.append(
'iaConfig',
JSON.stringify({
...input.iaConfig,
archivosAdjuntos: undefined, // los manejamos aparte
}),
)
input.iaConfig.archivosAdjuntos.forEach((file) => {
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
})
return invokeEdge<any>(
EDGE.ai_generate_plan,
edgeFunctionBody,
undefined,
supabaseBrowser(),
)
return invokeEdge<any>(EDGE.ai_generate_plan, input);
}
export async function plans_persist_from_ai(payload: {
jsonPlan: any
}): Promise<PlanEstudio> {
return invokeEdge<PlanEstudio>(EDGE.plans_persist_from_ai, payload)
export async function plans_persist_from_ai(
payload: { jsonPlan: any },
): Promise<PlanEstudio> {
return invokeEdge<PlanEstudio>(EDGE.plans_persist_from_ai, payload);
}
export async function plans_clone_from_existing(payload: {
planOrigenId: UUID
overrides: Partial<
Pick<PlanEstudio, 'nombre' | 'nivel' | 'tipo_ciclo' | 'numero_ciclos'>
> & {
carrera_id?: UUID
estructura_id?: UUID
datos?: Partial<PlanDatosSep> & Record<string, any>
}
planOrigenId: UUID;
overrides:
& Partial<
Pick<PlanEstudio, "nombre" | "nivel" | "tipo_ciclo" | "numero_ciclos">
>
& {
carrera_id?: UUID;
estructura_id?: UUID;
datos?: Partial<PlanDatosSep> & Record<string, any>;
};
}): Promise<PlanEstudio> {
return invokeEdge<PlanEstudio>(EDGE.plans_clone_from_existing, payload)
return invokeEdge<PlanEstudio>(EDGE.plans_clone_from_existing, payload);
}
export async function plans_import_from_files(payload: {
datosBasicos: {
nombrePlan: string
carreraId: UUID
estructuraId: UUID
nivel: string
tipoCiclo: TipoCiclo
numCiclos: number
}
archivoWordPlanId: UUID
archivoMapaExcelId?: UUID | null
archivoAsignaturasExcelId?: UUID | null
nombrePlan: string;
carreraId: UUID;
estructuraId: UUID;
nivel: string;
tipoCiclo: TipoCiclo;
numCiclos: number;
};
archivoWordPlanId: UUID;
archivoMapaExcelId?: UUID | null;
archivoMateriasExcelId?: UUID | null;
}): Promise<PlanEstudio> {
return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload)
return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload);
}
/** Update de tarjetas/fields del plan (Edge Function: merge server-side) */
export type PlansUpdateFieldsPatch = {
nombre?: string
nivel?: NivelPlanEstudio
tipo_ciclo?: TipoCiclo
numero_ciclos?: number
datos?: Partial<PlanDatosSep> & Record<string, any>
}
nombre?: string;
nivel?: NivelPlanEstudio;
tipo_ciclo?: TipoCiclo;
numero_ciclos?: number;
datos?: Partial<PlanDatosSep> & Record<string, any>;
};
export async function plans_update_fields(
planId: UUID,
patch: PlansUpdateFieldsPatch,
): Promise<PlanEstudio> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('planes_estudio')
.update(patch)
.eq('id', planId)
.select(
`
*,
carreras (*, facultades(*)),
estructuras_plan (*),
estados_plan (*)
`,
)
.single()
throwIfError(error)
return requireData(data, 'No se pudo actualizar el plan.')
// Alternativa Edge Function:
// return invokeEdge<PlanEstudio>(EDGE.plans_update_fields, { planId, patch })
return invokeEdge<PlanEstudio>(EDGE.plans_update_fields, { planId, patch });
}
/** Operaciones del mapa curricular (mover/reordenar) */
export type PlanMapOperation =
| {
op: 'MOVE_ASIGNATURA'
asignaturaId: UUID
numero_ciclo: number | null
linea_plan_id: UUID | null
orden_celda?: number | null
op: "MOVE_ASIGNATURA";
asignaturaId: UUID;
numero_ciclo: number | null;
linea_plan_id: UUID | null;
orden_celda?: number | null;
}
| {
op: 'REORDER_CELDA'
linea_plan_id: UUID
numero_ciclo: number
asignaturaIdsOrdenados: Array<UUID>
}
op: "REORDER_CELDA";
linea_plan_id: UUID;
numero_ciclo: number;
asignaturaIdsOrdenados: Array<UUID>;
};
export async function plans_update_map(
planId: UUID,
ops: Array<PlanMapOperation>,
): Promise<{ ok: true }> {
return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops })
return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops });
}
export async function plans_transition_state(payload: {
planId: UUID
haciaEstadoId: UUID
comentario?: string
planId: UUID;
haciaEstadoId: UUID;
comentario?: string;
}): Promise<{ ok: true }> {
return invokeEdge<{ ok: true }>(EDGE.plans_transition_state, payload)
return invokeEdge<{ ok: true }>(EDGE.plans_transition_state, payload);
}
/** Documento (Edge Function: genera y devuelve URL firmada o metadata) */
export type DocumentoResult = {
archivoId: UUID
signedUrl: string
mimeType?: string
nombre?: string
}
archivoId: UUID;
signedUrl: string;
mimeType?: string;
nombre?: string;
};
export async function plans_generate_document(
planId: UUID,
): Promise<DocumentoResult> {
return invokeEdge<DocumentoResult>(EDGE.plans_generate_document, { planId })
return invokeEdge<DocumentoResult>(EDGE.plans_generate_document, { planId });
}
export async function plans_get_document(
@@ -478,26 +303,5 @@ export async function plans_get_document(
): Promise<DocumentoResult | null> {
return invokeEdge<DocumentoResult | null>(EDGE.plans_get_document, {
planId,
})
}
export async function getCatalogos() {
const supabase = supabaseBrowser()
const [facultadesRes, carrerasRes, estadosRes, estructurasPlanRes] =
await Promise.all([
supabase.from('facultades').select('*').order('nombre'),
supabase.from('carreras').select('*').order('nombre'),
supabase.from('estados_plan').select('*').order('orden'),
supabase.from('estructuras_plan').select('*').order('creado_en', {
ascending: true,
}),
])
return {
facultades: facultadesRes.data ?? [],
carreras: carrerasRes.data ?? [],
estados: estadosRes.data ?? [],
estructurasPlan: estructurasPlanRes.data ?? [],
}
});
}

View File

@@ -1,362 +1,181 @@
import { supabaseBrowser } from '../supabase/client'
import { invokeEdge } from '../supabase/invokeEdge'
import { throwIfError, requireData } from './_helpers'
import type { DocumentoResult } from './plans.api'
import { supabaseBrowser } from "../supabase/client";
import { invokeEdge } from "../supabase/invokeEdge";
import { throwIfError, requireData } from "./_helpers";
import type {
Asignatura,
BibliografiaAsignatura,
CarreraRow,
CambioAsignatura,
EstructuraAsignatura,
FacultadRow,
PlanEstudioRow,
TipoAsignatura,
UUID,
} from '../types/domain'
import type {
AsignaturaSugerida,
DataAsignaturaSugerida,
} from '@/features/asignaturas/nueva/types'
import type { Database, TablesInsert } from '@/types/supabase'
} from "../types/domain";
import type { DocumentoResult } from "./plans.api";
const EDGE = {
generate_subject_suggestions: 'generate-subject-suggestions',
subjects_create_manual: 'subjects_create_manual',
ai_generate_subject: 'ai-generate-subject',
subjects_persist_from_ai: 'subjects_persist_from_ai',
subjects_clone_from_existing: 'subjects_clone_from_existing',
subjects_import_from_file: 'subjects_import_from_file',
subjects_create_manual: "subjects_create_manual",
ai_generate_subject: "ai_generate_subject",
subjects_persist_from_ai: "subjects_persist_from_ai",
subjects_clone_from_existing: "subjects_clone_from_existing",
subjects_import_from_file: "subjects_import_from_file",
subjects_update_fields: 'subjects_update_fields',
subjects_update_bibliografia: 'subjects_update_bibliografia',
subjects_update_fields: "subjects_update_fields",
subjects_update_contenido: "subjects_update_contenido",
subjects_update_bibliografia: "subjects_update_bibliografia",
subjects_generate_document: 'subjects_generate_document',
subjects_get_document: 'subjects_get_document',
} as const
subjects_generate_document: "subjects_generate_document",
subjects_get_document: "subjects_get_document",
} as const;
export type ContenidoTemaApi =
| string
| {
nombre: string
horasEstimadas?: number
descripcion?: string
[key: string]: unknown
}
/**
* Estructura persistida en `asignaturas.contenido_tematico`.
* La BDD guarda un arreglo de unidades, cada una con temas (strings u objetos).
*/
export type ContenidoApi = {
unidad: number
titulo: string
temas: Array<ContenidoTemaApi>
[key: string]: unknown
}
export type FacultadInSubject = Pick<
FacultadRow,
'id' | 'nombre' | 'nombre_corto' | 'color' | 'icono'
>
export type CarreraInSubject = Pick<
CarreraRow,
'id' | 'facultad_id' | 'nombre' | 'nombre_corto' | 'clave_sep' | 'activa'
> & {
facultades: FacultadInSubject | null
}
export type PlanEstudioInSubject = Pick<
PlanEstudioRow,
| 'id'
| 'carrera_id'
| 'estructura_id'
| 'nombre'
| 'nivel'
| 'tipo_ciclo'
| 'numero_ciclos'
| 'datos'
| 'estado_actual_id'
| 'activo'
| 'tipo_origen'
| 'meta_origen'
| 'creado_por'
| 'actualizado_por'
| 'creado_en'
| 'actualizado_en'
> & {
carreras: CarreraInSubject | null
}
export type EstructuraAsignaturaInSubject = Pick<
EstructuraAsignatura,
'id' | 'nombre' | 'version' | 'definicion'
>
/**
* Tipo real que devuelve `subjects_get` (asignatura + relaciones seleccionadas).
* Nota: `asignaturas_update` (update directo) NO devuelve estas relaciones.
*/
export type AsignaturaDetail = Omit<Asignatura, 'contenido_tematico'> & {
contenido_tematico: Array<ContenidoApi> | null
planes_estudio: PlanEstudioInSubject | null
estructuras_asignatura: EstructuraAsignaturaInSubject | null
}
export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
const supabase = supabaseBrowser()
export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
const supabase = supabaseBrowser();
const { data, error } = await supabase
.from('asignaturas')
.from("asignaturas")
.select(
`
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
planes_estudio(
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
),
estructuras_asignatura(id,nombre,version,definicion)
`,
`
)
.eq('id', subjectId)
.single()
.eq("id", subjectId)
.single();
throwIfError(error)
return requireData(
data,
'Asignatura no encontrada.',
) as unknown as AsignaturaDetail
throwIfError(error);
return requireData(data, "Materia no encontrada.");
}
export async function subjects_history(
subjectId: UUID,
): Promise<Array<CambioAsignatura>> {
const supabase = supabaseBrowser()
export async function subjects_history(subjectId: UUID): Promise<CambioAsignatura[]> {
const supabase = supabaseBrowser();
const { data, error } = await supabase
.from('cambios_asignatura')
.from("cambios_asignatura")
.select(
'id,asignatura_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,fuente,interaccion_ia_id',
"id,asignatura_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,fuente,interaccion_ia_id"
)
.eq('asignatura_id', subjectId)
.order('cambiado_en', { ascending: false })
.eq("asignatura_id", subjectId)
.order("cambiado_en", { ascending: false });
throwIfError(error)
return data ?? []
throwIfError(error);
return data ?? [];
}
export async function subjects_bibliografia_list(
subjectId: UUID,
): Promise<Array<BibliografiaAsignatura>> {
const supabase = supabaseBrowser()
export async function subjects_bibliografia_list(subjectId: UUID): Promise<BibliografiaAsignatura[]> {
const supabase = supabaseBrowser();
const { data, error } = await supabase
.from('bibliografia_asignatura')
.select(
'id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en',
)
.eq('asignatura_id', subjectId)
.order('tipo', { ascending: true })
.order('creado_en', { ascending: true })
.from("bibliografia_asignatura")
.select("id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en")
.eq("asignatura_id", subjectId)
.order("tipo", { ascending: true })
.order("creado_en", { ascending: true });
throwIfError(error)
return data ?? []
throwIfError(error);
return data ?? [];
}
export async function subjects_create_manual(
payload: TablesInsert<'asignaturas'>,
): Promise<Asignatura> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('asignaturas')
.insert(payload)
.select()
.single()
/** Wizard: crear materia manual (Edge Function) */
export type SubjectsCreateManualInput = {
planId: UUID;
datosBasicos: {
nombre: string;
clave?: string;
tipo: TipoAsignatura;
creditos: number;
horasSemana?: number;
estructuraId: UUID;
};
};
throwIfError(error)
return requireData(data, 'No se pudo crear la asignatura.')
export async function subjects_create_manual(payload: SubjectsCreateManualInput): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_create_manual, payload);
}
/**
* Nuevo payload unificado (JSON) para la Edge `ai_generate_subject`.
* - Siempre incluye `datosUpdate.plan_estudio_id`.
* - `datosUpdate.id` es opcional (si no existe, la Edge puede crear).
* En el frontend, insertamos primero y usamos `id` para actualizar.
*/
export type AISubjectUnifiedInput = {
datosUpdate: Partial<{
id: string
plan_estudio_id: string
estructura_id: string
nombre: string
codigo: string | null
tipo: string | null
creditos: number
horas_academicas: number | null
horas_independientes: number | null
numero_ciclo: number | null
linea_plan_id: string | null
orden_celda: number | null
}> & {
plan_estudio_id: string
}
iaConfig?: {
descripcionEnfoqueAcademico?: string
instruccionesAdicionalesIA?: string
archivosAdjuntos?: Array<string>
}
export async function ai_generate_subject(payload: {
planId: UUID;
datosBasicos: {
nombre: string;
clave?: string;
tipo: TipoAsignatura;
creditos: number;
horasSemana?: number;
estructuraId: UUID;
};
iaConfig: {
descripcionEnfoque: string;
notasAdicionales?: string;
archivosExistentesIds?: UUID[];
repositoriosIds?: UUID[];
archivosAdhocIds?: UUID[];
usarMCP?: boolean;
};
}): Promise<any> {
return invokeEdge<any>(EDGE.ai_generate_subject, payload);
}
export async function subjects_get_maybe(
subjectId: UUID,
): Promise<Asignatura | null> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('asignaturas')
.select('id,plan_estudio_id,estado')
.eq('id', subjectId)
.maybeSingle()
throwIfError(error)
return (data ?? null) as unknown as Asignatura | null
}
export type GenerateSubjectSuggestionsInput = {
plan_estudio_id: UUID
enfoque?: string
cantidad_de_sugerencias: number
sugerencias_conservadas: Array<{ nombre: string; descripcion: string }>
}
export async function generate_subject_suggestions(
input: GenerateSubjectSuggestionsInput,
): Promise<Array<AsignaturaSugerida>> {
const raw = await invokeEdge<Array<DataAsignaturaSugerida>>(
EDGE.generate_subject_suggestions,
input,
{ headers: { 'Content-Type': 'application/json' } },
)
return raw.map(
(s): AsignaturaSugerida => ({
id: crypto.randomUUID(),
selected: false,
source: 'IA',
nombre: s.nombre,
codigo: s.codigo,
tipo: s.tipo ?? null,
creditos: s.creditos ?? null,
horasAcademicas: s.horasAcademicas ?? null,
horasIndependientes: s.horasIndependientes ?? null,
descripcion: s.descripcion,
linea_plan_id: null,
numero_ciclo: null,
}),
)
}
export async function ai_generate_subject(
input: AISubjectUnifiedInput,
): Promise<any> {
return invokeEdge<any>(EDGE.ai_generate_subject, input, {
headers: { 'Content-Type': 'application/json' },
})
}
export async function subjects_persist_from_ai(payload: {
planId: UUID
jsonAsignatura: any
}): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_persist_from_ai, payload)
export async function subjects_persist_from_ai(payload: { planId: UUID; jsonMateria: any }): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_persist_from_ai, payload);
}
export async function subjects_clone_from_existing(payload: {
asignaturaOrigenId: UUID
planDestinoId: UUID
materiaOrigenId: UUID;
planDestinoId: UUID;
overrides?: Partial<{
nombre: string
codigo: string
tipo: TipoAsignatura
creditos: number
horas_semana: number
}>
nombre: string;
codigo: string;
tipo: TipoAsignatura;
creditos: number;
horas_semana: number;
}>;
}): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_clone_from_existing, payload)
return invokeEdge<Asignatura>(EDGE.subjects_clone_from_existing, payload);
}
export async function subjects_import_from_file(payload: {
planId: UUID
archivoWordAsignaturaId: UUID
archivosAdicionalesIds?: Array<UUID>
planId: UUID;
archivoWordMateriaId: UUID;
archivosAdicionalesIds?: UUID[];
}): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_import_from_file, payload)
return invokeEdge<Asignatura>(EDGE.subjects_import_from_file, payload);
}
/** Guardado de tarjetas/fields (Edge: merge server-side en asignaturas.datos y columnas) */
export type SubjectsUpdateFieldsPatch = Partial<{
codigo: string | null
nombre: string
tipo: TipoAsignatura
creditos: number
horas_semana: number | null
numero_ciclo: number | null
linea_plan_id: UUID | null
codigo: string | null;
nombre: string;
tipo: TipoAsignatura;
creditos: number;
horas_semana: number | null;
numero_ciclo: number | null;
linea_plan_id: UUID | null;
datos: Record<string, any>
}>
datos: Record<string, any>;
}>;
export async function subjects_update_fields(
subjectId: UUID,
patch: SubjectsUpdateFieldsPatch,
): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_update_fields, {
subjectId,
patch,
})
export async function subjects_update_fields(subjectId: UUID, patch: SubjectsUpdateFieldsPatch): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_update_fields, { subjectId, patch });
}
export async function subjects_update_contenido(
subjectId: UUID,
unidades: Array<ContenidoApi>,
): Promise<Asignatura> {
const supabase = supabaseBrowser()
type AsignaturaUpdate = Database['public']['Tables']['asignaturas']['Update']
const { data, error } = await supabase
.from('asignaturas')
.update({
contenido_tematico:
unidades as unknown as AsignaturaUpdate['contenido_tematico'],
})
.eq('id', subjectId)
.select()
.single()
throwIfError(error)
return requireData(data, 'No se pudo actualizar la asignatura.')
export async function subjects_update_contenido(subjectId: UUID, unidades: any[]): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_update_contenido, { subjectId, unidades });
}
export type BibliografiaUpsertInput = Array<{
id?: UUID
tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string
tipo_fuente?: 'MANUAL' | 'BIBLIOTECA'
biblioteca_item_id?: string | null
}>
id?: UUID;
tipo: "BASICA" | "COMPLEMENTARIA";
cita: string;
tipo_fuente?: "MANUAL" | "BIBLIOTECA";
biblioteca_item_id?: string | null;
}>;
export async function subjects_update_bibliografia(
subjectId: UUID,
entries: BibliografiaUpsertInput,
entries: BibliografiaUpsertInput
): Promise<{ ok: true }> {
return invokeEdge<{ ok: true }>(EDGE.subjects_update_bibliografia, {
subjectId,
entries,
})
return invokeEdge<{ ok: true }>(EDGE.subjects_update_bibliografia, { subjectId, entries });
}
/** Documento SEP asignatura */
/** Documento SEP materia */
/* export type DocumentoResult = {
archivoId: UUID;
signedUrl: string;
@@ -364,149 +183,10 @@ export async function subjects_update_bibliografia(
nombre?: string;
}; */
export async function subjects_generate_document(
subjectId: UUID,
): Promise<DocumentoResult> {
return invokeEdge<DocumentoResult>(EDGE.subjects_generate_document, {
subjectId,
})
export async function subjects_generate_document(subjectId: UUID): Promise<DocumentoResult> {
return invokeEdge<DocumentoResult>(EDGE.subjects_generate_document, { subjectId });
}
export async function subjects_get_document(
subjectId: UUID,
): Promise<DocumentoResult | null> {
return invokeEdge<DocumentoResult | null>(EDGE.subjects_get_document, {
subjectId,
})
}
export async function subjects_get_structure_catalog(): Promise<
Array<Database['public']['Tables']['estructuras_asignatura']['Row']>
> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('estructuras_asignatura')
.select('*')
.order('nombre', { ascending: true })
if (error) {
throw error
}
return data
}
export async function asignaturas_update(
asignaturaId: UUID,
patch: Partial<Asignatura>, // O tu tipo específico para el Patch de materias
): Promise<Asignatura> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('asignaturas')
.update(patch)
.eq('id', asignaturaId)
.select() // Trae la materia actualizada
.single()
throwIfError(error)
return requireData(data, 'No se pudo actualizar la asignatura.')
}
// Insertar una nueva línea
export async function lineas_insert(linea: {
nombre: string
plan_estudio_id: string
orden: number
area?: string
}) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('lineas_plan') // Asegúrate que el nombre de la tabla sea correcto
.insert([linea])
.select()
.single()
if (error) throw error
return data
}
// Actualizar una línea existente
export async function lineas_update(
lineaId: string,
patch: { nombre?: string; orden?: number; area?: string },
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('lineas_plan')
.update(patch)
.eq('id', lineaId)
.select()
.single()
if (error) throw error
return data
}
export async function lineas_delete(lineaId: string) {
const supabase = supabaseBrowser()
// Nota: Si configuraste "ON DELETE SET NULL" en tu base de datos,
// las asignaturas se desvincularán solas. Si no, Supabase podría dar error.
const { error } = await supabase
.from('lineas_plan')
.delete()
.eq('id', lineaId)
if (error) throw error
return lineaId
}
export async function bibliografia_insert(entry: {
asignatura_id: string
tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string
tipo_fuente: 'MANUAL' | 'BIBLIOTECA'
biblioteca_item_id?: string | null
}) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('bibliografia_asignatura')
.insert([entry])
.select()
.single()
if (error) throw error
return data
}
export async function bibliografia_update(
id: string,
updates: {
cita?: string
tipo?: 'BASICA' | 'COMPLEMENTARIA'
},
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('bibliografia_asignatura')
.update(updates) // Ahora 'updates' es compatible con lo que espera Supabase
.eq('id', id)
.select()
.single()
if (error) throw error
return data
}
export async function bibliografia_delete(id: string) {
const supabase = supabaseBrowser()
const { error } = await supabase
.from('bibliografia_asignatura')
.delete()
.eq('id', id)
if (error) throw error
return id
export async function subjects_get_document(subjectId: UUID): Promise<DocumentoResult | null> {
return invokeEdge<DocumentoResult | null>(EDGE.subjects_get_document, { subjectId });
}

View File

@@ -1,139 +1,29 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMutation } from "@tanstack/react-query";
import {
ai_plan_chat_v2,
ai_plan_chat,
ai_plan_improve,
ai_subject_chat,
ai_subject_improve,
create_conversation,
get_chat_history,
getConversationByPlan,
library_search,
update_conversation_status,
update_recommendation_applied_status,
update_conversation_title,
} from '../api/ai.api'
// eslint-disable-next-line node/prefer-node-protocol
import type { UUID } from 'crypto'
} from "../api/ai.api";
export function useAIPlanImprove() {
return useMutation({ mutationFn: ai_plan_improve })
return useMutation({ mutationFn: ai_plan_improve });
}
export function useAIPlanChat() {
return useMutation({
mutationFn: async (payload: {
planId: UUID
content: string
campos?: Array<string>
conversacionId?: string
}) => {
let currentId = payload.conversacionId
// 1. Si no hay ID, creamos la conversación
if (!currentId) {
const response = await create_conversation(payload.planId)
// CAMBIO AQUÍ: Accedemos a la estructura correcta según tu consola
currentId = response.conversation_plan.id
}
// 2. Ahora enviamos el mensaje con el ID garantizado
const result = await ai_plan_chat_v2({
conversacionId: currentId!,
content: payload.content,
campos: payload.campos,
})
// Retornamos el resultado del chat y el ID para el estado del componente
return { ...result, conversacionId: currentId }
},
})
}
export function useChatHistory(conversacionId?: string) {
return useQuery({
queryKey: ['chat-history', conversacionId],
queryFn: async () => {
return get_chat_history(conversacionId!)
},
enabled: Boolean(conversacionId),
})
}
export function useUpdateConversationStatus() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({
id,
estado,
}: {
id: string
estado: 'ARCHIVADA' | 'ACTIVA'
}) => update_conversation_status(id, estado),
onSuccess: () => {
// Esto refresca las listas automáticamente
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
},
})
}
export function useConversationByPlan(planId: string | null) {
return useQuery({
queryKey: ['conversation-by-plan', planId],
queryFn: () => getConversationByPlan(planId!),
enabled: !!planId, // solo ejecuta si existe planId
})
}
export function useUpdateRecommendationApplied() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({
conversacionId,
campoAfectado,
}: {
conversacionId: string
campoAfectado: string
}) => update_recommendation_applied_status(conversacionId, campoAfectado),
onSuccess: (_, variables) => {
// Invalidamos la query para que useConversationByPlan refresque el JSON
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
console.log(
`Recomendación ${variables.campoAfectado} marcada como aplicada.`,
)
},
onError: (error) => {
console.error('Error al actualizar el estado de la recomendación:', error)
},
})
return useMutation({ mutationFn: ai_plan_chat });
}
export function useAISubjectImprove() {
return useMutation({ mutationFn: ai_subject_improve })
return useMutation({ mutationFn: ai_subject_improve });
}
export function useAISubjectChat() {
return useMutation({ mutationFn: ai_subject_chat })
return useMutation({ mutationFn: ai_subject_chat });
}
export function useLibrarySearch() {
return useMutation({ mutationFn: library_search })
}
export function useUpdateConversationTitle() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, nombre }: { id: string; nombre: string }) =>
update_conversation_title(id, nombre),
onSuccess: (_, variables) => {
// Invalidamos para que la lista de chats se refresque
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
},
})
return useMutation({ mutationFn: library_search });
}

View File

@@ -1,145 +1,59 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { throwIfError } from '../api/_helpers'
import { qk } from '../query/keys'
import { supabaseBrowser } from '../supabase/client'
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { supabaseBrowser } from "../supabase/client";
import { qk } from "../query/keys";
import { throwIfError } from "../api/_helpers";
export function useSession() {
const supabase = supabaseBrowser()
const qc = useQueryClient()
const supabase = supabaseBrowser();
const qc = useQueryClient();
const query = useQuery({
queryKey: qk.session(),
queryFn: async () => {
const { data, error } = await supabase.auth.getSession()
throwIfError(error)
return data.session ?? null
const { data, error } = await supabase.auth.getSession();
throwIfError(error);
return data.session ?? null;
},
staleTime: Infinity,
})
});
useEffect(() => {
const { data } = supabase.auth.onAuthStateChange(() => {
qc.invalidateQueries({ queryKey: qk.session() })
qc.invalidateQueries({ queryKey: qk.meProfile() })
qc.invalidateQueries({ queryKey: qk.meAccess() })
qc.invalidateQueries({ queryKey: qk.auth })
})
qc.invalidateQueries({ queryKey: qk.session() });
qc.invalidateQueries({ queryKey: qk.meProfile() });
qc.invalidateQueries({ queryKey: qk.auth });
});
return () => data.subscription.unsubscribe()
}, [supabase, qc])
return () => data.subscription.unsubscribe();
}, [supabase, qc]);
return query
return query;
}
export function useMeProfile() {
const supabase = supabaseBrowser()
const supabase = supabaseBrowser();
return useQuery({
queryKey: qk.meProfile(),
queryFn: async () => {
const { data: u, error: uErr } = await supabase.auth.getUser()
throwIfError(uErr)
const userId = u.user?.id
if (!userId) return null
const { data: u, error: uErr } = await supabase.auth.getUser();
throwIfError(uErr);
const userId = u.user?.id;
if (!userId) return null;
const { data, error } = await supabase
.from('usuarios_app')
.select('id,nombre_completo,email,externo,creado_en,actualizado_en')
.eq('id', userId)
.single()
.from("usuarios_app")
.select("id,nombre_completo,email,externo,creado_en,actualizado_en")
.eq("id", userId)
.single();
// si aún no existe perfil en usuarios_app, permite null (tu seed/trigger puede crearlo)
if (error && (error as any).code === 'PGRST116') return null
if (error && (error as any).code === "PGRST116") return null;
throwIfError(error)
return data ?? null
throwIfError(error);
return data ?? null;
},
staleTime: 60_000,
})
}
export type MeAccessRole = {
assignmentId: string
rolId: string
clave: string
nombre: string
descripcion: string | null
facultadId: string | null
carreraId: string | null
}
export type MeAccess = {
userId: string
roles: Array<MeAccessRole>
permissions: Array<string>
}
/**
* Database-first RBAC: obtiene roles del usuario desde tablas app (NO desde JWT).
*
* Nota: el esquema actual modela roles con `usuarios_roles` -> `roles`.
*/
export function useMeAccess() {
const supabase = supabaseBrowser()
return useQuery({
queryKey: qk.meAccess(),
queryFn: async (): Promise<MeAccess | null> => {
const { data: u, error: uErr } = await supabase.auth.getUser()
throwIfError(uErr)
const userId = u.user?.id
if (!userId) return null
const { data, error } = await supabase
.from('usuarios_roles')
.select(
'id,rol_id,facultad_id,carrera_id,roles(id,clave,nombre,descripcion)',
)
.eq('usuario_id', userId)
throwIfError(error)
const roles: Array<MeAccessRole> = (data ?? [])
.map((row: any) => {
const rol = row.roles
if (!rol) return null
return {
assignmentId: row.id,
rolId: rol.id,
clave: rol.clave,
nombre: rol.nombre,
descripcion: rol.descripcion ?? null,
facultadId: row.facultad_id ?? null,
carreraId: row.carrera_id ?? null,
} satisfies MeAccessRole
})
.filter(Boolean) as Array<MeAccessRole>
// Por ahora, los permisos granulares se derivan de claves de rol.
// Si luego existe una tabla `roles_permisos`, aquí se expande a permisos reales.
const permissions = Array.from(new Set(roles.map((r) => r.clave)))
return {
userId,
roles,
permissions,
}
},
staleTime: 30_000,
refetchOnWindowFocus: true,
})
}
export function useAuth() {
const session = useSession()
const meProfile = useMeProfile()
const meAccess = useMeAccess()
return {
session,
meProfile,
meAccess,
}
});
}

View File

@@ -3,17 +3,14 @@ import {
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { useEffect } from 'react'
} from "@tanstack/react-query";
import {
ai_generate_plan,
getCatalogos,
plan_asignaturas_list,
plan_lineas_list,
plans_clone_from_existing,
plans_create_manual,
plans_delete,
plans_generate_document,
plans_get,
plans_get_document,
@@ -24,18 +21,16 @@ import {
plans_transition_state,
plans_update_fields,
plans_update_map,
} from '../api/plans.api'
import { lineas_delete } from '../api/subjects.api'
import { qk } from '../query/keys'
import { supabaseBrowser } from '../supabase/client'
} from "../api/plans.api";
import { qk } from "../query/keys";
import type {
PlanListFilters,
PlanMapOperation,
PlansCreateManualInput,
PlansUpdateFieldsPatch,
} from '../api/plans.api'
import type { UUID } from '../types/domain'
} from "../api/plans.api";
import type { UUID } from "../types/domain";
export function usePlanes(filters: PlanListFilters) {
// 🧠 Tip: memoiza "filters" (useMemo) para que queryKey sea estable.
@@ -51,223 +46,124 @@ export function usePlanes(filters: PlanListFilters) {
// Opcional: Tiempo que la data se considera fresca
staleTime: 1000 * 60 * 5, // 5 minutos
})
});
}
export function usePlan(planId: UUID | null | undefined) {
return useQuery({
queryKey: planId ? qk.plan(planId) : ['planes', 'detail', null],
queryFn: () => {
console.log('usePlan')
return plans_get(planId as UUID)
},
queryKey: planId ? qk.plan(planId) : ["planes", "detail", null],
queryFn: () => plans_get(planId as UUID),
enabled: Boolean(planId),
})
});
}
export function usePlanLineas(planId: UUID | null | undefined) {
return useQuery({
queryKey: planId ? qk.planLineas(planId) : ['planes', 'lineas', null],
queryKey: planId ? qk.planLineas(planId) : ["planes", "lineas", null],
queryFn: () => plan_lineas_list(planId as UUID),
enabled: Boolean(planId),
})
});
}
export function usePlanAsignaturas(planId: UUID | null | undefined) {
const qc = useQueryClient()
const query = useQuery({
queryKey: planId
? qk.planAsignaturas(planId)
: ['planes', 'asignaturas', null],
queryFn: () => plan_asignaturas_list(planId as UUID),
enabled: Boolean(planId),
})
useEffect(() => {
if (!planId) return
const supabase = supabaseBrowser()
const channel = supabase.channel(`plan-asignaturas-${planId}`)
channel.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'asignaturas',
filter: `plan_estudio_id=eq.${planId}`,
},
(payload: {
eventType?: 'INSERT' | 'UPDATE' | 'DELETE'
new?: any
old?: any
}) => {
const eventType = payload.eventType
if (eventType === 'DELETE') {
const oldRow: any = payload.old
const deletedId = oldRow?.id
if (!deletedId) return
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
if (!Array.isArray(prev)) return prev
return prev.filter((a: any) => String(a?.id) !== String(deletedId))
})
return
}
const newRow: any = payload.new
if (!newRow?.id) return
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
if (!Array.isArray(prev)) return prev
const idx = prev.findIndex(
(a: any) => String(a?.id) === String(newRow.id),
)
if (idx === -1) return [...prev, newRow]
const next = [...prev]
next[idx] = { ...prev[idx], ...newRow }
return next
})
},
)
channel.subscribe()
return () => {
try {
supabase.removeChannel(channel)
} catch {
// noop
}
}
}, [planId, qc])
return query
}
export function usePlanHistorial(
planId: UUID | null | undefined,
page: number,
) {
return useQuery({
queryKey: planId
? [...qk.planHistorial(planId), page]
: ['planes', 'historial', null, page],
queryFn: () => plans_history(planId as UUID, page),
? qk.planAsignaturas(planId)
: ["planes", "asignaturas", null],
queryFn: () => plan_asignaturas_list(planId as UUID),
enabled: Boolean(planId),
placeholderData: (previousData) => previousData,
})
});
}
export function usePlanHistorial(planId: UUID | null | undefined) {
return useQuery({
queryKey: planId ? qk.planHistorial(planId) : ["planes", "historial", null],
queryFn: () => plans_history(planId as UUID),
enabled: Boolean(planId),
});
}
export function usePlanDocumento(planId: UUID | null | undefined) {
return useQuery({
queryKey: planId ? qk.planDocumento(planId) : ['planes', 'documento', null],
queryKey: planId ? qk.planDocumento(planId) : ["planes", "documento", null],
queryFn: () => plans_get_document(planId as UUID),
enabled: Boolean(planId),
staleTime: 30_000,
})
}
export function useCatalogosPlanes() {
return useQuery({
queryKey: qk.estructurasPlan(),
queryFn: getCatalogos,
staleTime: 1000 * 60 * 60, // 1 hora de caché (estos datos casi no cambian)
})
});
}
/* ------------------ Mutations ------------------ */
export function useCreatePlanManual() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: (input: PlansCreateManualInput) => plans_create_manual(input),
onSuccess: (plan) => {
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.setQueryData(qk.plan(plan.id), plan)
qc.invalidateQueries({ queryKey: ["planes", "list"] });
qc.setQueryData(qk.plan(plan.id), plan);
},
})
});
}
export function useGeneratePlanAI() {
const qc = useQueryClient()
return useMutation({
mutationFn: ai_generate_plan,
onSuccess: (data) => {
// Asumiendo que la Edge Function devuelve { ok: true, plan: { id: ... } }
console.log('success de ai_generate_plan')
const newPlan = data.plan
if (newPlan) {
// 1. Invalidar la lista para que aparezca el nuevo plan
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
// 2. (Opcional) Pre-cargar el dato individual para que la navegación sea instantánea
// qc.setQueryData(["planes", "detail", newPlan.id], newPlan);
}
},
})
});
}
// Funcion obsoleta porque ahora el plan se persiste directamente en useGeneratePlanAI
export function usePersistPlanFromAI() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: { jsonPlan: any }) => plans_persist_from_ai(payload),
onSuccess: (plan) => {
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.setQueryData(qk.plan(plan.id), plan)
qc.invalidateQueries({ queryKey: ["planes", "list"] });
qc.setQueryData(qk.plan(plan.id), plan);
},
})
});
}
export function useClonePlan() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: plans_clone_from_existing,
onSuccess: (plan) => {
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.setQueryData(qk.plan(plan.id), plan)
qc.invalidateQueries({ queryKey: ["planes", "list"] });
qc.setQueryData(qk.plan(plan.id), plan);
},
})
});
}
export function useImportPlanFromFiles() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: plans_import_from_files,
onSuccess: (plan) => {
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.setQueryData(qk.plan(plan.id), plan)
qc.invalidateQueries({ queryKey: ["planes", "list"] });
qc.setQueryData(qk.plan(plan.id), plan);
},
})
});
}
export function useUpdatePlanFields() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { planId: UUID; patch: PlansUpdateFieldsPatch }) =>
plans_update_fields(vars.planId, vars.patch),
onSuccess: (updated) => {
qc.setQueryData(qk.plan(updated.id), updated)
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.invalidateQueries({ queryKey: qk.planHistorial(updated.id) })
qc.setQueryData(qk.plan(updated.id), updated);
qc.invalidateQueries({ queryKey: ["planes", "list"] });
qc.invalidateQueries({ queryKey: qk.planHistorial(updated.id) });
},
})
});
}
export function useUpdatePlanMapa() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { planId: UUID; ops: Array<PlanMapOperation> }) =>
@@ -275,90 +171,61 @@ export function useUpdatePlanMapa() {
// ✅ Optimista (rápida) para el caso MOVE_ASIGNATURA
onMutate: async (vars) => {
await qc.cancelQueries({ queryKey: qk.planAsignaturas(vars.planId) })
const prev = qc.getQueryData<any>(qk.planAsignaturas(vars.planId))
await qc.cancelQueries({ queryKey: qk.planAsignaturas(vars.planId) });
const prev = qc.getQueryData<any>(qk.planAsignaturas(vars.planId));
// solo optimizamos MOVEs simples
const moves = vars.ops.filter((x) => x.op === 'MOVE_ASIGNATURA')
const moves = vars.ops.filter((x) => x.op === "MOVE_ASIGNATURA");
if (prev && Array.isArray(prev) && moves.length) {
const next = prev.map((a: any) => {
const m = moves.find((x) => x.asignaturaId === a.id)
if (!m) return a
const m = moves.find((x) => x.asignaturaId === a.id);
if (!m) return a;
return {
...a,
numero_ciclo: m.numero_ciclo,
linea_plan_id: m.linea_plan_id,
orden_celda: m.orden_celda ?? a.orden_celda,
}
})
qc.setQueryData(qk.planAsignaturas(vars.planId), next)
};
});
qc.setQueryData(qk.planAsignaturas(vars.planId), next);
}
return { prev }
return { prev };
},
onError: (_err, vars, ctx) => {
if (ctx?.prev) qc.setQueryData(qk.planAsignaturas(vars.planId), ctx.prev)
if (ctx?.prev) qc.setQueryData(qk.planAsignaturas(vars.planId), ctx.prev);
},
onSuccess: (_ok, vars) => {
qc.invalidateQueries({ queryKey: qk.planAsignaturas(vars.planId) })
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) })
qc.invalidateQueries({ queryKey: qk.planAsignaturas(vars.planId) });
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) });
},
})
});
}
export function useTransitionPlanEstado() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: plans_transition_state,
onSuccess: (_ok, vars) => {
qc.invalidateQueries({ queryKey: qk.plan(vars.planId) })
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) })
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.invalidateQueries({ queryKey: qk.plan(vars.planId) });
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) });
qc.invalidateQueries({ queryKey: ["planes", "list"] });
},
})
}
export function useDeletePlanEstudio() {
const qc = useQueryClient()
return useMutation({
mutationFn: (planId: UUID) => plans_delete(planId),
onSuccess: (_ok, planId) => {
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.removeQueries({ queryKey: qk.plan(planId) })
qc.removeQueries({ queryKey: qk.planMaybe(planId) })
qc.removeQueries({ queryKey: qk.planAsignaturas(planId) })
qc.removeQueries({ queryKey: qk.planLineas(planId) })
qc.removeQueries({ queryKey: qk.planHistorial(planId) })
qc.removeQueries({ queryKey: qk.planDocumento(planId) })
},
})
});
}
export function useGeneratePlanDocumento() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: (planId: UUID) => plans_generate_document(planId),
onSuccess: (_doc, planId) => {
qc.invalidateQueries({ queryKey: qk.planDocumento(planId) })
qc.invalidateQueries({ queryKey: qk.planHistorial(planId) })
qc.invalidateQueries({ queryKey: qk.planDocumento(planId) });
qc.invalidateQueries({ queryKey: qk.planHistorial(planId) });
},
})
}
export function useDeleteLinea() {
const qc = useQueryClient()
return useMutation({
mutationFn: lineas_delete,
onSuccess: (_idEliminado) => {
// Invalidamos para que las materias y líneas se refresquen
qc.invalidateQueries({ queryKey: ['plan_lineas'] })
qc.invalidateQueries({ queryKey: ['plan_asignaturas'] })
},
})
});
}

View File

@@ -1,319 +1,166 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../query/keys";
import type { UUID } from "../types/domain";
import type {
BibliografiaUpsertInput,
SubjectsCreateManualInput,
SubjectsUpdateFieldsPatch,
} from "../api/subjects.api";
import {
ai_generate_subject,
asignaturas_update,
bibliografia_delete,
bibliografia_insert,
bibliografia_update,
lineas_insert,
lineas_update,
subjects_bibliografia_list,
subjects_clone_from_existing,
subjects_create_manual,
subjects_generate_document,
subjects_get,
subjects_get_document,
subjects_get_structure_catalog,
subjects_history,
subjects_import_from_file,
subjects_persist_from_ai,
subjects_update_bibliografia,
subjects_update_contenido,
subjects_update_fields,
} from '../api/subjects.api'
import { qk } from '../query/keys'
import type {
BibliografiaUpsertInput,
ContenidoApi,
SubjectsUpdateFieldsPatch,
} from '../api/subjects.api'
import type { UUID } from '../types/domain'
import type { TablesInsert } from '@/types/supabase'
} from "../api/subjects.api";
export function useSubject(subjectId: UUID | null | undefined) {
return useQuery({
queryKey: subjectId
? qk.asignatura(subjectId)
: ['asignaturas', 'detail', null],
queryKey: subjectId ? qk.asignatura(subjectId) : ["asignaturas", "detail", null],
queryFn: () => subjects_get(subjectId as UUID),
enabled: Boolean(subjectId),
})
});
}
export function useSubjectBibliografia(subjectId: UUID | null | undefined) {
return useQuery({
queryKey: subjectId
? qk.asignaturaBibliografia(subjectId)
: ['asignaturas', 'bibliografia', null],
queryKey: subjectId ? qk.asignaturaBibliografia(subjectId) : ["asignaturas", "bibliografia", null],
queryFn: () => subjects_bibliografia_list(subjectId as UUID),
enabled: Boolean(subjectId),
})
});
}
export function useSubjectHistorial(subjectId: UUID | null | undefined) {
return useQuery({
queryKey: subjectId
? qk.asignaturaHistorial(subjectId)
: ['asignaturas', 'historial', null],
queryKey: subjectId ? qk.asignaturaHistorial(subjectId) : ["asignaturas", "historial", null],
queryFn: () => subjects_history(subjectId as UUID),
enabled: Boolean(subjectId),
})
});
}
export function useSubjectDocumento(subjectId: UUID | null | undefined) {
return useQuery({
queryKey: subjectId
? qk.asignaturaDocumento(subjectId)
: ['asignaturas', 'documento', null],
queryKey: subjectId ? qk.asignaturaDocumento(subjectId) : ["asignaturas", "documento", null],
queryFn: () => subjects_get_document(subjectId as UUID),
enabled: Boolean(subjectId),
staleTime: 30_000,
})
}
export function useSubjectEstructuras() {
return useQuery({
queryKey: qk.estructurasAsignatura(),
queryFn: () => subjects_get_structure_catalog(),
})
});
}
/* ------------------ Mutations ------------------ */
export function useCreateSubjectManual() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: TablesInsert<'asignaturas'>) =>
subjects_create_manual(payload),
mutationFn: (payload: SubjectsCreateManualInput) => subjects_create_manual(payload),
onSuccess: (subject) => {
qc.setQueryData(qk.asignatura(subject.id), subject)
qc.invalidateQueries({
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(subject.plan_estudio_id),
})
qc.setQueryData(qk.asignatura(subject.id), subject);
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
},
})
});
}
export function useGenerateSubjectAI() {
return useMutation({
mutationFn: ai_generate_subject,
})
return useMutation({ mutationFn: ai_generate_subject });
}
export function usePersistSubjectFromAI() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: { planId: UUID; jsonAsignatura: any }) =>
subjects_persist_from_ai(payload),
mutationFn: (payload: { planId: UUID; jsonMateria: any }) => subjects_persist_from_ai(payload),
onSuccess: (subject) => {
qc.setQueryData(qk.asignatura(subject.id), subject)
qc.invalidateQueries({
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(subject.plan_estudio_id),
})
qc.setQueryData(qk.asignatura(subject.id), subject);
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
},
})
});
}
export function useCloneSubject() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: subjects_clone_from_existing,
onSuccess: (subject) => {
qc.setQueryData(qk.asignatura(subject.id), subject)
qc.invalidateQueries({
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(subject.plan_estudio_id),
})
qc.setQueryData(qk.asignatura(subject.id), subject);
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
},
})
});
}
export function useImportSubjectFromFile() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: subjects_import_from_file,
onSuccess: (subject) => {
qc.setQueryData(qk.asignatura(subject.id), subject)
qc.invalidateQueries({
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(subject.plan_estudio_id),
})
qc.setQueryData(qk.asignatura(subject.id), subject);
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
},
})
});
}
export function useUpdateSubjectFields() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) =>
subjects_update_fields(vars.subjectId, vars.patch),
onSuccess: (updated) => {
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
prev ? { ...(prev as any), ...(updated as any) } : updated,
)
qc.invalidateQueries({
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
})
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
qc.setQueryData(qk.asignatura(updated.id), updated);
qc.invalidateQueries({ queryKey: qk.planAsignaturas(updated.plan_estudio_id) });
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) });
},
})
});
}
export function useUpdateSubjectContenido() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { subjectId: UUID; unidades: Array<ContenidoApi> }) =>
mutationFn: (vars: { subjectId: UUID; unidades: any[] }) =>
subjects_update_contenido(vars.subjectId, vars.unidades),
onSuccess: (updated) => {
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
prev ? { ...(prev as any), ...(updated as any) } : updated,
)
qc.invalidateQueries({
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(updated.plan_estudio_id),
})
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
qc.setQueryData(qk.asignatura(updated.id), updated);
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) });
},
})
});
}
export function useUpdateSubjectBibliografia() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { subjectId: UUID; entries: BibliografiaUpsertInput }) =>
subjects_update_bibliografia(vars.subjectId, vars.entries),
onSuccess: (_ok, vars) => {
qc.invalidateQueries({
queryKey: qk.asignaturaBibliografia(vars.subjectId),
})
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(vars.subjectId) })
qc.invalidateQueries({ queryKey: qk.asignaturaBibliografia(vars.subjectId) });
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(vars.subjectId) });
},
})
});
}
export function useGenerateSubjectDocumento() {
const qc = useQueryClient()
const qc = useQueryClient();
return useMutation({
mutationFn: (subjectId: UUID) => subjects_generate_document(subjectId),
onSuccess: (_doc, subjectId) => {
qc.invalidateQueries({ queryKey: qk.asignaturaDocumento(subjectId) })
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(subjectId) })
qc.invalidateQueries({ queryKey: qk.asignaturaDocumento(subjectId) });
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(subjectId) });
},
})
}
export function useUpdateAsignatura() {
const qc = useQueryClient()
return useMutation({
mutationFn: (vars: {
asignaturaId: UUID
patch: Partial<SubjectsUpdateFieldsPatch>
}) => asignaturas_update(vars.asignaturaId, vars.patch),
onSuccess: (updated) => {
// ✅ Mantener consistencia con las query keys centralizadas (qk)
// 1) Actualiza el detalle (esto evita volver a entrar con caché vieja)
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
prev ? { ...(prev as any), ...(updated as any) } : updated,
)
// 2) Refresca vistas derivadas del plan
qc.invalidateQueries({
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(updated.plan_estudio_id),
})
// 3) Refresca historial de la asignatura si existe
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
},
})
}
export function useCreateLinea() {
const qc = useQueryClient()
return useMutation({
mutationFn: lineas_insert,
onSuccess: (nuevaLinea) => {
qc.invalidateQueries({
queryKey: ['plan_lineas', nuevaLinea.plan_estudio_id],
})
},
})
}
export function useUpdateLinea() {
const qc = useQueryClient()
return useMutation({
mutationFn: (vars: { lineaId: string; patch: any }) =>
lineas_update(vars.lineaId, vars.patch),
onSuccess: (updated) => {
qc.invalidateQueries({
queryKey: ['plan_lineas', updated.plan_estudio_id],
})
},
})
}
export function useCreateBibliografia() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: bibliografia_insert,
onSuccess: (data) => {
// USAR LA MISMA LLAVE QUE EL HOOK DE LECTURA
queryClient.invalidateQueries({
queryKey: qk.asignaturaBibliografia(data.asignatura_id),
})
},
})
}
export function useUpdateBibliografia(asignaturaId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, updates }: { id: string; updates: any }) =>
bibliografia_update(id, updates),
onSuccess: () => {
qc.invalidateQueries({
queryKey: qk.asignaturaBibliografia(asignaturaId),
})
},
})
}
export function useDeleteBibliografia(asignaturaId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => bibliografia_delete(id),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: qk.asignaturaBibliografia(asignaturaId),
})
},
})
});
}

View File

@@ -1,311 +0,0 @@
import type {
Asignatura,
AsignaturaStructure,
UnidadTematica,
BibliografiaEntry,
CambioAsignatura,
DocumentoAsignatura,
} from '@/types/asignatura'
export const mockAsignatura: Asignatura = {
id: '1',
nombre: 'Inteligencia Artificial Aplicada',
clave: 'IAA-401',
creditos: 8,
lineaCurricular: 'Sistemas Inteligentes',
ciclo: '7° Semestre',
planId: 'plan-1',
planNombre: 'Licenciatura en Ingeniería en Sistemas Computacionales 2024',
carrera: 'Ingeniería en Sistemas Computacionales',
facultad: 'Facultad de Ingeniería',
estructuraId: 'estructura-1',
}
export const mockEstructura: AsignaturaStructure = {
id: 'estructura-1',
nombre: 'Plantilla SEP Licenciatura',
campos: [
{
id: 'objetivo_general',
nombre: 'Objetivo General',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Describe el propósito principal de la asignatura',
placeholder: 'Al finalizar el curso, el estudiante será capaz de...',
},
{
id: 'competencias',
nombre: 'Competencias a Desarrollar',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Competencias profesionales que se desarrollarán',
},
{
id: 'justificacion',
nombre: 'Justificación',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Relevancia de la asignatura en el plan de estudios',
},
{
id: 'requisitos',
nombre: 'Requisitos / Seriación',
tipo: 'texto',
obligatorio: false,
descripcion: 'Asignaturas previas requeridas',
},
{
id: 'estrategias_didacticas',
nombre: 'Estrategias Didácticas',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Métodos de enseñanza-aprendizaje',
},
{
id: 'evaluacion',
nombre: 'Sistema de Evaluación',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Criterios y porcentajes de evaluación',
},
{
id: 'perfil_docente',
nombre: 'Perfil del Docente',
tipo: 'texto_largo',
obligatorio: false,
descripcion: 'Características requeridas del profesor',
},
],
}
export const mockDatosGenerales: Record<string, any> = {
objetivo_general:
'Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos del mundo real, aplicando principios éticos y metodologías actuales en el campo.',
competencias:
'• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes y texto\n• Evaluar y optimizar modelos de IA considerando métricas de rendimiento\n• Aplicar principios éticos en el desarrollo de sistemas inteligentes',
justificacion:
'La inteligencia artificial es una de las tecnologías más disruptivas del siglo XXI. Su integración en diversos sectores demanda profesionales con sólidas bases teóricas y prácticas. Esta asignatura proporciona las competencias necesarias para que el egresado pueda innovar y contribuir al desarrollo tecnológico del país.',
requisitos:
'Programación Avanzada (PAV-301), Matemáticas Discretas (MAT-201)',
estrategias_didacticas:
'• Aprendizaje basado en proyectos\n• Talleres prácticos con datasets reales\n• Exposiciones y discusiones grupales\n• Análisis de casos de estudio\n• Desarrollo de prototipo integrador',
evaluacion:
'• Exámenes parciales: 30%\n• Proyecto integrador: 35%\n• Prácticas de laboratorio: 20%\n• Participación y tareas: 15%',
perfil_docente:
'Profesional con maestría o doctorado en áreas afines a la inteligencia artificial, con experiencia mínima de 3 años en docencia y desarrollo de proyectos de IA.',
}
export const mockContenidoTematico: Array<UnidadTematica> = [
{
id: 'unidad-1',
nombre: 'Fundamentos de Inteligencia Artificial',
numero: 1,
temas: [
{
id: 'tema-1-1',
nombre: 'Historia y evolución de la IA',
descripcion: 'Desde los orígenes hasta la actualidad',
horasEstimadas: 2,
},
{
id: 'tema-1-2',
nombre: 'Tipos de IA y aplicaciones',
descripcion: 'IA débil, fuerte y superinteligencia',
horasEstimadas: 3,
},
{
id: 'tema-1-3',
nombre: 'Ética en IA',
descripcion: 'Consideraciones éticas y responsabilidad',
horasEstimadas: 2,
},
],
},
{
id: 'unidad-2',
nombre: 'Machine Learning',
numero: 2,
temas: [
{
id: 'tema-2-1',
nombre: 'Aprendizaje supervisado',
descripcion: 'Regresión y clasificación',
horasEstimadas: 6,
},
{
id: 'tema-2-2',
nombre: 'Aprendizaje no supervisado',
descripcion: 'Clustering y reducción de dimensionalidad',
horasEstimadas: 5,
},
{
id: 'tema-2-3',
nombre: 'Evaluación de modelos',
descripcion: 'Métricas y validación cruzada',
horasEstimadas: 4,
},
],
},
{
id: 'unidad-3',
nombre: 'Deep Learning',
numero: 3,
temas: [
{
id: 'tema-3-1',
nombre: 'Redes neuronales artificiales',
descripcion: 'Perceptrón y backpropagation',
horasEstimadas: 5,
},
{
id: 'tema-3-2',
nombre: 'Redes convolucionales (CNN)',
descripcion: 'Procesamiento de imágenes',
horasEstimadas: 6,
},
{
id: 'tema-3-3',
nombre: 'Redes recurrentes (RNN)',
descripcion: 'Procesamiento de secuencias',
horasEstimadas: 5,
},
{
id: 'tema-3-4',
nombre: 'Transformers y atención',
descripcion: 'Arquitecturas modernas',
horasEstimadas: 6,
},
],
},
{
id: 'unidad-4',
nombre: 'Aplicaciones Prácticas',
numero: 4,
temas: [
{
id: 'tema-4-1',
nombre: 'Procesamiento de lenguaje natural',
descripcion: 'NLP y chatbots',
horasEstimadas: 6,
},
{
id: 'tema-4-2',
nombre: 'Visión por computadora',
descripcion: 'Detección y reconocimiento',
horasEstimadas: 5,
},
{
id: 'tema-4-3',
nombre: 'Sistemas de recomendación',
descripcion: 'Filtrado colaborativo y contenido',
horasEstimadas: 4,
},
],
},
]
export const mockBibliografia: Array<BibliografiaEntry> = [
{
id: 'bib-1',
tipo: 'BASICA',
cita: 'Russell, S., & Norvig, P. (2021). Artificial Intelligence: A Modern Approach (4th ed.). Pearson.',
fuenteBibliotecaId: 'lib-1',
fuenteBiblioteca: {
id: 'lib-1',
titulo: 'Artificial Intelligence: A Modern Approach',
autor: 'Stuart Russell, Peter Norvig',
editorial: 'Pearson',
anio: 2021,
isbn: '978-0134610993',
tipo: 'libro',
disponible: true,
},
},
{
id: 'bib-2',
tipo: 'BASICA',
cita: "Géron, A. (2022). Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow (3rd ed.). O'Reilly Media.",
fuenteBibliotecaId: 'lib-2',
fuenteBiblioteca: {
id: 'lib-2',
titulo:
'Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow',
autor: 'Aurélien Géron',
editorial: "O'Reilly Media",
anio: 2022,
isbn: '978-1098125974',
tipo: 'libro',
disponible: true,
},
},
{
id: 'bib-3',
tipo: 'COMPLEMENTARIA',
cita: 'Goodfellow, I., Bengio, Y., & Courville, A. (2016). Deep Learning. MIT Press.',
},
{
id: 'bib-4',
tipo: 'COMPLEMENTARIA',
cita: 'Chollet, F. (2021). Deep Learning with Python (2nd ed.). Manning Publications.',
fuenteBibliotecaId: 'lib-4',
fuenteBiblioteca: {
id: 'lib-4',
titulo: 'Deep Learning with Python',
autor: 'François Chollet',
editorial: 'Manning Publications',
anio: 2021,
isbn: '978-1617296864',
tipo: 'libro',
disponible: false,
},
},
]
export const mockHistorial: Array<CambioAsignatura> = [
{
id: 'cambio-1',
tipo: 'datos',
descripcion: 'Actualización del objetivo general',
usuario: 'Dr. Carlos Méndez',
fecha: new Date('2024-12-10T14:30:00'),
detalles: { campo: 'objetivo_general' },
},
{
id: 'cambio-2',
tipo: 'contenido',
descripcion: 'Agregada Unidad 4: Aplicaciones Prácticas',
usuario: 'Dr. Carlos Méndez',
fecha: new Date('2024-12-09T10:15:00'),
detalles: { unidad: 'Unidad 4' },
},
{
id: 'cambio-3',
tipo: 'ia',
descripcion: 'IA mejoró las competencias a desarrollar',
usuario: 'Dra. María López',
fecha: new Date('2024-12-08T16:45:00'),
detalles: { campo: 'competencias', accion: 'mejora' },
},
{
id: 'cambio-4',
tipo: 'bibliografia',
descripcion: 'Añadida referencia: Deep Learning with Python',
usuario: 'Biblioteca Central',
fecha: new Date('2024-12-07T09:00:00'),
},
{
id: 'cambio-5',
tipo: 'documento',
descripcion: 'Documento SEP regenerado (versión 3)',
usuario: 'Sistema',
fecha: new Date('2024-12-06T11:30:00'),
},
]
export const mockDocumentoSep: DocumentoAsignatura = {
id: 'doc-1',
asignaturaId: '1',
version: 3,
fechaGeneracion: new Date('2024-12-06T11:30:00'),
estado: 'listo',
}

302
src/data/mockMateriaData.ts Normal file
View File

@@ -0,0 +1,302 @@
import type {
Materia,
MateriaStructure,
UnidadTematica,
BibliografiaEntry,
CambioMateria,
DocumentoMateria,
LibraryResource
} from '@/types/materia';
export const mockMateria: Materia = {
id: '1',
nombre: 'Inteligencia Artificial Aplicada',
clave: 'IAA-401',
creditos: 8,
lineaCurricular: 'Sistemas Inteligentes',
ciclo: '7° Semestre',
planId: 'plan-1',
planNombre: 'Licenciatura en Ingeniería en Sistemas Computacionales 2024',
carrera: 'Ingeniería en Sistemas Computacionales',
facultad: 'Facultad de Ingeniería',
estructuraId: 'estructura-1',
};
export const mockEstructura: MateriaStructure = {
id: 'estructura-1',
nombre: 'Plantilla SEP Licenciatura',
campos: [
{
id: 'objetivo_general',
nombre: 'Objetivo General',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Describe el propósito principal de la materia',
placeholder: 'Al finalizar el curso, el estudiante será capaz de...',
},
{
id: 'competencias',
nombre: 'Competencias a Desarrollar',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Competencias profesionales que se desarrollarán',
},
{
id: 'justificacion',
nombre: 'Justificación',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Relevancia de la materia en el plan de estudios',
},
{
id: 'requisitos',
nombre: 'Requisitos / Seriación',
tipo: 'texto',
obligatorio: false,
descripcion: 'Materias previas requeridas',
},
{
id: 'estrategias_didacticas',
nombre: 'Estrategias Didácticas',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Métodos de enseñanza-aprendizaje',
},
{
id: 'evaluacion',
nombre: 'Sistema de Evaluación',
tipo: 'texto_largo',
obligatorio: true,
descripcion: 'Criterios y porcentajes de evaluación',
},
{
id: 'perfil_docente',
nombre: 'Perfil del Docente',
tipo: 'texto_largo',
obligatorio: false,
descripcion: 'Características requeridas del profesor',
},
],
};
export const mockDatosGenerales: Record<string, any> = {
objetivo_general: 'Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos del mundo real, aplicando principios éticos y metodologías actuales en el campo.',
competencias: '• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes y texto\n• Evaluar y optimizar modelos de IA considerando métricas de rendimiento\n• Aplicar principios éticos en el desarrollo de sistemas inteligentes',
justificacion: 'La inteligencia artificial es una de las tecnologías más disruptivas del siglo XXI. Su integración en diversos sectores demanda profesionales con sólidas bases teóricas y prácticas. Esta materia proporciona las competencias necesarias para que el egresado pueda innovar y contribuir al desarrollo tecnológico del país.',
requisitos: 'Programación Avanzada (PAV-301), Matemáticas Discretas (MAT-201)',
estrategias_didacticas: '• Aprendizaje basado en proyectos\n• Talleres prácticos con datasets reales\n• Exposiciones y discusiones grupales\n• Análisis de casos de estudio\n• Desarrollo de prototipo integrador',
evaluacion: '• Exámenes parciales: 30%\n• Proyecto integrador: 35%\n• Prácticas de laboratorio: 20%\n• Participación y tareas: 15%',
perfil_docente: 'Profesional con maestría o doctorado en áreas afines a la inteligencia artificial, con experiencia mínima de 3 años en docencia y desarrollo de proyectos de IA.',
};
export const mockContenidoTematico: UnidadTematica[] = [
{
id: 'unidad-1',
nombre: 'Fundamentos de Inteligencia Artificial',
numero: 1,
temas: [
{ id: 'tema-1-1', nombre: 'Historia y evolución de la IA', descripcion: 'Desde los orígenes hasta la actualidad', horasEstimadas: 2 },
{ id: 'tema-1-2', nombre: 'Tipos de IA y aplicaciones', descripcion: 'IA débil, fuerte y superinteligencia', horasEstimadas: 3 },
{ id: 'tema-1-3', nombre: 'Ética en IA', descripcion: 'Consideraciones éticas y responsabilidad', horasEstimadas: 2 },
],
},
{
id: 'unidad-2',
nombre: 'Machine Learning',
numero: 2,
temas: [
{ id: 'tema-2-1', nombre: 'Aprendizaje supervisado', descripcion: 'Regresión y clasificación', horasEstimadas: 6 },
{ id: 'tema-2-2', nombre: 'Aprendizaje no supervisado', descripcion: 'Clustering y reducción de dimensionalidad', horasEstimadas: 5 },
{ id: 'tema-2-3', nombre: 'Evaluación de modelos', descripcion: 'Métricas y validación cruzada', horasEstimadas: 4 },
],
},
{
id: 'unidad-3',
nombre: 'Deep Learning',
numero: 3,
temas: [
{ id: 'tema-3-1', nombre: 'Redes neuronales artificiales', descripcion: 'Perceptrón y backpropagation', horasEstimadas: 5 },
{ id: 'tema-3-2', nombre: 'Redes convolucionales (CNN)', descripcion: 'Procesamiento de imágenes', horasEstimadas: 6 },
{ id: 'tema-3-3', nombre: 'Redes recurrentes (RNN)', descripcion: 'Procesamiento de secuencias', horasEstimadas: 5 },
{ id: 'tema-3-4', nombre: 'Transformers y atención', descripcion: 'Arquitecturas modernas', horasEstimadas: 6 },
],
},
{
id: 'unidad-4',
nombre: 'Aplicaciones Prácticas',
numero: 4,
temas: [
{ id: 'tema-4-1', nombre: 'Procesamiento de lenguaje natural', descripcion: 'NLP y chatbots', horasEstimadas: 6 },
{ id: 'tema-4-2', nombre: 'Visión por computadora', descripcion: 'Detección y reconocimiento', horasEstimadas: 5 },
{ id: 'tema-4-3', nombre: 'Sistemas de recomendación', descripcion: 'Filtrado colaborativo y contenido', horasEstimadas: 4 },
],
},
];
export const mockBibliografia: BibliografiaEntry[] = [
{
id: 'bib-1',
tipo: 'BASICA',
cita: 'Russell, S., & Norvig, P. (2021). Artificial Intelligence: A Modern Approach (4th ed.). Pearson.',
fuenteBibliotecaId: 'lib-1',
fuenteBiblioteca: {
id: 'lib-1',
titulo: 'Artificial Intelligence: A Modern Approach',
autor: 'Stuart Russell, Peter Norvig',
editorial: 'Pearson',
anio: 2021,
isbn: '978-0134610993',
tipo: 'libro',
disponible: true,
},
},
{
id: 'bib-2',
tipo: 'BASICA',
cita: 'Géron, A. (2022). Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow (3rd ed.). O\'Reilly Media.',
fuenteBibliotecaId: 'lib-2',
fuenteBiblioteca: {
id: 'lib-2',
titulo: 'Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow',
autor: 'Aurélien Géron',
editorial: 'O\'Reilly Media',
anio: 2022,
isbn: '978-1098125974',
tipo: 'libro',
disponible: true,
},
},
{
id: 'bib-3',
tipo: 'COMPLEMENTARIA',
cita: 'Goodfellow, I., Bengio, Y., & Courville, A. (2016). Deep Learning. MIT Press.',
},
{
id: 'bib-4',
tipo: 'COMPLEMENTARIA',
cita: 'Chollet, F. (2021). Deep Learning with Python (2nd ed.). Manning Publications.',
fuenteBibliotecaId: 'lib-4',
fuenteBiblioteca: {
id: 'lib-4',
titulo: 'Deep Learning with Python',
autor: 'François Chollet',
editorial: 'Manning Publications',
anio: 2021,
isbn: '978-1617296864',
tipo: 'libro',
disponible: false,
},
},
];
export const mockHistorial: CambioMateria[] = [
{
id: 'cambio-1',
tipo: 'datos',
descripcion: 'Actualización del objetivo general',
usuario: 'Dr. Carlos Méndez',
fecha: new Date('2024-12-10T14:30:00'),
detalles: { campo: 'objetivo_general' },
},
{
id: 'cambio-2',
tipo: 'contenido',
descripcion: 'Agregada Unidad 4: Aplicaciones Prácticas',
usuario: 'Dr. Carlos Méndez',
fecha: new Date('2024-12-09T10:15:00'),
detalles: { unidad: 'Unidad 4' },
},
{
id: 'cambio-3',
tipo: 'ia',
descripcion: 'IA mejoró las competencias a desarrollar',
usuario: 'Dra. María López',
fecha: new Date('2024-12-08T16:45:00'),
detalles: { campo: 'competencias', accion: 'mejora' },
},
{
id: 'cambio-4',
tipo: 'bibliografia',
descripcion: 'Añadida referencia: Deep Learning with Python',
usuario: 'Biblioteca Central',
fecha: new Date('2024-12-07T09:00:00'),
},
{
id: 'cambio-5',
tipo: 'documento',
descripcion: 'Documento SEP regenerado (versión 3)',
usuario: 'Sistema',
fecha: new Date('2024-12-06T11:30:00'),
},
];
export const mockDocumentoSep: DocumentoMateria = {
id: 'doc-1',
materiaId: '1',
version: 3,
fechaGeneracion: new Date('2024-12-06T11:30:00'),
estado: 'listo',
};
export const mockLibraryResources: LibraryResource[] = [
{
id: 'lib-1',
titulo: 'Artificial Intelligence: A Modern Approach',
autor: 'Stuart Russell, Peter Norvig',
editorial: 'Pearson',
anio: 2021,
isbn: '978-0134610993',
tipo: 'libro',
disponible: true,
},
{
id: 'lib-2',
titulo: 'Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow',
autor: 'Aurélien Géron',
editorial: 'O\'Reilly Media',
anio: 2022,
isbn: '978-1098125974',
tipo: 'libro',
disponible: true,
},
{
id: 'lib-3',
titulo: 'Pattern Recognition and Machine Learning',
autor: 'Christopher Bishop',
editorial: 'Springer',
anio: 2006,
isbn: '978-0387310732',
tipo: 'libro',
disponible: true,
},
{
id: 'lib-4',
titulo: 'Deep Learning with Python',
autor: 'François Chollet',
editorial: 'Manning Publications',
anio: 2021,
isbn: '978-1617296864',
tipo: 'libro',
disponible: false,
},
{
id: 'lib-5',
titulo: 'Neural Networks and Deep Learning: A Textbook',
autor: 'Charu C. Aggarwal',
editorial: 'Springer',
anio: 2023,
isbn: '978-3031296413',
tipo: 'libro',
disponible: true,
},
{
id: 'lib-6',
titulo: 'Machine Learning: A Probabilistic Perspective',
autor: 'Kevin Murphy',
editorial: 'MIT Press',
anio: 2012,
isbn: '978-0262018029',
tipo: 'libro',
disponible: true,
},
];

View File

@@ -1,38 +1,31 @@
export const qk = {
auth: ['auth'] as const,
session: () => ['auth', 'session'] as const,
meProfile: () => ['auth', 'meProfile'] as const,
meAccess: () => ['auth', 'meAccess'] as const,
auth: ["auth"] as const,
session: () => ["auth", "session"] as const,
meProfile: () => ["auth", "meProfile"] as const,
facultades: () => ['meta', 'facultades'] as const,
facultades: () => ["meta", "facultades"] as const,
carreras: (facultadId?: string | null) =>
['meta', 'carreras', { facultadId: facultadId ?? null }] as const,
["meta", "carreras", { facultadId: facultadId ?? null }] as const,
estructurasPlan: (nivel?: string | null) =>
['meta', 'estructurasPlan', { nivel: nivel ?? null }] as const,
estructurasAsignatura: () => ['meta', 'estructurasAsignatura'] as const,
estadosPlan: () => ['meta', 'estadosPlan'] as const,
["meta", "estructurasPlan", { nivel: nivel ?? null }] as const,
estructurasAsignatura: () => ["meta", "estructurasAsignatura"] as const,
estadosPlan: () => ["meta", "estadosPlan"] as const,
planesList: (filters: unknown) => ['planes', 'list', filters] as const,
plan: (planId: string) => ['planes', 'detail', planId] as const,
planMaybe: (planId: string) => ['planes', 'detail-maybe', planId] as const,
planLineas: (planId: string) => ['planes', planId, 'lineas'] as const,
planAsignaturas: (planId: string) =>
['planes', planId, 'asignaturas'] as const,
planHistorial: (planId: string) => ['planes', planId, 'historial'] as const,
planDocumento: (planId: string) => ['planes', planId, 'documento'] as const,
planesList: (filters: unknown) => ["planes", "list", filters] as const,
plan: (planId: string) => ["planes", "detail", planId] as const,
planLineas: (planId: string) => ["planes", planId, "lineas"] as const,
planAsignaturas: (planId: string) => ["planes", planId, "asignaturas"] as const,
planHistorial: (planId: string) => ["planes", planId, "historial"] as const,
planDocumento: (planId: string) => ["planes", planId, "documento"] as const,
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
asignatura: (asignaturaId: string) =>
['asignaturas', 'detail', asignaturaId] as const,
asignaturaMaybe: (asignaturaId: string) =>
['asignaturas', 'detail-maybe', asignaturaId] as const,
asignatura: (asignaturaId: string) => ["asignaturas", "detail", asignaturaId] as const,
asignaturaBibliografia: (asignaturaId: string) =>
['asignaturas', asignaturaId, 'bibliografia'] as const,
["asignaturas", asignaturaId, "bibliografia"] as const,
asignaturaHistorial: (asignaturaId: string) =>
['asignaturas', asignaturaId, 'historial'] as const,
["asignaturas", asignaturaId, "historial"] as const,
asignaturaDocumento: (asignaturaId: string) =>
['asignaturas', asignaturaId, 'documento'] as const,
["asignaturas", asignaturaId, "documento"] as const,
tareas: () => ['tareas', 'mias'] as const,
notificaciones: () => ['notificaciones', 'mias'] as const,
}
tareas: () => ["tareas", "mias"] as const,
notificaciones: () => ["notificaciones", "mias"] as const,
};

View File

@@ -1,44 +1,8 @@
import {
MutationCache,
QueryCache,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import { qk } from './keys'
import type React from 'react'
function isRlsViolationError(error: unknown): boolean {
const anyErr = error as any
const code = anyErr?.code
const status = anyErr?.status ?? anyErr?.response?.status
console.log('Checking RLS violation error:', { code, status })
// Supabase/PostgREST suele devolver 403 (Forbidden) o código PG 42501 (insufficient_privilege)
return status === 403 || code === '42501'
}
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export function getContext() {
const queryClientRef: { current: QueryClient | null } = { current: null }
const handleAuthzDesync = (error: unknown) => {
if (!isRlsViolationError(error)) return
// Forzar resincronización “database-first” del rol/permisos
console.log('RLS violation detected, invalidating queries...')
queryClientRef.current?.invalidateQueries({ queryKey: qk.meAccess() })
}
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
handleAuthzDesync(error)
},
}),
mutationCache: new MutationCache({
onError: (error) => {
handleAuthzDesync(error)
},
}),
const queryClient = new QueryClient(
{
defaultOptions: {
queries: {
staleTime: 30_000,
@@ -49,9 +13,8 @@ export function getContext() {
retry: 0,
},
},
})
queryClientRef.current = queryClient
}
)
return {
queryClient,
}

View File

@@ -3,7 +3,7 @@ import { createClient } from "@supabase/supabase-js";
import { getEnv } from "./env";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "src/types/supabase";
import type { Database } from "src/types/supabase.js";
let _client: SupabaseClient<Database> | null = null;

View File

@@ -1,108 +1,47 @@
import {
FunctionsFetchError,
FunctionsHttpError,
FunctionsRelayError,
} from '@supabase/supabase-js'
import { supabaseBrowser } from './client'
import type { Database } from '@/types/supabase'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "../types/database";
import { supabaseBrowser } from "./client";
export type EdgeInvokeOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
headers?: Record<string, string>
}
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
headers?: Record<string, string>;
};
export class EdgeFunctionError extends Error {
constructor(
message: string,
public readonly functionName: string,
public readonly status?: number,
public readonly details?: unknown,
public readonly details?: unknown
) {
super(message)
this.name = 'EdgeFunctionError'
super(message);
this.name = "EdgeFunctionError";
}
}
export async function invokeEdge<TOut>(
functionName: string,
body?:
| string
| File
| Blob
| ArrayBuffer
| FormData
| ReadableStream<Uint8Array<ArrayBufferLike>>
| Record<string, unknown>
| undefined,
body?: unknown,
opts: EdgeInvokeOptions = {},
client?: SupabaseClient<Database>,
client?: SupabaseClient<Database>
): Promise<TOut> {
const supabase = client ?? supabaseBrowser()
const supabase = client ?? supabaseBrowser();
const { data, error } = await supabase.functions.invoke(functionName, {
body,
method: opts.method ?? 'POST',
method: opts.method ?? "POST",
headers: opts.headers,
})
});
if (error) {
// Valores por defecto (por si falla el parseo o es otro tipo de error)
let message = error.message // El genérico "returned a non-2xx status code"
let status = undefined
let details: unknown = error
// 2. Verificamos si es un error HTTP (4xx o 5xx) que trae cuerpo JSON
if (error instanceof FunctionsHttpError) {
try {
// Obtenemos el status real (ej. 404, 400)
status = error.context.status
// ¡LA CLAVE! Leemos el JSON que tu Edge Function envió
const errorBody = await error.context.json()
details = errorBody
// Intentamos extraer el mensaje humano según tu estructura { error: { message: "..." } }
// o la estructura simple { error: "..." }
if (errorBody && typeof errorBody === 'object') {
// Caso 1: Estructura anidada (la que definimos hace poco: { error: { message: "..." } })
if (
'error' in errorBody &&
typeof errorBody.error === 'object' &&
errorBody.error !== null &&
'message' in errorBody.error
) {
message = (errorBody.error as { message: string }).message
}
// Caso 2: Estructura simple ({ error: "Mensaje de error" })
else if (
'error' in errorBody &&
typeof errorBody.error === 'string'
) {
message = errorBody.error
}
// Caso 3: Propiedad message directa ({ message: "..." })
else if (
'message' in errorBody &&
typeof errorBody.message === 'string'
) {
message = errorBody.message
}
}
} catch (e) {
console.warn('No se pudo parsear el error JSON de la Edge Function', e)
}
} else if (error instanceof FunctionsRelayError) {
message = `Error de Relay Supabase: ${error.message}`
} else if (error instanceof FunctionsFetchError) {
message = `Error de conexión (Fetch): ${error.message}`
const anyErr = error as any;
throw new EdgeFunctionError(
anyErr.message ?? "Error en Edge Function",
functionName,
anyErr.status,
anyErr
);
}
// 3. Lanzamos tu error personalizado con los datos reales extraídos
throw new EdgeFunctionError(message, functionName, status, details)
}
return data as TOut
return data as TOut;
}

View File

@@ -51,17 +51,6 @@ export type PlanDatosSep = {
propuesta_de_evaluacion_periodica_del_plan_de_estudios?: string | null;
};
export type PlanEstudioWithRel =
& Tables<"planes_estudio">
& {
carreras:
| Tables<"carreras"> & {
facultades: Tables<"facultades"> | null;
}
| null;
estados_plan: Tables<"estados_plan"> | null;
};
export type Paged<T> = { data: Array<T>; count: number | null };
export type FacultadRow = Tables<"facultades">;

View File

@@ -0,0 +1,127 @@
import { useNavigate } from '@tanstack/react-router'
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm'
import { PasoConfiguracionPanel } from '@/components/asignaturas/wizard/PasoConfiguracionPanel'
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
import { VistaSinPermisos } from '@/components/asignaturas/wizard/VistaSinPermisos'
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
import { WizardHeader } from '@/components/asignaturas/wizard/WizardHeader'
import { defineStepper } from '@/components/stepper'
import { Dialog, DialogContent } from '@/components/ui/dialog'
const Wizard = defineStepper(
{
id: 'metodo',
title: 'Método',
description: 'Manual, IA o Clonado',
},
{
id: 'basicos',
title: 'Datos básicos',
description: 'Nombre y estructura',
},
{
id: 'configuracion',
title: 'Configuración',
description: 'Detalles según modo',
},
{
id: 'resumen',
title: 'Resumen',
description: 'Confirmar creación',
},
)
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
const navigate = useNavigate()
const role = auth_get_current_user_role()
const {
wizard,
setWizard,
canContinueDesdeMetodo,
canContinueDesdeBasicos,
canContinueDesdeConfig,
simularGeneracionIA,
crearAsignatura,
} = useNuevaAsignaturaWizard(planId)
const handleClose = () => {
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
}
return (
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
<DialogContent
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
onInteractOutside={(e) => e.preventDefault()}
>
{role !== 'JEFE_CARRERA' ? (
<VistaSinPermisos onClose={handleClose} />
) : (
<Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id}
className="flex h-full flex-col"
>
{({ methods }) => (
<>
<WizardHeader
title="Nueva Asignatura"
Wizard={Wizard}
methods={{ ...methods, onClose: handleClose }}
/>
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
<div className="mx-auto max-w-3xl">
{Wizard.utils.getIndex(methods.current.id) === 0 && (
<Wizard.Stepper.Panel>
<PasoMetodoCardGroup
wizard={wizard}
onChange={setWizard}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 1 && (
<Wizard.Stepper.Panel>
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 2 && (
<Wizard.Stepper.Panel>
<PasoConfiguracionPanel
wizard={wizard}
onChange={setWizard}
onGenerarIA={simularGeneracionIA}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 3 && (
<Wizard.Stepper.Panel>
<PasoResumenCard wizard={wizard} />
</Wizard.Stepper.Panel>
)}
</div>
</div>
<WizardControls
Wizard={Wizard}
methods={methods}
wizard={wizard}
canContinueDesdeMetodo={canContinueDesdeMetodo}
canContinueDesdeBasicos={canContinueDesdeBasicos}
canContinueDesdeConfig={canContinueDesdeConfig}
onCreate={() => crearAsignatura(handleClose)}
/>
</>
)}
</Wizard.Stepper.Provider>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,90 @@
import { useState } from "react";
import type { AsignaturaPreview, NewSubjectWizardState } from "../types";
export function useNuevaAsignaturaWizard(planId: string) {
const [wizard, setWizard] = useState<NewSubjectWizardState>({
step: 1,
planId,
modoCreacion: null,
datosBasicos: {
nombre: "",
clave: "",
tipo: "OBLIGATORIA",
creditos: 0,
horasSemana: 0,
estructuraId: "",
},
clonInterno: {},
clonTradicional: {
archivoWordAsignaturaId: null,
archivosAdicionalesIds: [],
},
iaConfig: {
descripcionEnfoque: "",
notasAdicionales: "",
archivosExistentesIds: [],
},
resumen: {},
isLoading: false,
errorMessage: null,
});
const canContinueDesdeMetodo = wizard.modoCreacion === "MANUAL" ||
wizard.modoCreacion === "IA" ||
(wizard.modoCreacion === "CLONADO" && !!wizard.subModoClonado);
const canContinueDesdeBasicos = !!wizard.datosBasicos.nombre &&
wizard.datosBasicos.creditos > 0 &&
!!wizard.datosBasicos.estructuraId;
const canContinueDesdeConfig = (() => {
if (wizard.modoCreacion === "MANUAL") return true;
if (wizard.modoCreacion === "IA") {
return !!wizard.iaConfig?.descripcionEnfoque;
}
if (wizard.modoCreacion === "CLONADO") {
if (wizard.subModoClonado === "INTERNO") {
return !!wizard.clonInterno?.asignaturaOrigenId;
}
if (wizard.subModoClonado === "TRADICIONAL") {
return !!wizard.clonTradicional?.archivoWordAsignaturaId;
}
}
return false;
})();
const simularGeneracionIA = async () => {
setWizard((w) => ({ ...w, isLoading: true }));
await new Promise((r) => setTimeout(r, 1500));
setWizard((w) => ({
...w,
isLoading: false,
resumen: {
previewAsignatura: {
nombre: w.datosBasicos.nombre,
objetivo:
"Aplicar los fundamentos teóricos para la resolución de problemas...",
unidades: 5,
bibliografiaCount: 3,
} as AsignaturaPreview,
},
}));
};
const crearAsignatura = async (onCreated: () => void) => {
setWizard((w) => ({ ...w, isLoading: true }));
await new Promise((r) => setTimeout(r, 1000));
onCreated();
};
return {
wizard,
setWizard,
canContinueDesdeMetodo,
canContinueDesdeBasicos,
canContinueDesdeConfig,
simularGeneracionIA,
crearAsignatura,
};
}

View File

@@ -0,0 +1,45 @@
export type ModoCreacion = "MANUAL" | "IA" | "CLONADO";
export type SubModoClonado = "INTERNO" | "TRADICIONAL";
export type TipoAsignatura = "OBLIGATORIA" | "OPTATIVA" | "TRONCAL" | "OTRO";
export type AsignaturaPreview = {
nombre: string;
objetivo: string;
unidades: number;
bibliografiaCount: number;
};
export type NewSubjectWizardState = {
step: 1 | 2 | 3 | 4;
planId: string;
modoCreacion: ModoCreacion | null;
subModoClonado?: SubModoClonado;
datosBasicos: {
nombre: string;
clave?: string;
tipo: TipoAsignatura;
creditos: number;
horasSemana?: number;
estructuraId: string;
};
clonInterno?: {
facultadId?: string;
carreraId?: string;
planOrigenId?: string;
asignaturaOrigenId?: string | null;
};
clonTradicional?: {
archivoWordAsignaturaId: string | null;
archivosAdicionalesIds: Array<string>;
};
iaConfig?: {
descripcionEnfoque: string;
notasAdicionales: string;
archivosExistentesIds: Array<string>;
};
resumen: {
previewAsignatura?: AsignaturaPreview;
};
isLoading: boolean;
errorMessage: string | null;
};

View File

@@ -1,151 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react'
// --- DEFINICIÓN DE MENSAJES ---
const MENSAJES_CORTOS = [
// Hasta 5 sugerencias (6 mensajes)
'Analizando el plan de estudios...',
'Identificando áreas de oportunidad...',
'Consultando bases de datos académicas...',
'Redactando competencias específicas...',
'Calculando créditos y horas...',
'Afinando los últimos detalles...',
]
const MENSAJES_MEDIOS = [
// Hasta 10 sugerencias (10 mensajes)
'Conectando con el motor de IA...',
'Analizando estructura curricular...',
'Buscando asignaturas compatibles...',
'Verificando prerrequisitos...',
'Generando descripciones detalladas...',
'Balanceando cargas académicas...',
'Asignando horas independientes...',
'Validando coherencia temática...',
'Formateando resultados...',
'Finalizando generación...',
]
const MENSAJES_LARGOS = [
// Más de 10 sugerencias (14 mensajes)
'Iniciando procesamiento masivo...',
'Escaneando retícula completa...',
'Detectando líneas de investigación...',
'Generando primer bloque de asignaturas...',
'Evaluando pertinencia académica...',
'Optimizando créditos por ciclo...',
'Redactando objetivos de aprendizaje...',
'Generando segundo bloque...',
'Revisando duplicidad de contenidos...',
'Ajustando tiempos teóricos y prácticos...',
'Verificando normatividad...',
'Compilando sugerencias...',
'Aplicando formato final...',
'Casi listo, gracias por tu paciencia...',
]
interface AIProgressLoaderProps {
isLoading: boolean
cantidadDeSugerencias: number
}
export const AIProgressLoader: React.FC<AIProgressLoaderProps> = ({
isLoading,
cantidadDeSugerencias,
}) => {
const [progress, setProgress] = useState(0)
const [currentMessageIndex, setCurrentMessageIndex] = useState(0)
// 1. Seleccionar el grupo de mensajes según la cantidad
const messages = useMemo(() => {
if (cantidadDeSugerencias <= 5) return MENSAJES_CORTOS
if (cantidadDeSugerencias <= 10) return MENSAJES_MEDIOS
return MENSAJES_LARGOS
}, [cantidadDeSugerencias])
useEffect(() => {
if (!isLoading) {
setProgress(0)
setCurrentMessageIndex(0)
return
}
// --- CÁLCULO DEL TIEMPO TOTAL ---
// y = 4.07x + 10.93 (en segundos)
const estimatedSeconds = 4.07 * cantidadDeSugerencias + 10.93
const durationMs = estimatedSeconds * 1000
// Intervalo de actualización de la barra (cada 50ms para suavidad)
const updateInterval = 50
const totalSteps = durationMs / updateInterval
const incrementPerStep = 99 / totalSteps // Llegamos al 99% para esperar la respuesta real
// --- TIMER 1: BARRA DE PROGRESO ---
const progressTimer = setInterval(() => {
setProgress((prev) => {
const next = prev + incrementPerStep
return next >= 99 ? 99 : next // Topar en 99%
})
}, updateInterval)
// --- TIMER 2: MENSAJES (CADA 5 SEGUNDOS) ---
const messagesTimer = setInterval(() => {
setCurrentMessageIndex((prev) => {
// Si ya es el último mensaje, no avanzar más (no ciclar)
if (prev >= messages.length - 1) return prev
return prev + 1
})
}, 5000)
// Cleanup al desmontar o cuando isLoading cambie
return () => {
clearInterval(progressTimer)
clearInterval(messagesTimer)
}
}, [isLoading, cantidadDeSugerencias, messages])
if (!isLoading) return null
return (
<div className="animate-in fade-in zoom-in m-2 mx-auto w-full max-w-md duration-300">
{/* Contenedor de la barra */}
<div className="relative pt-1">
<div className="mb-2 flex items-center justify-between">
<div>
<span className="inline-block rounded-full bg-blue-200 px-2 py-1 text-xs font-semibold text-blue-600 uppercase">
Generando IA
</span>
</div>
<div className="text-right">
<span className="inline-block text-xs font-semibold text-blue-600">
{Math.floor(progress)}%
</span>
</div>
</div>
{/* Barra de fondo */}
<div className="mb-4 flex h-2 overflow-hidden rounded bg-blue-100 text-xs">
{/* Barra de progreso dinámica */}
<div
style={{ width: `${progress}%` }}
className="flex flex-col justify-center bg-blue-500 text-center whitespace-nowrap text-white shadow-none transition-all duration-75 ease-linear"
></div>
</div>
{/* Mensajes cambiantes */}
<div className="h-6 text-center">
{' '}
{/* Altura fija para evitar saltos */}
<p className="text-sm text-slate-500 italic transition-opacity duration-500">
{messages[currentMessageIndex]}
</p>
</div>
{/* Nota de tiempo estimado (Opcional, transparencia operacional) */}
<p className="mt-2 text-center text-[10px] text-slate-400">
Tiempo estimado: ~{Math.ceil(4.07 * cantidadDeSugerencias + 10.93)}{' '}
segs
</p>
</div>
</div>
)
}

View File

@@ -1,165 +0,0 @@
import { useNavigate } from '@tanstack/react-router'
import * as Icons from 'lucide-react'
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
import { defineStepper } from '@/components/stepper'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { WizardLayout } from '@/components/wizard/WizardLayout'
import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader'
const auth_get_current_user_role = (): string => 'JEFE_CARRERA'
const Wizard = defineStepper(
{
id: 'metodo',
title: 'Método',
description: 'Manual, IA o Clonado',
},
{
id: 'basicos',
title: 'Datos básicos',
description: 'Nombre y estructura',
},
{
id: 'detalles',
title: 'Detalles',
description: 'Detalles según modo',
},
{
id: 'resumen',
title: 'Resumen',
description: 'Confirmar creación',
},
)
export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
const navigate = useNavigate()
const role = auth_get_current_user_role()
const {
wizard,
setWizard,
canContinueDesdeMetodo,
canContinueDesdeBasicos,
canContinueDesdeDetalles,
} = useNuevaAsignaturaWizard(planId)
const titleOverrides =
wizard.tipoOrigen === 'IA_MULTIPLE'
? {
basicos: 'Sugerencias',
detalles: 'Estructura',
}
: undefined
const handleClose = () => {
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
}
if (role !== 'JEFE_CARRERA') {
return (
<WizardLayout title="Nueva Asignatura" onClose={handleClose}>
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive flex items-center gap-2">
<Icons.ShieldAlert className="h-5 w-5" />
Sin permisos
</CardTitle>
<CardDescription>
Solo el Jefe de Carrera puede crear asignaturas.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-end">
<Button variant="secondary" onClick={handleClose}>
Volver
</Button>
</CardContent>
</Card>
</WizardLayout>
)
}
return (
<Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id}
className="flex h-full flex-col"
>
{({ methods }) => {
const idx = Wizard.utils.getIndex(methods.current.id)
return (
<WizardLayout
title="Nueva Asignatura"
onClose={handleClose}
headerSlot={
<WizardResponsiveHeader
wizard={Wizard}
methods={methods}
titleOverrides={titleOverrides}
/>
}
footerSlot={
<Wizard.Stepper.Controls>
<WizardControls
errorMessage={wizard.errorMessage}
onPrev={() => methods.prev()}
onNext={() => methods.next()}
disablePrev={idx === 0 || wizard.isLoading}
disableNext={
wizard.isLoading ||
(idx === 0 && !canContinueDesdeMetodo) ||
(idx === 1 && !canContinueDesdeBasicos) ||
(idx === 2 && !canContinueDesdeDetalles)
}
disableCreate={wizard.isLoading}
isLastStep={idx >= Wizard.steps.length - 1}
wizard={wizard}
setWizard={setWizard}
/>
</Wizard.Stepper.Controls>
}
>
<div className="mx-auto max-w-3xl">
{idx === 0 && (
<Wizard.Stepper.Panel>
<PasoMetodoCardGroup wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel>
)}
{idx === 1 && (
<Wizard.Stepper.Panel>
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel>
)}
{idx === 2 && (
<Wizard.Stepper.Panel>
<PasoDetallesPanel wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel>
)}
{idx === 3 && (
<Wizard.Stepper.Panel>
<PasoResumenCard wizard={wizard} />
</Wizard.Stepper.Panel>
)}
</div>
</WizardLayout>
)
}}
</Wizard.Stepper.Provider>
)
}

View File

@@ -1,83 +0,0 @@
import { useState } from 'react'
import type { NewSubjectWizardState } from '../types'
export function useNuevaAsignaturaWizard(planId: string) {
const [wizard, setWizard] = useState<NewSubjectWizardState>({
step: 1,
plan_estudio_id: planId,
estructuraId: null,
tipoOrigen: null,
datosBasicos: {
nombre: '',
codigo: '',
tipo: null,
creditos: null,
horasAcademicas: null,
horasIndependientes: null,
estructuraId: '',
},
sugerencias: [],
clonInterno: {},
clonTradicional: {
archivoWordAsignaturaId: null,
archivosAdicionalesIds: [],
},
iaConfig: {
descripcionEnfoqueAcademico: '',
instruccionesAdicionalesIA: '',
archivosReferencia: [],
repositoriosReferencia: [],
archivosAdjuntos: [],
},
iaMultiple: {
enfoque: '',
cantidadDeSugerencias: 10,
isLoading: false,
},
resumen: {},
isLoading: false,
errorMessage: null,
})
const canContinueDesdeMetodo =
wizard.tipoOrigen === 'MANUAL' ||
wizard.tipoOrigen === 'IA_SIMPLE' ||
wizard.tipoOrigen === 'IA_MULTIPLE' ||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
const canContinueDesdeBasicos =
(!!wizard.datosBasicos.nombre &&
wizard.datosBasicos.tipo !== null &&
wizard.datosBasicos.creditos !== null &&
wizard.datosBasicos.creditos > 0 &&
!!wizard.datosBasicos.estructuraId) ||
(wizard.tipoOrigen === 'IA_MULTIPLE' &&
wizard.sugerencias.filter((s) => s.selected).length > 0)
const canContinueDesdeDetalles = (() => {
if (wizard.tipoOrigen === 'MANUAL') return true
if (wizard.tipoOrigen === 'IA_SIMPLE') {
return !!wizard.iaConfig?.descripcionEnfoqueAcademico
}
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
return !!wizard.clonInterno?.asignaturaOrigenId
}
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
return !!wizard.clonTradicional?.archivoWordAsignaturaId
}
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
return wizard.estructuraId !== null
}
return false
})()
return {
wizard,
setWizard,
canContinueDesdeMetodo,
canContinueDesdeBasicos,
canContinueDesdeDetalles,
}
}

View File

@@ -1,79 +0,0 @@
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import type { Asignatura } from '@/data'
export type ModoCreacion = 'MANUAL' | 'IA' | 'CLONADO'
export type TipoAsignatura = 'OBLIGATORIA' | 'OPTATIVA' | 'TRONCAL' | 'OTRO'
export type AsignaturaPreview = {
nombre: string
objetivo: string
unidades: number
bibliografiaCount: number
}
export type DataAsignaturaSugerida = {
nombre: Asignatura['nombre']
codigo?: Asignatura['codigo']
tipo: Asignatura['tipo'] | null
creditos: Asignatura['creditos'] | null
horasAcademicas?: number | null
horasIndependientes?: number | null
descripcion: string
}
export type AsignaturaSugerida = {
id: string
selected: boolean
source: 'IA' | 'MANUAL' | 'CLON'
linea_plan_id: string | null
numero_ciclo: number | null
} & DataAsignaturaSugerida
export type NewSubjectWizardState = {
step: 1 | 2 | 3 | 4
plan_estudio_id: Asignatura['plan_estudio_id']
estructuraId: Asignatura['estructura_id'] | null
tipoOrigen:
| Asignatura['tipo_origen']
| 'CLONADO'
| 'IA_SIMPLE'
| 'IA_MULTIPLE'
| null
datosBasicos: {
nombre: Asignatura['nombre']
codigo?: Asignatura['codigo']
tipo: Asignatura['tipo'] | null
creditos: Asignatura['creditos'] | null
horasAcademicas?: Asignatura['horas_academicas'] | null
horasIndependientes?: Asignatura['horas_independientes'] | null
estructuraId: Asignatura['estructura_id'] | null
}
sugerencias: Array<AsignaturaSugerida>
clonInterno?: {
facultadId?: string
carreraId?: string
planOrigenId?: string
asignaturaOrigenId?: string | null
}
clonTradicional?: {
archivoWordAsignaturaId: string | null
archivosAdicionalesIds: Array<string>
}
iaConfig?: {
descripcionEnfoqueAcademico: string
instruccionesAdicionalesIA: string
archivosReferencia: Array<string>
repositoriosReferencia?: Array<string>
archivosAdjuntos?: Array<UploadedFile>
}
iaMultiple?: {
enfoque: string
cantidadDeSugerencias: number
isLoading: boolean
}
resumen: {
previewAsignatura?: AsignaturaPreview
}
isLoading: boolean
errorMessage: string | null
}

View File

@@ -0,0 +1,205 @@
import { useNavigate } from '@tanstack/react-router'
import * as Icons from 'lucide-react'
import { useNuevoPlanWizard } from './hooks/useNuevoPlanWizard'
import { PasoBasicosForm } from '@/components/planes/wizard/PasoBasicosForm'
import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel'
import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup'
import { PasoResumenCard } from '@/components/planes/wizard/PasoResumenCard'
import { WizardControls } from '@/components/planes/wizard/WizardControls'
import { WizardHeader } from '@/components/planes/wizard/WizardHeader'
import { defineStepper } from '@/components/stepper'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
// Mock de permisos/rol
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
const Wizard = defineStepper(
{
id: 'modo',
title: 'Método',
description: 'Selecciona cómo crearás el plan',
},
{
id: 'basicos',
title: 'Datos básicos',
description: 'Nombre, carrera, nivel y ciclos',
},
{ id: 'detalles', title: 'Detalles', description: 'IA, clonado o archivos' },
{ id: 'resumen', title: 'Resumen', description: 'Confirma y crea el plan' },
)
export default function NuevoPlanModalContainer() {
const navigate = useNavigate()
const role = auth_get_current_user_role()
const {
wizard,
setWizard,
carrerasFiltradas,
canContinueDesdeModo,
canContinueDesdeBasicos,
canContinueDesdeDetalles,
generarPreviewIA,
} = useNuevoPlanWizard()
const handleClose = () => {
navigate({ to: '/planes', resetScroll: false })
}
const crearPlan = async () => {
setWizard((w) => ({ ...w, isLoading: true, errorMessage: null }))
await new Promise((r) => setTimeout(r, 900))
const nuevoId = (() => {
if (wizard.modoCreacion === 'MANUAL') return 'plan_new_manual_001'
if (wizard.modoCreacion === 'IA') return 'plan_new_ai_001'
if (wizard.subModoClonado === 'INTERNO') return 'plan_new_clone_001'
return 'plan_new_import_001'
})()
navigate({ to: `/planes/${nuevoId}` })
}
return (
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
<DialogContent
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
onInteractOutside={(e) => {
e.preventDefault()
}}
>
{role !== 'JEFE_CARRERA' ? (
<>
<DialogHeader className="flex-none border-b p-6">
<DialogTitle>Nuevo plan de estudios</DialogTitle>
</DialogHeader>
<div className="flex-1 p-6">
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icons.ShieldAlert className="text-destructive h-5 w-5" />
Sin permisos
</CardTitle>
<CardDescription>
No tienes permisos para crear planes de estudio.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-end">
<button
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-md border px-3 py-2 text-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none"
onClick={handleClose}
>
Volver
</button>
</CardContent>
</Card>
</div>
</>
) : (
<Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id}
className="flex h-full flex-col"
>
{({ methods }) => {
const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1
const totalSteps = Wizard.steps.length
const nextStep = Wizard.steps[currentIndex]
return (
<>
<WizardHeader
currentIndex={currentIndex}
totalSteps={totalSteps}
currentTitle={methods.current.title}
currentDescription={methods.current.description}
nextTitle={nextStep?.title}
onClose={handleClose}
Wizard={Wizard}
/>
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
<div className="mx-auto max-w-3xl">
{Wizard.utils.getIndex(methods.current.id) === 0 && (
<Wizard.Stepper.Panel>
<PasoModoCardGroup
wizard={wizard}
onChange={setWizard}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 1 && (
<Wizard.Stepper.Panel>
<PasoBasicosForm
wizard={wizard}
onChange={setWizard}
carrerasFiltradas={carrerasFiltradas}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 2 && (
<Wizard.Stepper.Panel>
<PasoDetallesPanel
wizard={wizard}
onChange={setWizard}
onGenerarIA={generarPreviewIA}
isLoading={wizard.isLoading}
/>
</Wizard.Stepper.Panel>
)}
{Wizard.utils.getIndex(methods.current.id) === 3 && (
<Wizard.Stepper.Panel>
<PasoResumenCard wizard={wizard} />
</Wizard.Stepper.Panel>
)}
</div>
</div>
<div className="flex-none border-t bg-white p-6">
<Wizard.Stepper.Controls>
<WizardControls
errorMessage={wizard.errorMessage}
onPrev={() => methods.prev()}
onNext={() => methods.next()}
onCreate={crearPlan}
disablePrev={
Wizard.utils.getIndex(methods.current.id) === 0 ||
wizard.isLoading
}
disableNext={
wizard.isLoading ||
(Wizard.utils.getIndex(methods.current.id) === 0 &&
!canContinueDesdeModo) ||
(Wizard.utils.getIndex(methods.current.id) === 1 &&
!canContinueDesdeBasicos) ||
(Wizard.utils.getIndex(methods.current.id) === 2 &&
!canContinueDesdeDetalles)
}
disableCreate={wizard.isLoading}
isLastStep={
Wizard.utils.getIndex(methods.current.id) >=
Wizard.steps.length - 1
}
/>
</Wizard.Stepper.Controls>
</div>
</>
)
}}
</Wizard.Stepper.Provider>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,56 @@
import type { TipoCiclo } from "./types";
export const FACULTADES = [
{ id: "ing", nombre: "Facultad de Ingeniería" },
{
id: "med",
nombre: "Facultad de Medicina en medicina en medicina en medicina",
},
{ id: "neg", nombre: "Facultad de Negocios" },
];
export const CARRERAS = [
{ id: "sis", nombre: "Ing. en Sistemas", facultadId: "ing" },
{ id: "ind", nombre: "Ing. Industrial", facultadId: "ing" },
{ id: "medico", nombre: "Médico Cirujano", facultadId: "med" },
{ id: "act", nombre: "Actuaría", facultadId: "neg" },
];
export const NIVELES = [
"Licenciatura",
"Especialidad",
"Maestría",
"Doctorado",
];
export const TIPOS_CICLO: Array<{ value: TipoCiclo; label: string }> = [
{ value: "SEMESTRE", label: "Semestre" },
{ value: "CUATRIMESTRE", label: "Cuatrimestre" },
{ value: "TRIMESTRE", label: "Trimestre" },
];
export const PLANES_EXISTENTES = [
{
id: "plan-2021-sis",
nombre: "ISC 2021",
estado: "Aprobado",
anio: 2021,
facultadId: "ing",
carreraId: "sis",
},
{
id: "plan-2020-ind",
nombre: "I. Industrial 2020",
estado: "Aprobado",
anio: 2020,
facultadId: "ing",
carreraId: "ind",
},
{
id: "plan-2019-med",
nombre: "Medicina 2019",
estado: "Vigente",
anio: 2019,
facultadId: "med",
carreraId: "medico",
},
];

View File

@@ -0,0 +1,102 @@
import { useMemo, useState } from "react";
import { CARRERAS } from "../catalogs";
import type { NewPlanWizardState, PlanPreview } from "../types";
export function useNuevoPlanWizard() {
const [wizard, setWizard] = useState<NewPlanWizardState>({
step: 1,
modoCreacion: null,
datosBasicos: {
nombrePlan: "",
carreraId: "",
facultadId: "",
nivel: "",
tipoCiclo: "SEMESTRE",
numCiclos: 8,
},
clonInterno: { planOrigenId: null },
clonTradicional: {
archivoWordPlanId: null,
archivoMapaExcelId: null,
archivoAsignaturasExcelId: null,
},
iaConfig: {
descripcionEnfoque: "",
poblacionObjetivo: "",
notasAdicionales: "",
archivosReferencia: [],
},
resumen: {},
isLoading: false,
errorMessage: null,
});
const carrerasFiltradas = useMemo(() => {
const fac = wizard.datosBasicos.facultadId;
return fac ? CARRERAS.filter((c) => c.facultadId === fac) : CARRERAS;
}, [wizard.datosBasicos.facultadId]);
const canContinueDesdeModo = wizard.modoCreacion === "MANUAL" ||
wizard.modoCreacion === "IA" ||
(wizard.modoCreacion === "CLONADO" && !!wizard.subModoClonado);
const canContinueDesdeBasicos = !!wizard.datosBasicos.nombrePlan &&
!!wizard.datosBasicos.carreraId &&
!!wizard.datosBasicos.facultadId &&
!!wizard.datosBasicos.nivel &&
wizard.datosBasicos.numCiclos > 0;
const canContinueDesdeDetalles = (() => {
if (wizard.modoCreacion === "MANUAL") return true;
if (wizard.modoCreacion === "IA") {
return !!wizard.iaConfig?.descripcionEnfoque;
}
if (wizard.modoCreacion === "CLONADO") {
if (wizard.subModoClonado === "INTERNO") {
return !!wizard.clonInterno?.planOrigenId;
}
if (wizard.subModoClonado === "TRADICIONAL") {
const t = wizard.clonTradicional;
if (!t) return false;
const tieneWord = !!t.archivoWordPlanId;
const tieneAlMenosUnExcel = !!t.archivoMapaExcelId ||
!!t.archivoAsignaturasExcelId;
return tieneWord && tieneAlMenosUnExcel;
}
}
return false;
})();
const generarPreviewIA = async () => {
setWizard((w) => ({ ...w, isLoading: true, errorMessage: null }));
await new Promise((r) => setTimeout(r, 800));
const preview: PlanPreview = {
nombrePlan: wizard.datosBasicos.nombrePlan || "Plan sin nombre",
nivel: wizard.datosBasicos.nivel || "Licenciatura",
tipoCiclo: wizard.datosBasicos.tipoCiclo,
numCiclos: wizard.datosBasicos.numCiclos,
numAsignaturasAprox: wizard.datosBasicos.numCiclos * 6,
secciones: [
{ id: "obj", titulo: "Objetivos", resumen: "Borrador de objetivos…" },
{ id: "perfil", titulo: "Perfil de egreso", resumen: "Borrador…" },
],
};
setWizard((w) => ({
...w,
isLoading: false,
resumen: { previewPlan: preview },
}));
};
return {
wizard,
setWizard,
carrerasFiltradas,
canContinueDesdeModo,
canContinueDesdeBasicos,
canContinueDesdeDetalles,
generarPreviewIA,
};
}

View File

@@ -0,0 +1,41 @@
export type TipoCiclo = "SEMESTRE" | "CUATRIMESTRE" | "TRIMESTRE";
export type ModoCreacion = "MANUAL" | "IA" | "CLONADO";
export type SubModoClonado = "INTERNO" | "TRADICIONAL";
export type PlanPreview = {
nombrePlan: string;
nivel: string;
tipoCiclo: TipoCiclo;
numCiclos: number;
numAsignaturasAprox?: number;
secciones?: Array<{ id: string; titulo: string; resumen: string }>;
};
export type NewPlanWizardState = {
step: 1 | 2 | 3 | 4;
modoCreacion: ModoCreacion | null;
subModoClonado?: SubModoClonado;
datosBasicos: {
nombrePlan: string;
carreraId: string;
facultadId: string;
nivel: string;
tipoCiclo: TipoCiclo;
numCiclos: number;
};
clonInterno?: { planOrigenId: string | null };
clonTradicional?: {
archivoWordPlanId: string | null;
archivoMapaExcelId: string | null;
archivoAsignaturasExcelId: string | null;
};
iaConfig?: {
descripcionEnfoque: string;
poblacionObjetivo: string;
notasAdicionales: string;
archivosReferencia: Array<string>;
};
resumen: { previewPlan?: PlanPreview };
isLoading: boolean;
errorMessage: string | null;
};

View File

@@ -1,155 +0,0 @@
import { useNavigate } from '@tanstack/react-router'
import * as Icons from 'lucide-react'
import { useNuevoPlanWizard } from './hooks/useNuevoPlanWizard'
// import type { NewPlanWizardState } from './types'
import { PasoBasicosForm } from '@/components/planes/wizard/PasoBasicosForm/PasoBasicosForm'
import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel/PasoDetallesPanel'
import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup'
import { PasoResumenCard } from '@/components/planes/wizard/PasoResumenCard'
import { WizardControls } from '@/components/planes/wizard/WizardControls'
import { defineStepper } from '@/components/stepper'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { WizardLayout } from '@/components/wizard/WizardLayout'
import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader'
// import { useGeneratePlanAI } from '@/data/hooks/usePlans'
// Mock de permisos/rol
const auth_get_current_user_role = (): string => 'JEFE_CARRERA'
const Wizard = defineStepper(
{
id: 'modo',
title: 'Método',
description: 'Selecciona cómo crearás el plan',
},
{
id: 'basicos',
title: 'Datos básicos',
description: 'Nombre, carrera, nivel y ciclos',
},
{ id: 'detalles', title: 'Detalles', description: 'IA, clonado o archivos' },
{ id: 'resumen', title: 'Resumen', description: 'Confirma y crea el plan' },
)
export default function NuevoPlanModalContainer() {
const navigate = useNavigate()
const role = auth_get_current_user_role()
// const generatePlanAI = useGeneratePlanAI()
const {
wizard,
setWizard,
canContinueDesdeModo,
canContinueDesdeBasicos,
canContinueDesdeDetalles,
} = useNuevoPlanWizard()
const handleClose = () => {
navigate({ to: '/planes', resetScroll: false })
}
// Crear plan: ahora la lógica vive en WizardControls
if (role !== 'JEFE_CARRERA') {
return (
<WizardLayout title="Nuevo plan de estudios" onClose={handleClose}>
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icons.ShieldAlert className="text-destructive h-5 w-5" />
Sin permisos
</CardTitle>
<CardDescription>
No tienes permisos para crear planes de estudio.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-end">
<button
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-md border px-3 py-2 text-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none"
onClick={handleClose}
>
Volver
</button>
</CardContent>
</Card>
</WizardLayout>
)
}
return (
<Wizard.Stepper.Provider
initialStep={Wizard.utils.getFirst().id}
className="flex h-full flex-col"
>
{({ methods }) => {
const idx = Wizard.utils.getIndex(methods.current.id)
return (
<WizardLayout
title="Nuevo plan de estudios"
onClose={handleClose}
headerSlot={
<WizardResponsiveHeader wizard={Wizard} methods={methods} />
}
footerSlot={
<Wizard.Stepper.Controls>
<WizardControls
errorMessage={wizard.errorMessage}
onPrev={() => methods.prev()}
onNext={() => methods.next()}
disablePrev={idx === 0 || wizard.isLoading}
disableNext={
wizard.isLoading ||
(idx === 0 && !canContinueDesdeModo) ||
(idx === 1 && !canContinueDesdeBasicos) ||
(idx === 2 && !canContinueDesdeDetalles)
}
disableCreate={wizard.isLoading}
isLastStep={idx >= Wizard.steps.length - 1}
wizard={wizard}
setWizard={setWizard}
/>
</Wizard.Stepper.Controls>
}
>
<div className="mx-auto max-w-3xl">
{idx === 0 && (
<Wizard.Stepper.Panel>
<PasoModoCardGroup wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel>
)}
{idx === 1 && (
<Wizard.Stepper.Panel>
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
</Wizard.Stepper.Panel>
)}
{idx === 2 && (
<Wizard.Stepper.Panel>
<PasoDetallesPanel
wizard={wizard}
onChange={setWizard}
isLoading={wizard.isLoading}
/>
</Wizard.Stepper.Panel>
)}
{idx === 3 && (
<Wizard.Stepper.Panel>
<PasoResumenCard wizard={wizard} />
</Wizard.Stepper.Panel>
)}
</div>
</WizardLayout>
)
}}
</Wizard.Stepper.Provider>
)
}

View File

@@ -1,156 +0,0 @@
import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain'
export const FACULTADES = [
{ id: 'ing', nombre: 'Facultad de Ingeniería' },
{
id: 'med',
nombre: 'Facultad de Medicina en medicina en medicina en medicina',
},
{ id: 'neg', nombre: 'Facultad de Negocios' },
]
export const CARRERAS = [
{ id: 'sis', nombre: 'Ing. en Sistemas', facultadId: 'ing' },
{ id: 'ind', nombre: 'Ing. Industrial', facultadId: 'ing' },
{ id: 'medico', nombre: 'Médico Cirujano', facultadId: 'med' },
{ id: 'act', nombre: 'Actuaría', facultadId: 'neg' },
]
export const NIVELES: Array<NivelPlanEstudio> = [
'Licenciatura',
'Maestría',
'Doctorado',
'Especialidad',
'Diplomado',
'Otro',
]
export const TIPOS_CICLO: Array<TipoCiclo> = [
'Semestre',
'Cuatrimestre',
'Trimestre',
'Otro',
]
export const PLANES_EXISTENTES = [
{
id: 'plan-2021-sis',
nombre: 'ISC 2021',
estado: 'Aprobado',
anio: 2021,
facultadId: 'ing',
carreraId: 'sis',
},
{
id: 'plan-2020-ind',
nombre: 'I. Industrial 2020',
estado: 'Aprobado',
anio: 2020,
facultadId: 'ing',
carreraId: 'ind',
},
{
id: 'plan-2019-med',
nombre: 'Medicina 2019',
estado: 'Vigente',
anio: 2019,
facultadId: 'med',
carreraId: 'medico',
},
]
export const ARCHIVOS = [
{
id: 'file-1',
nombre: 'Sílabo POO 2023.docx',
tipo: 'docx',
tamaño: '245 KB',
},
{
id: 'file-2',
nombre: 'Guía de prácticas BD.pdf',
tipo: 'pdf',
tamaño: '1.2 MB',
},
{
id: 'file-3',
nombre: 'Rúbrica evaluación proyectos.xlsx',
tipo: 'xlsx',
tamaño: '89 KB',
},
{
id: 'file-4',
nombre: 'Banco de reactivos IA.docx',
tipo: 'docx',
tamaño: '567 KB',
},
{
id: 'file-5',
nombre: 'Asignatural didáctico Web.pdf',
tipo: 'pdf',
tamaño: '3.4 MB',
},
]
export const REPOSITORIOS = [
{
id: 'repo-1',
nombre: 'Asignaturales ISC 2024',
descripcion: 'Documentos de referencia para Ingeniería en Sistemas',
cantidadArchivos: 45,
},
{
id: 'repo-2',
nombre: 'Lineamientos SEP',
descripcion: 'Documentos oficiales y normativas SEP actualizadas',
cantidadArchivos: 12,
},
{
id: 'repo-3',
nombre: 'Bibliografía Digital',
descripcion: 'Recursos bibliográficos digitalizados',
cantidadArchivos: 128,
},
{
id: 'repo-4',
nombre: 'Plantillas Institucionales',
descripcion: 'Formatos y plantillas oficiales ULSA',
cantidadArchivos: 23,
},
]
export const PLANTILLAS_ANEXO_1 = [
{
id: 'sep-2025',
name: 'Licenciatura RVOE SEP.docx',
versions: ['v2025.2 (Vigente)', 'v2025.1', 'v2024.Final'],
},
{
id: 'interno-mix',
name: 'Estándar Institucional Mixto.docx',
versions: ['v2.0', 'v1.5', 'v1.0-beta'],
},
{
id: 'conacyt',
name: 'Formato Posgrado CONAHCYT.docx',
versions: ['v3.0 (2025)', 'v2.8'],
},
]
export const PLANTILLAS_ANEXO_2 = [
{
id: 'sep-2017-xlsx',
name: 'Licenciatura RVOE 2017.xlsx',
versions: ['v2017.0', 'v2018.1', 'v2019.2', 'v2020.Final'],
},
{
id: 'interno-mix-xlsx',
name: 'Estándar Institucional Mixto.xlsx',
versions: ['v1.0', 'v1.5'],
},
{
id: 'conacyt-xlsx',
name: 'Formato Posgrado CONAHCYT.xlsx',
versions: ['v1.0', 'v2.0'],
},
]

View File

@@ -1,91 +0,0 @@
import { useState } from 'react'
import type { NewPlanWizardState } from '../types'
export function useNuevoPlanWizard() {
const [wizard, setWizard] = useState<NewPlanWizardState>({
step: 1,
tipoOrigen: null,
datosBasicos: {
nombrePlan: '',
facultad: { id: '', nombre: '' },
carrera: { id: '', nombre: '' },
nivel: '',
tipoCiclo: '',
numCiclos: null,
estructuraPlanId: null,
},
// datosBasicos: {
// nombrePlan: "Medicina",
// carreraId: "medico",
// facultadId: "med",
// nivel: "Licenciatura",
// tipoCiclo: "SEMESTRE",
// numCiclos: 8,
// plantillaPlanId: "sep-2025",
// plantillaPlanVersion: "v2025.2 (Vigente)",
// plantillaMapaId: "sep-2017-xlsx",
// plantillaMapaVersion: "v2017.0",
// },
clonInterno: { planOrigenId: null },
clonTradicional: {
archivoWordPlanId: null,
archivoMapaExcelId: null,
archivoAsignaturasExcelId: null,
},
iaConfig: {
descripcionEnfoqueAcademico: '',
instruccionesAdicionalesIA: '',
archivosReferencia: [],
repositoriosReferencia: [],
archivosAdjuntos: [],
},
resumen: {},
isLoading: false,
errorMessage: null,
})
const canContinueDesdeModo =
wizard.tipoOrigen === 'MANUAL' ||
wizard.tipoOrigen === 'IA' ||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
const canContinueDesdeBasicos =
!!wizard.datosBasicos.nombrePlan &&
!!wizard.datosBasicos.carrera.id &&
!!wizard.datosBasicos.facultad.id &&
!!wizard.datosBasicos.nivel &&
wizard.datosBasicos.numCiclos !== null &&
wizard.datosBasicos.numCiclos > 0 &&
// Requerir ambas plantillas (plan y mapa) con versión
!!wizard.datosBasicos.estructuraPlanId
const canContinueDesdeDetalles = (() => {
if (wizard.tipoOrigen === 'MANUAL') return true
if (wizard.tipoOrigen === 'IA') {
// Requerimos descripción del enfoque y notas adicionales
return !!wizard.iaConfig?.descripcionEnfoqueAcademico
}
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
return !!wizard.clonInterno?.planOrigenId
}
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
const t = wizard.clonTradicional
if (!t) return false
const tieneWord = !!t.archivoWordPlanId
const tieneAlMenosUnExcel =
!!t.archivoMapaExcelId || !!t.archivoAsignaturasExcelId
return tieneWord && tieneAlMenosUnExcel
}
return false
})()
return {
wizard,
setWizard,
canContinueDesdeModo,
canContinueDesdeBasicos,
canContinueDesdeDetalles,
}
}

View File

@@ -1,67 +0,0 @@
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import type {
NivelPlanEstudio,
TipoCiclo,
TipoOrigen,
} from '@/data/types/domain'
export type PlanPreview = {
nombrePlan: string
nivel: NivelPlanEstudio
tipoCiclo: TipoCiclo
numCiclos: number
numAsignaturasAprox?: number
secciones?: Array<{ id: string; titulo: string; resumen: string }>
}
export type NewPlanWizardState = {
step: 1 | 2 | 3 | 4
tipoOrigen: TipoOrigen | null
datosBasicos: {
nombrePlan: string
facultad: {
id: string
nombre: string
}
carrera: {
id: string
nombre: string
}
nivel: NivelPlanEstudio | ''
tipoCiclo: TipoCiclo | ''
numCiclos: number | null
// Selección de plantillas (obligatorias)
estructuraPlanId: string | null
}
clonInterno?: { planOrigenId: string | null }
clonTradicional?: {
archivoWordPlanId: {
id: string
name: string
size: string
type: string
} | null
archivoMapaExcelId: {
id: string
name: string
size: string
type: string
} | null
archivoAsignaturasExcelId: {
id: string
name: string
size: string
type: string
} | null
}
iaConfig?: {
descripcionEnfoqueAcademico: string
instruccionesAdicionalesIA?: string
archivosReferencia: Array<string>
repositoriosReferencia?: Array<string>
archivosAdjuntos?: Array<UploadedFile>
}
resumen: { previewPlan?: PlanPreview }
isLoading: boolean
errorMessage: string | null
}

View File

@@ -1,5 +0,0 @@
export const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
};

View File

@@ -1,10 +0,0 @@
// src/features/planes/utils/icon-utils.ts
import * as Icons from "lucide-react";
import { BookOpen } from "lucide-react";
export const getIconByName = (iconName: string | null) => {
if (!iconName) return BookOpen;
// "as any" es necesario aquí porque el string es dinámico
const Icon = (Icons as any)[iconName];
return Icon || BookOpen;
};

View File

@@ -6,7 +6,6 @@ import reportWebVitals from './reportWebVitals.ts'
import { routeTree } from './routeTree.gen'
import * as TanStackQueryProvider from '@/data/query/queryClient.tsx'
import { supabaseBrowser } from '@/data/supabase/client'
import './styles.css'
@@ -17,7 +16,6 @@ const router = createRouter({
routeTree,
context: {
...TanStackQueryProviderContext,
supabase: supabaseBrowser(),
},
defaultPreload: 'intent',
scrollRestoration: true,
@@ -30,9 +28,6 @@ declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
interface HistoryState {
showConfetti?: boolean
}
}
// Render the app

View File

@@ -12,25 +12,23 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as LoginRouteImport } from './routes/login'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as IndexRouteImport } from './routes/index'
import { Route as PlanesListaRouteImport } from './routes/planes/_lista'
import { Route as PlanesPlanesListRouteRouteImport } from './routes/planes/PlanesListRoute'
import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query'
import { Route as PlanesListaRouteRouteImport } from './routes/planes/_lista/route'
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
import { Route as PlanesPlanIdDetalleRouteImport } from './routes/planes/$planId/_detalle'
import { Route as PlanesPlanIdDetalleIndexRouteImport } from './routes/planes/$planId/_detalle/index'
import { Route as PlanesPlanIdAsignaturasRouteRouteImport } from './routes/planes/$planId/asignaturas/route'
import { Route as PlanesPlanIdDetalleRouteRouteImport } from './routes/planes/$planId/_detalle/route'
import { Route as PlanesPlanIdAsignaturasIndexRouteImport } from './routes/planes/$planId/asignaturas/index'
import { Route as PlanesPlanIdDetalleMateriasRouteImport } from './routes/planes/$planId/_detalle/materias'
import { Route as PlanesPlanIdDetalleMapaRouteImport } from './routes/planes/$planId/_detalle/mapa'
import { Route as PlanesPlanIdDetalleIaplanRouteImport } from './routes/planes/$planId/_detalle/iaplan'
import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial'
import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo'
import { Route as PlanesPlanIdDetalleDocumentoRouteImport } from './routes/planes/$planId/_detalle/documento'
import { Route as PlanesPlanIdDetalleAsignaturasRouteImport } from './routes/planes/$planId/_detalle/asignaturas'
import { Route as PlanesPlanIdDetalleDatosRouteImport } from './routes/planes/$planId/_detalle/datos'
import { Route as PlanesPlanIdAsignaturasListaRouteRouteImport } from './routes/planes/$planId/asignaturas/_lista/route'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/route'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/index'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/historial'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/documento'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/contenido'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/bibliografia'
import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva'
import { Route as PlanesPlanIdAsignaturasListaNuevaRouteImport } from './routes/planes/$planId/asignaturas/_lista/nueva'
const LoginRoute = LoginRouteImport.update({
id: '/login',
@@ -47,9 +45,9 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesListaRoute = PlanesListaRouteImport.update({
id: '/planes/_lista',
path: '/planes',
const PlanesPlanesListRouteRoute = PlanesPlanesListRouteRouteImport.update({
id: '/planes/PlanesListRoute',
path: '/planes/PlanesListRoute',
getParentRoute: () => rootRouteImport,
} as any)
const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
@@ -57,176 +55,156 @@ const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
path: '/demo/tanstack-query',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesListaRouteRoute = PlanesListaRouteRouteImport.update({
id: '/planes/_lista',
path: '/planes',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesListaNuevoRoute = PlanesListaNuevoRouteImport.update({
id: '/nuevo',
path: '/nuevo',
getParentRoute: () => PlanesListaRoute,
getParentRoute: () => PlanesListaRouteRoute,
} as any)
const PlanesPlanIdDetalleRoute = PlanesPlanIdDetalleRouteImport.update({
const PlanesPlanIdAsignaturasRouteRoute =
PlanesPlanIdAsignaturasRouteRouteImport.update({
id: '/planes/$planId/asignaturas',
path: '/planes/$planId/asignaturas',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdDetalleRouteRoute =
PlanesPlanIdDetalleRouteRouteImport.update({
id: '/planes/$planId/_detalle',
path: '/planes/$planId',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdDetalleIndexRoute =
PlanesPlanIdDetalleIndexRouteImport.update({
const PlanesPlanIdAsignaturasIndexRoute =
PlanesPlanIdAsignaturasIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => PlanesPlanIdDetalleRoute,
getParentRoute: () => PlanesPlanIdAsignaturasRouteRoute,
} as any)
const PlanesPlanIdDetalleMateriasRoute =
PlanesPlanIdDetalleMateriasRouteImport.update({
id: '/materias',
path: '/materias',
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
} as any)
const PlanesPlanIdDetalleMapaRoute = PlanesPlanIdDetalleMapaRouteImport.update({
id: '/mapa',
path: '/mapa',
getParentRoute: () => PlanesPlanIdDetalleRoute,
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
} as any)
const PlanesPlanIdDetalleIaplanRoute =
PlanesPlanIdDetalleIaplanRouteImport.update({
id: '/iaplan',
path: '/iaplan',
getParentRoute: () => PlanesPlanIdDetalleRoute,
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
} as any)
const PlanesPlanIdDetalleHistorialRoute =
PlanesPlanIdDetalleHistorialRouteImport.update({
id: '/historial',
path: '/historial',
getParentRoute: () => PlanesPlanIdDetalleRoute,
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
} as any)
const PlanesPlanIdDetalleFlujoRoute =
PlanesPlanIdDetalleFlujoRouteImport.update({
id: '/flujo',
path: '/flujo',
getParentRoute: () => PlanesPlanIdDetalleRoute,
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
} as any)
const PlanesPlanIdDetalleDocumentoRoute =
PlanesPlanIdDetalleDocumentoRouteImport.update({
id: '/documento',
path: '/documento',
getParentRoute: () => PlanesPlanIdDetalleRoute,
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
} as any)
const PlanesPlanIdDetalleAsignaturasRoute =
PlanesPlanIdDetalleAsignaturasRouteImport.update({
id: '/asignaturas',
path: '/asignaturas',
getParentRoute: () => PlanesPlanIdDetalleRoute,
const PlanesPlanIdDetalleDatosRoute =
PlanesPlanIdDetalleDatosRouteImport.update({
id: '/datos',
path: '/datos',
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
} as any)
const PlanesPlanIdAsignaturasListaRouteRoute =
PlanesPlanIdAsignaturasListaRouteRouteImport.update({
id: '/_lista',
getParentRoute: () => PlanesPlanIdAsignaturasRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdRouteRoute =
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport.update({
id: '/planes/$planId/asignaturas/$asignaturaId',
path: '/planes/$planId/asignaturas/$asignaturaId',
getParentRoute: () => rootRouteImport,
id: '/$asignaturaId',
path: '/$asignaturaId',
getParentRoute: () => PlanesPlanIdAsignaturasRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdIndexRoute =
PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute =
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport.update({
id: '/iaasignatura',
path: '/iaasignatura',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute =
PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport.update({
id: '/historial',
path: '/historial',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute =
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport.update({
id: '/documento',
path: '/documento',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute =
PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport.update({
id: '/contenido',
path: '/contenido',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute =
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport.update({
id: '/bibliografia',
path: '/bibliografia',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdDetalleAsignaturasNuevaRoute =
PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({
const PlanesPlanIdAsignaturasListaNuevaRoute =
PlanesPlanIdAsignaturasListaNuevaRouteImport.update({
id: '/nueva',
path: '/nueva',
getParentRoute: () => PlanesPlanIdDetalleAsignaturasRoute,
getParentRoute: () => PlanesPlanIdAsignaturasListaRouteRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/planes': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/planes': typeof PlanesListaRouteWithChildren
'/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
'/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
'/planes/$planId/datos': typeof PlanesPlanIdDetalleDatosRoute
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
'/planes/$planId/materias': typeof PlanesPlanIdDetalleMateriasRoute
'/planes/$planId/asignaturas/': typeof PlanesPlanIdAsignaturasIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdAsignaturasListaNuevaRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/planes': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/planes': typeof PlanesListaRouteWithChildren
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
'/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasIndexRoute
'/planes/$planId/datos': typeof PlanesPlanIdDetalleDatosRoute
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
'/planes/$planId/materias': typeof PlanesPlanIdDetalleMateriasRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdAsignaturasListaNuevaRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/planes/_lista': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/planes/_lista': typeof PlanesListaRouteWithChildren
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteRouteWithChildren
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
'/planes/$planId/_detalle/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
'/planes/$planId/asignaturas/_lista': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
'/planes/$planId/_detalle/datos': typeof PlanesPlanIdDetalleDatosRoute
'/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute
'/planes/$planId/_detalle/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
'/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
'/planes/$planId/_detalle/materias': typeof PlanesPlanIdDetalleMateriasRoute
'/planes/$planId/asignaturas/': typeof PlanesPlanIdAsignaturasIndexRoute
'/planes/$planId/asignaturas/_lista/nueva': typeof PlanesPlanIdAsignaturasListaNuevaRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -234,81 +212,75 @@ export interface FileRouteTypes {
| '/'
| '/dashboard'
| '/login'
| '/demo/tanstack-query'
| '/planes'
| '/demo/tanstack-query'
| '/planes/PlanesListRoute'
| '/planes/$planId'
| '/planes/$planId/asignaturas'
| '/planes/nuevo'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/asignaturas'
| '/planes/$planId/datos'
| '/planes/$planId/documento'
| '/planes/$planId/flujo'
| '/planes/$planId/historial'
| '/planes/$planId/iaplan'
| '/planes/$planId/mapa'
| '/planes/$planId/'
| '/planes/$planId/materias'
| '/planes/$planId/asignaturas/'
| '/planes/$planId/asignaturas/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
| '/planes/$planId/asignaturas/$asignaturaId/documento'
| '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/dashboard'
| '/login'
| '/demo/tanstack-query'
| '/planes'
| '/demo/tanstack-query'
| '/planes/PlanesListRoute'
| '/planes/$planId'
| '/planes/nuevo'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/asignaturas'
| '/planes/$planId/datos'
| '/planes/$planId/documento'
| '/planes/$planId/flujo'
| '/planes/$planId/historial'
| '/planes/$planId/iaplan'
| '/planes/$planId/mapa'
| '/planes/$planId'
| '/planes/$planId/materias'
| '/planes/$planId/asignaturas/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
| '/planes/$planId/asignaturas/$asignaturaId/documento'
| '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId'
id:
| '__root__'
| '/'
| '/dashboard'
| '/login'
| '/demo/tanstack-query'
| '/planes/_lista'
| '/demo/tanstack-query'
| '/planes/PlanesListRoute'
| '/planes/$planId/_detalle'
| '/planes/$planId/asignaturas'
| '/planes/_lista/nuevo'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/_detalle/asignaturas'
| '/planes/$planId/asignaturas/_lista'
| '/planes/$planId/_detalle/datos'
| '/planes/$planId/_detalle/documento'
| '/planes/$planId/_detalle/flujo'
| '/planes/$planId/_detalle/historial'
| '/planes/$planId/_detalle/iaplan'
| '/planes/$planId/_detalle/mapa'
| '/planes/$planId/_detalle/'
| '/planes/$planId/_detalle/asignaturas/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
| '/planes/$planId/asignaturas/$asignaturaId/documento'
| '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId/'
| '/planes/$planId/_detalle/materias'
| '/planes/$planId/asignaturas/'
| '/planes/$planId/asignaturas/_lista/nueva'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DashboardRoute: typeof DashboardRoute
LoginRoute: typeof LoginRoute
PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
PlanesListaRoute: typeof PlanesListaRouteWithChildren
PlanesPlanIdDetalleRoute: typeof PlanesPlanIdDetalleRouteWithChildren
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
PlanesPlanesListRouteRoute: typeof PlanesPlanesListRouteRoute
PlanesPlanIdDetalleRouteRoute: typeof PlanesPlanIdDetalleRouteRouteWithChildren
PlanesPlanIdAsignaturasRouteRoute: typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
}
declare module '@tanstack/react-router' {
@@ -334,11 +306,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/_lista': {
id: '/planes/_lista'
path: '/planes'
fullPath: '/planes'
preLoaderRoute: typeof PlanesListaRouteImport
'/planes/PlanesListRoute': {
id: '/planes/PlanesListRoute'
path: '/planes/PlanesListRoute'
fullPath: '/planes/PlanesListRoute'
preLoaderRoute: typeof PlanesPlanesListRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/tanstack-query': {
@@ -348,218 +320,196 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DemoTanstackQueryRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/_lista': {
id: '/planes/_lista'
path: '/planes'
fullPath: '/planes'
preLoaderRoute: typeof PlanesListaRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/_lista/nuevo': {
id: '/planes/_lista/nuevo'
path: '/nuevo'
fullPath: '/planes/nuevo'
preLoaderRoute: typeof PlanesListaNuevoRouteImport
parentRoute: typeof PlanesListaRoute
parentRoute: typeof PlanesListaRouteRoute
}
'/planes/$planId/asignaturas': {
id: '/planes/$planId/asignaturas'
path: '/planes/$planId/asignaturas'
fullPath: '/planes/$planId/asignaturas'
preLoaderRoute: typeof PlanesPlanIdAsignaturasRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/$planId/_detalle': {
id: '/planes/$planId/_detalle'
path: '/planes/$planId'
fullPath: '/planes/$planId'
preLoaderRoute: typeof PlanesPlanIdDetalleRouteImport
preLoaderRoute: typeof PlanesPlanIdDetalleRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/$planId/_detalle/': {
id: '/planes/$planId/_detalle/'
'/planes/$planId/asignaturas/': {
id: '/planes/$planId/asignaturas/'
path: '/'
fullPath: '/planes/$planId/'
preLoaderRoute: typeof PlanesPlanIdDetalleIndexRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute
fullPath: '/planes/$planId/asignaturas/'
preLoaderRoute: typeof PlanesPlanIdAsignaturasIndexRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasRouteRoute
}
'/planes/$planId/_detalle/materias': {
id: '/planes/$planId/_detalle/materias'
path: '/materias'
fullPath: '/planes/$planId/materias'
preLoaderRoute: typeof PlanesPlanIdDetalleMateriasRouteImport
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
}
'/planes/$planId/_detalle/mapa': {
id: '/planes/$planId/_detalle/mapa'
path: '/mapa'
fullPath: '/planes/$planId/mapa'
preLoaderRoute: typeof PlanesPlanIdDetalleMapaRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
}
'/planes/$planId/_detalle/iaplan': {
id: '/planes/$planId/_detalle/iaplan'
path: '/iaplan'
fullPath: '/planes/$planId/iaplan'
preLoaderRoute: typeof PlanesPlanIdDetalleIaplanRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
}
'/planes/$planId/_detalle/historial': {
id: '/planes/$planId/_detalle/historial'
path: '/historial'
fullPath: '/planes/$planId/historial'
preLoaderRoute: typeof PlanesPlanIdDetalleHistorialRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
}
'/planes/$planId/_detalle/flujo': {
id: '/planes/$planId/_detalle/flujo'
path: '/flujo'
fullPath: '/planes/$planId/flujo'
preLoaderRoute: typeof PlanesPlanIdDetalleFlujoRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
}
'/planes/$planId/_detalle/documento': {
id: '/planes/$planId/_detalle/documento'
path: '/documento'
fullPath: '/planes/$planId/documento'
preLoaderRoute: typeof PlanesPlanIdDetalleDocumentoRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
}
'/planes/$planId/_detalle/asignaturas': {
id: '/planes/$planId/_detalle/asignaturas'
path: '/asignaturas'
'/planes/$planId/_detalle/datos': {
id: '/planes/$planId/_detalle/datos'
path: '/datos'
fullPath: '/planes/$planId/datos'
preLoaderRoute: typeof PlanesPlanIdDetalleDatosRouteImport
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
}
'/planes/$planId/asignaturas/_lista': {
id: '/planes/$planId/asignaturas/_lista'
path: ''
fullPath: '/planes/$planId/asignaturas'
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute
preLoaderRoute: typeof PlanesPlanIdAsignaturasListaRouteRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId': {
id: '/planes/$planId/asignaturas/$asignaturaId'
path: '/planes/$planId/asignaturas/$asignaturaId'
path: '/$asignaturaId'
fullPath: '/planes/$planId/asignaturas/$asignaturaId'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport
parentRoute: typeof rootRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/': {
id: '/planes/$planId/asignaturas/$asignaturaId/'
path: '/'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': {
id: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
path: '/iaasignatura'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/historial': {
id: '/planes/$planId/asignaturas/$asignaturaId/historial'
path: '/historial'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/historial'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/documento': {
id: '/planes/$planId/asignaturas/$asignaturaId/documento'
path: '/documento'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/documento'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/contenido': {
id: '/planes/$planId/asignaturas/$asignaturaId/contenido'
path: '/contenido'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/contenido'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': {
id: '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
path: '/bibliografia'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/_detalle/asignaturas/nueva': {
id: '/planes/$planId/_detalle/asignaturas/nueva'
'/planes/$planId/asignaturas/_lista/nueva': {
id: '/planes/$planId/asignaturas/_lista/nueva'
path: '/nueva'
fullPath: '/planes/$planId/asignaturas/nueva'
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRouteImport
parentRoute: typeof PlanesPlanIdDetalleAsignaturasRoute
preLoaderRoute: typeof PlanesPlanIdAsignaturasListaNuevaRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasListaRouteRoute
}
}
}
interface PlanesListaRouteChildren {
interface PlanesListaRouteRouteChildren {
PlanesListaNuevoRoute: typeof PlanesListaNuevoRoute
}
const PlanesListaRouteChildren: PlanesListaRouteChildren = {
const PlanesListaRouteRouteChildren: PlanesListaRouteRouteChildren = {
PlanesListaNuevoRoute: PlanesListaNuevoRoute,
}
const PlanesListaRouteWithChildren = PlanesListaRoute._addFileChildren(
PlanesListaRouteChildren,
)
const PlanesListaRouteRouteWithChildren =
PlanesListaRouteRoute._addFileChildren(PlanesListaRouteRouteChildren)
interface PlanesPlanIdDetalleAsignaturasRouteChildren {
PlanesPlanIdDetalleAsignaturasNuevaRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
}
const PlanesPlanIdDetalleAsignaturasRouteChildren: PlanesPlanIdDetalleAsignaturasRouteChildren =
{
PlanesPlanIdDetalleAsignaturasNuevaRoute:
PlanesPlanIdDetalleAsignaturasNuevaRoute,
}
const PlanesPlanIdDetalleAsignaturasRouteWithChildren =
PlanesPlanIdDetalleAsignaturasRoute._addFileChildren(
PlanesPlanIdDetalleAsignaturasRouteChildren,
)
interface PlanesPlanIdDetalleRouteChildren {
PlanesPlanIdDetalleAsignaturasRoute: typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
interface PlanesPlanIdDetalleRouteRouteChildren {
PlanesPlanIdDetalleDatosRoute: typeof PlanesPlanIdDetalleDatosRoute
PlanesPlanIdDetalleDocumentoRoute: typeof PlanesPlanIdDetalleDocumentoRoute
PlanesPlanIdDetalleFlujoRoute: typeof PlanesPlanIdDetalleFlujoRoute
PlanesPlanIdDetalleHistorialRoute: typeof PlanesPlanIdDetalleHistorialRoute
PlanesPlanIdDetalleIaplanRoute: typeof PlanesPlanIdDetalleIaplanRoute
PlanesPlanIdDetalleMapaRoute: typeof PlanesPlanIdDetalleMapaRoute
PlanesPlanIdDetalleIndexRoute: typeof PlanesPlanIdDetalleIndexRoute
PlanesPlanIdDetalleMateriasRoute: typeof PlanesPlanIdDetalleMateriasRoute
}
const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = {
PlanesPlanIdDetalleAsignaturasRoute:
PlanesPlanIdDetalleAsignaturasRouteWithChildren,
const PlanesPlanIdDetalleRouteRouteChildren: PlanesPlanIdDetalleRouteRouteChildren =
{
PlanesPlanIdDetalleDatosRoute: PlanesPlanIdDetalleDatosRoute,
PlanesPlanIdDetalleDocumentoRoute: PlanesPlanIdDetalleDocumentoRoute,
PlanesPlanIdDetalleFlujoRoute: PlanesPlanIdDetalleFlujoRoute,
PlanesPlanIdDetalleHistorialRoute: PlanesPlanIdDetalleHistorialRoute,
PlanesPlanIdDetalleIaplanRoute: PlanesPlanIdDetalleIaplanRoute,
PlanesPlanIdDetalleMapaRoute: PlanesPlanIdDetalleMapaRoute,
PlanesPlanIdDetalleIndexRoute: PlanesPlanIdDetalleIndexRoute,
PlanesPlanIdDetalleMateriasRoute: PlanesPlanIdDetalleMateriasRoute,
}
const PlanesPlanIdDetalleRouteWithChildren =
PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren)
const PlanesPlanIdDetalleRouteRouteWithChildren =
PlanesPlanIdDetalleRouteRoute._addFileChildren(
PlanesPlanIdDetalleRouteRouteChildren,
)
interface PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren {
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
interface PlanesPlanIdAsignaturasListaRouteRouteChildren {
PlanesPlanIdAsignaturasListaNuevaRoute: typeof PlanesPlanIdAsignaturasListaNuevaRoute
}
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren =
const PlanesPlanIdAsignaturasListaRouteRouteChildren: PlanesPlanIdAsignaturasListaRouteRouteChildren =
{
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute:
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute,
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute:
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute,
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute:
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute,
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute:
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute,
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute:
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute,
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute:
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute,
PlanesPlanIdAsignaturasListaNuevaRoute:
PlanesPlanIdAsignaturasListaNuevaRoute,
}
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren =
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute._addFileChildren(
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren,
const PlanesPlanIdAsignaturasListaRouteRouteWithChildren =
PlanesPlanIdAsignaturasListaRouteRoute._addFileChildren(
PlanesPlanIdAsignaturasListaRouteRouteChildren,
)
interface PlanesPlanIdAsignaturasRouteRouteChildren {
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
PlanesPlanIdAsignaturasListaRouteRoute: typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
PlanesPlanIdAsignaturasIndexRoute: typeof PlanesPlanIdAsignaturasIndexRoute
}
const PlanesPlanIdAsignaturasRouteRouteChildren: PlanesPlanIdAsignaturasRouteRouteChildren =
{
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute:
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
PlanesPlanIdAsignaturasListaRouteRoute:
PlanesPlanIdAsignaturasListaRouteRouteWithChildren,
PlanesPlanIdAsignaturasIndexRoute: PlanesPlanIdAsignaturasIndexRoute,
}
const PlanesPlanIdAsignaturasRouteRouteWithChildren =
PlanesPlanIdAsignaturasRouteRoute._addFileChildren(
PlanesPlanIdAsignaturasRouteRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DashboardRoute: DashboardRoute,
LoginRoute: LoginRoute,
PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren,
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
PlanesListaRoute: PlanesListaRouteWithChildren,
PlanesPlanIdDetalleRoute: PlanesPlanIdDetalleRouteWithChildren,
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute:
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren,
PlanesPlanesListRouteRoute: PlanesPlanesListRouteRoute,
PlanesPlanIdDetalleRouteRoute: PlanesPlanIdDetalleRouteRouteWithChildren,
PlanesPlanIdAsignaturasRouteRoute:
PlanesPlanIdAsignaturasRouteRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -1,59 +1,20 @@
import { TanStackDevtools } from '@tanstack/react-devtools'
import {
Outlet,
createRootRouteWithContext,
redirect,
useNavigate,
useRouterState,
} from '@tanstack/react-router'
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { useEffect } from 'react'
import Header from '../components/Header'
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
import type { Database } from '@/types/supabase'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { QueryClient } from '@tanstack/react-query'
import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { throwIfError } from '@/data/api/_helpers'
import { useSession } from '@/data/hooks/useAuth'
import { qk } from '@/data/query/keys'
interface MyRouterContext {
queryClient: QueryClient
supabase: SupabaseClient<Database>
}
export const Route = createRootRouteWithContext<MyRouterContext>()({
beforeLoad: async ({ context, location }) => {
const pathname = location.pathname
const isLogin = pathname === '/login'
const isIndex = pathname === '/'
const session = await context.queryClient.ensureQueryData({
queryKey: qk.session(),
queryFn: async () => {
const { data, error } = await context.supabase.auth.getSession()
throwIfError(error)
return data.session ?? null
},
staleTime: Infinity,
})
if (!session && !isLogin) {
throw redirect({ to: '/login' })
}
if (session && (isLogin || isIndex)) {
throw redirect({ to: '/dashboard' })
}
},
component: () => (
<>
<AuthSync />
<MaybeHeader />
<Header />
<Outlet />
<TanStackDevtools
config={{
@@ -69,68 +30,4 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
/>
</>
),
notFoundComponent: () => <NotFoundPage />,
errorComponent: ({ error, reset }) => {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center space-y-4 p-6 text-center">
<h2 className="text-2xl font-bold text-red-600">
¡Ups! Algo salió mal
</h2>
<p className="max-w-md text-gray-600">
Ocurrió un error inesperado al cargar esta sección.
</p>
{/* Opcional: Mostrar el detalle técnico en desarrollo */}
<pre className="max-w-full overflow-auto rounded border border-gray-300 bg-gray-100 p-4 text-left text-xs">
{error.message}
</pre>
<button
onClick={reset}
className="rounded bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700"
>
Intentar de nuevo
</button>
</div>
)
},
})
function MaybeHeader() {
const pathname = useRouterState({
select: (s) => s.location.pathname,
})
if (pathname === '/login') return null
return <Header />
}
function AuthSync() {
const { data: session, isLoading } = useSession()
// Mantiene roles/permisos sincronizados con la BD (database-first)
// useMeAccess()
const navigate = useNavigate()
const pathname = useRouterState({
select: (s) => s.location.pathname,
})
// Reaccionar a cambios de sesión (login/logout) sin depender solo de beforeLoad.
// Nota: beforeLoad sigue siendo la línea de defensa en navegación/refresh.
useEffect(() => {
if (isLoading) return
if (!session && pathname !== '/login') {
void navigate({ to: '/login', replace: true })
return
}
if (session && pathname === '/login') {
void navigate({ to: '/dashboard', replace: true })
}
}, [isLoading, session, pathname, navigate])
return null
}

View File

@@ -1,343 +0,0 @@
import { createFileRoute, Outlet, Link, notFound } from '@tanstack/react-router'
import {
ChevronLeft,
GraduationCap,
Clock,
Hash,
CalendarDays,
} from 'lucide-react'
import { useState, useEffect, forwardRef } from 'react'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { Skeleton } from '@/components/ui/skeleton'
import { plans_get } from '@/data/api/plans.api'
import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans'
import { qk } from '@/data/query/keys'
export const Route = createFileRoute('/planes/$planId/_detalle')({
loader: async ({ context: { queryClient }, params: { planId } }) => {
try {
await queryClient.ensureQueryData({
queryKey: qk.plan(planId),
queryFn: () => plans_get(planId),
})
} catch (e: any) {
// PGRST116: The result contains 0 rows
if (e?.code === 'PGRST116') {
throw notFound()
}
throw e
}
},
notFoundComponent: () => {
return (
<NotFoundPage
title="Plan de Estudios no encontrado"
message="El plan de estudios que intentas consultar no existe o no tienes permisos para verlo."
/>
)
},
component: RouteComponent,
})
function RouteComponent() {
const { planId } = Route.useParams()
const { data, isLoading } = usePlan(planId)
const { mutate } = useUpdatePlanFields()
// Estados locales para manejar la edición "en vivo" antes de persistir
const [nombrePlan, setNombrePlan] = useState('')
const [nivelPlan, setNivelPlan] = useState('')
const [isDirty, setIsDirty] = useState(false)
useEffect(() => {
if (data) {
setNombrePlan(data.nombre || '')
setNivelPlan(data.nivel || '')
}
}, [data])
const niveles = [
'Licenciatura',
'Maestría',
'Doctorado',
'Diplomado',
'Especialidad',
]
const persistChange = (patch: any) => {
mutate({ planId, patch })
}
const MAX_CHARACTERS = 200
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
// 1. Permitir teclas de control (Borrar, flechas, etc.) siempre
const isControlKey =
e.key === 'Backspace' ||
e.key === 'Delete' ||
e.key.includes('Arrow') ||
e.metaKey ||
e.ctrlKey
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
return
}
// 2. Bloquear si excede los 200 caracteres y no es una tecla de control
const currentText = e.currentTarget.textContent || ''
if (currentText.length >= MAX_CHARACTERS && !isControlKey) {
e.preventDefault()
}
}
const handlePaste = (e: React.ClipboardEvent<HTMLSpanElement>) => {
e.preventDefault()
const text = e.clipboardData.getData('text/plain')
const currentText = e.currentTarget.textContent || ''
// Calcular cuánto espacio queda
const remainingSpace = MAX_CHARACTERS - currentText.length
if (remainingSpace > 0) {
const slicedText = text.slice(0, remainingSpace)
document.execCommand('insertText', false, slicedText)
}
}
return (
<div className="min-h-screen bg-white">
{/* 1. Header Superior */}
<div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm">
<div className="px-6 py-2">
<Link
to="/planes"
className="flex w-fit items-center gap-1 text-xs text-gray-500 transition-colors hover:text-gray-800"
>
<ChevronLeft size={14} /> Volver a planes
</Link>
</div>
</div>
<div className="mx-auto max-w-400 space-y-8 p-8">
{/* 2. Header del Plan */}
{isLoading ? (
/* ===== SKELETON ===== */
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => (
<DatosGeneralesSkeleton key={i} />
))}
</div>
) : (
<div className="flex flex-col items-start justify-between gap-4 md:flex-row">
<div>
<h1 className="flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight text-slate-900">
{/* El prefijo "Nivel en" lo mantenemos simple */}
<span className="shrink-0">{nivelPlan} en</span>
<span
role="textbox"
tabIndex={0}
contentEditable
suppressContentEditableWarning
spellCheck={false}
onKeyDown={handleKeyDown}
onPaste={handlePaste} // Añadido para controlar lo que pegan
onBlur={(e) => {
const nuevoNombre =
e.currentTarget.textContent?.trim() || ''
setNombrePlan(nuevoNombre)
if (nuevoNombre !== data?.nombre) {
mutate({ planId, patch: { nombre: nuevoNombre } })
}
}}
// Clases añadidas: break-words y whitespace-pre-wrap para el wrap
className="block w-full cursor-text border-b border-transparent break-words whitespace-pre-wrap transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500 sm:inline-block sm:w-auto"
style={{ textDecoration: 'none' }}
>
{nombrePlan}
</span>
</h1>
<p className="mt-1 text-lg font-medium text-slate-500">
{data?.carreras?.facultades?.nombre}{' '}
{data?.carreras?.nombre_corto}
</p>
</div>
<div className="flex gap-2">
<Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
{data?.estados_plan?.etiqueta}
</Badge>
</div>
</div>
)}
{/* 3. Cards de Información */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<InfoCard
icon={<GraduationCap className="text-slate-400" />}
label="Nivel"
value={nivelPlan}
isEditable
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
{niveles.map((n) => (
<DropdownMenuItem
key={n}
onClick={() => {
setNivelPlan(n)
if (n !== data?.nivel) {
mutate({ planId, patch: { nivel: n } })
}
}}
>
{n}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<InfoCard
icon={<Clock className="text-slate-400" />}
label="Duración"
value={`${data?.numero_ciclos || 0} Ciclos`}
/>
<InfoCard
icon={<Hash className="text-slate-400" />}
label="Créditos"
value="320"
/>
<InfoCard
icon={<CalendarDays className="text-slate-400" />}
label="Creación"
value={data?.creado_en?.split('T')[0]}
/>
</div>
{/* 4. Navegación de Tabs */}
<div className="scrollbar-hide overflow-x-auto border-b">
<nav className="flex min-w-max gap-8">
<Tab to="/planes/$planId/" params={{ planId }}>
Datos Generales
</Tab>
<Tab to="/planes/$planId/mapa" params={{ planId }}>
Mapa Curricular
</Tab>
<Tab to="/planes/$planId/asignaturas" params={{ planId }}>
Asignaturas
</Tab>
<Tab to="/planes/$planId/flujo" params={{ planId }}>
Flujo y Estados
</Tab>
<Tab to="/planes/$planId/iaplan" params={{ planId }}>
IA del Plan
</Tab>
<Tab to="/planes/$planId/documento" params={{ planId }}>
Documento
</Tab>
<Tab to="/planes/$planId/historial" params={{ planId }}>
Historial
</Tab>
</nav>
</div>
<main className="animate-in fade-in pt-2 duration-500">
<Outlet />
</main>
</div>
</div>
)
}
const InfoCard = forwardRef<
HTMLDivElement,
{
icon: React.ReactNode
label: string
value: string | number | undefined
isEditable?: boolean
} & React.HTMLAttributes<HTMLDivElement>
>(function InfoCard(
{ icon, label, value, isEditable, className, ...props },
ref,
) {
return (
<div
ref={ref}
{...props}
className={`flex h-18 w-full items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm transition-all ${
isEditable
? 'cursor-pointer hover:border-teal-200 hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500/40'
: ''
} ${className ?? ''}`}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-white shadow-sm">
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="mb-0.5 truncate text-[10px] font-bold tracking-wider text-slate-400 uppercase">
{label}
</p>
<p className="truncate text-sm font-semibold text-slate-700">
{value || '---'}
</p>
</div>
</div>
)
})
function Tab({
to,
params,
children,
}: {
to: string
params?: any
search?: any
children: React.ReactNode
}) {
return (
<Link
to={to}
params={params}
className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800"
activeProps={{ className: 'border-teal-600 text-teal-700 font-bold' }}
activeOptions={{
exact: true,
}}
>
{children}
</Link>
)
}
function DatosGeneralesSkeleton() {
return (
<div className="rounded-xl border bg-white">
{/* Header */}
<div className="flex items-center justify-between border-b px-5 py-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-8 w-16" />
</div>
{/* Content */}
<div className="space-y-3 p-5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-10/12" />
<Skeleton className="h-4 w-9/12" />
</div>
</div>
)
}

View File

@@ -0,0 +1,137 @@
import * as Dialog from '@radix-ui/react-dialog';
import { Pencil, X } from 'lucide-react';
export type Materia = {
id: string;
clave: string;
nombre: string;
creditos: number;
hd: number; // Horas Docente
hi: number; // Horas Independientes
tipo: 'Obligatoria' | 'Optativa' | 'Especialidad';
ciclo: number;
linea: string;
estado: string;
};
interface MateriaCardProps {
materia: Materia;
}
export function MateriaCard({ materia }: MateriaCardProps) {
return (
<Dialog.Root>
{/* Trigger: La tarjeta en sí misma */}
<Dialog.Trigger asChild>
<div className="group relative flex flex-col p-2 mb-2 rounded-lg border border-slate-200 bg-white hover:border-emerald-500 hover:shadow-md transition-all cursor-pointer select-none">
{/* Header de la tarjeta */}
<div className="flex justify-between items-start mb-1">
<span className="text-[9px] font-mono font-bold text-slate-400 uppercase">{materia.clave}</span>
<div className="flex gap-1">
<span className="px-1.5 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-[8px] font-bold uppercase">
{materia.tipo === 'Obligatoria' ? 'OB' : 'OP'}
</span>
</div>
</div>
{/* Nombre */}
<h4 className="text-[11px] font-semibold text-slate-800 leading-tight mb-2 min-h-[2rem]">
{materia.nombre}
</h4>
{/* Footer de la tarjeta (Créditos y Horas) */}
<div className="flex justify-between items-center text-[9px] text-slate-500 border-t pt-1 border-slate-50">
<span>{materia.creditos} cr</span>
<div className="flex gap-1">
<span>HD:{materia.hd}</span>
<span>HI:{materia.hi}</span>
</div>
</div>
{/* Overlay de Hover (Opcional: un iconito de editar) */}
<div className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Pencil className="w-3 h-3 text-emerald-600" />
</div>
</div>
</Dialog.Trigger>
{/* Modal / Portal */}
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 animate-in fade-in" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white rounded-xl shadow-2xl p-6 z-50 border border-slate-200 animate-in zoom-in-95">
<div className="flex justify-between items-center mb-6">
<Dialog.Title className="text-lg font-bold text-slate-800">Editar Materia</Dialog.Title>
<Dialog.Close className="text-slate-400 hover:text-slate-600 transition-colors">
<X className="w-5 h-5" />
</Dialog.Close>
</div>
<form className="space-y-4">
{/* Clave y Nombre */}
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-slate-600 uppercase">Clave</label>
<input
defaultValue={materia.clave}
className="px-3 py-2 rounded-lg border border-slate-300 focus:ring-2 focus:ring-emerald-500 outline-none text-sm font-mono"
/>
</div>
<div className="col-span-2 flex flex-col gap-1.5">
<label className="text-xs font-bold text-slate-600 uppercase">Nombre</label>
<input
defaultValue={materia.nombre}
className="px-3 py-2 rounded-lg border border-slate-300 focus:ring-2 focus:ring-emerald-500 outline-none text-sm"
/>
</div>
</div>
{/* Créditos y Horas */}
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-slate-600 uppercase italic">Créditos</label>
<input type="number" defaultValue={materia.creditos} className="px-3 py-2 rounded-lg border border-slate-300 text-sm" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-slate-600 uppercase italic">HD (Hrs Docente)</label>
<input type="number" defaultValue={materia.hd} className="px-3 py-2 rounded-lg border border-slate-300 text-sm" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-slate-600 uppercase italic">HI (Hrs Indep.)</label>
<input type="number" defaultValue={materia.hi} className="px-3 py-2 rounded-lg border border-slate-300 text-sm" />
</div>
</div>
{/* Ciclo y Línea */}
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-slate-600 uppercase">Ciclo</label>
<select className="px-3 py-2 rounded-lg border border-slate-300 text-sm bg-white">
<option>Ciclo {materia.ciclo}</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-slate-600 uppercase">Línea Curricular</label>
<select className="px-3 py-2 rounded-lg border border-slate-300 text-sm bg-white">
<option>{materia.linea}</option>
</select>
</div>
</div>
{/* Botones de acción */}
<div className="flex justify-end gap-3 pt-6">
<Dialog.Close className="px-4 py-2 rounded-lg text-sm font-semibold text-slate-600 hover:bg-slate-100 transition-colors">
Cancelar
</Dialog.Close>
<button
type="button"
className="px-6 py-2 rounded-lg text-sm font-semibold bg-emerald-700 text-white hover:bg-emerald-800 transition-colors shadow-sm"
>
Guardar
</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,314 +0,0 @@
import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'
import {
Plus,
Search,
Filter,
ChevronRight,
BookOpen,
Loader2,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import type { Asignatura, AsignaturaStatus, TipoAsignatura } from '@/types/plan'
import type { Tables } from '@/types/supabase'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { usePlanAsignaturas, usePlanLineas } from '@/data'
// --- Configuración de Estilos ---
const statusConfig: Record<
AsignaturaStatus,
{ label: string; className: string }
> = {
generando: {
label: 'Generando',
className:
'bg-slate-100 text-slate-600 animate-pulse [animation-duration:2s]',
},
borrador: { label: 'Borrador', className: 'bg-slate-100 text-slate-600' },
revisada: { label: 'Revisada', className: 'bg-amber-100 text-amber-700' },
aprobada: { label: 'Aprobada', className: 'bg-emerald-100 text-emerald-700' },
}
const tipoConfig: Record<TipoAsignatura, { label: string; className: string }> =
{
OBLIGATORIA: {
label: 'Obligatoria',
className: 'bg-blue-100 text-blue-700',
},
OPTATIVA: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' },
TRONCAL: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' },
OTRA: { label: 'Otra', className: 'bg-slate-100 text-slate-700' },
}
// --- Mapeadores de API ---
const mapAsignaturas = (
asigApi: Array<Tables<'asignaturas'>> = [],
): Array<Asignatura> => {
return asigApi.map((asig) => ({
id: asig.id,
clave: asig.codigo ?? '',
nombre: asig.nombre,
creditos: asig.creditos,
ciclo: asig.numero_ciclo ?? null,
lineaCurricularId: asig.linea_plan_id ?? null,
tipo: asig.tipo,
estado: asig.estado,
hd: asig.horas_academicas ?? 0,
hi: asig.horas_independientes ?? 0,
prerrequisitos: [],
}))
}
export const Route = createFileRoute('/planes/$planId/_detalle/asignaturas')({
component: AsignaturasPage,
})
function AsignaturasPage() {
const { planId } = Route.useParams()
const navigate = useNavigate()
// 1. Fetch de datos reales
const { data: asignaturaApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
// 2. Estados de filtrado
const [searchTerm, setSearchTerm] = useState('')
const [filterTipo, setFilterTipo] = useState<string>('all')
const [filterEstado, setFilterEstado] = useState<string>('all')
const [filterLinea, setFilterLinea] = useState<string>('all')
// 3. Procesamiento de datos
const asignaturas = useMemo(
() => mapAsignaturas(asignaturaApi),
[asignaturaApi],
)
const lineas = useMemo(() => lineasApi || [], [lineasApi])
const filteredAsignaturas = asignaturas.filter((m) => {
const matchesSearch =
m.nombre.toLowerCase().includes(searchTerm.toLowerCase()) ||
m.clave.toLowerCase().includes(searchTerm.toLowerCase())
const matchesTipo = filterTipo === 'all' || m.tipo === filterTipo
const matchesEstado = filterEstado === 'all' || m.estado === filterEstado
const matchesLinea =
filterLinea === 'all' || m.lineaCurricularId === filterLinea
return matchesSearch && matchesTipo && matchesEstado && matchesLinea
})
const getLineaNombre = (lineaId: string | null) => {
if (!lineaId) return 'Sin asignar'
return lineas.find((l: any) => l.id === lineaId)?.nombre || 'Desconocida'
}
if (loadingAsig || loadingLineas) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
)
}
return (
<div className="container mx-auto space-y-6 px-6 py-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="text-foreground text-xl font-bold">
Asignaturas del Plan
</h2>
<p className="text-muted-foreground mt-1 text-sm">
{asignaturas.length} asignaturas en total {' '}
{filteredAsignaturas.length} filtradas
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => {
console.log('planId desde asignaturas', planId)
navigate({
to: `/planes/${planId}/asignaturas/nueva`,
resetScroll: false,
})
}}
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
>
<Plus className="mr-2 h-4 w-4" /> Nueva Asignatura
</Button>
</div>
</div>
{/* Barra de Filtros Avanzada */}
<div className="flex flex-wrap items-center gap-3 rounded-xl border bg-slate-50 p-4">
<div className="relative min-w-60 flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="Buscar por nombre o clave..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="bg-white pl-9"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Filter className="text-muted-foreground mr-1 h-4 w-4" />
<Select value={filterTipo} onValueChange={setFilterTipo}>
<SelectTrigger className="w-35 bg-white">
<SelectValue placeholder="Tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los tipos</SelectItem>
<SelectItem value="obligatoria">Obligatoria</SelectItem>
<SelectItem value="optativa">Optativa</SelectItem>
</SelectContent>
</Select>
<Select value={filterEstado} onValueChange={setFilterEstado}>
<SelectTrigger className="w-35 bg-white">
<SelectValue placeholder="Estado" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los estados</SelectItem>
<SelectItem value="borrador">Borrador</SelectItem>
<SelectItem value="revisada">Revisada</SelectItem>
<SelectItem value="aprobada">Aprobada</SelectItem>
</SelectContent>
</Select>
<Select value={filterLinea} onValueChange={setFilterLinea}>
<SelectTrigger className="w-45 bg-white">
<SelectValue placeholder="Línea" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas las líneas</SelectItem>
{lineas.map((linea: any) => (
<SelectItem key={linea.id} value={linea.id}>
{linea.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Tabla Pro */}
<div className="overflow-hidden rounded-xl border bg-white shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-slate-50/50">
<TableHead className="w-30">Clave</TableHead>
<TableHead>Nombre</TableHead>
<TableHead className="text-center">Créditos</TableHead>
<TableHead className="text-center">Ciclo</TableHead>
<TableHead>Línea Curricular</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Estado</TableHead>
<TableHead className="w-12.5"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAsignaturas.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-40 text-center">
<div className="text-muted-foreground flex flex-col items-center justify-center">
<BookOpen className="mb-2 h-10 w-10 opacity-20" />
<p className="font-medium">No se encontraron asignaturas</p>
<p className="text-xs">
Intenta cambiar los filtros de búsqueda
</p>
</div>
</TableCell>
</TableRow>
) : (
filteredAsignaturas.map((asignatura) => (
<TableRow
key={asignatura.id}
className="group cursor-pointer transition-colors hover:bg-slate-50/80"
onClick={() =>
navigate({
to: '/planes/$planId/asignaturas/$asignaturaId',
params: {
planId,
asignaturaId: asignatura.id, // 👈 puede ser índice, consecutivo o slug
},
state: {
realId: asignatura.id, // 👈 ID largo oculto
asignaturaId: asignatura.id,
} as any,
})
}
>
<TableCell className="font-mono text-xs font-bold text-slate-400">
{asignatura.clave}
</TableCell>
<TableCell className="font-semibold text-slate-700">
{asignatura.nombre}
</TableCell>
<TableCell className="text-center font-medium">
{asignatura.creditos}
</TableCell>
<TableCell className="text-center">
{asignatura.ciclo ? (
<Badge variant="outline" className="font-normal">
Ciclo {asignatura.ciclo}
</Badge>
) : (
<span className="text-slate-300"></span>
)}
</TableCell>
<TableCell className="text-sm text-slate-600">
{getLineaNombre(asignatura.lineaCurricularId)}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
>
{tipoConfig[asignatura.tipo].label}
</Badge>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
>
{statusConfig[asignatura.estado].label}
</Badge>
</TableCell>
<TableCell>
<div className="opacity-0 transition-opacity group-hover:opacity-100">
<ChevronRight className="h-5 w-5 text-slate-400" />
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<Outlet />
</div>
)
}

View File

@@ -0,0 +1,149 @@
import { usePlan } from '@/data';
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import type { DatosGeneralesField } from '@/types/plan'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Pencil,
Check,
X,
Sparkles,
AlertCircle
} from 'lucide-react'
//import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea
export const Route = createFileRoute('/planes/$planId/_detalle/datos')({
component: DatosGeneralesPage,
})
function DatosGeneralesPage() {
// 1. Definimos los DATOS iniciales (Lo que antes venía por props)
const [campos, setCampos] = useState<DatosGeneralesField[]>([
{ id: '1', label: 'Objetivo General', value: 'Formar profesionales...', requerido: true, tipo: 'texto' },
{ id: '2', label: 'Perfil de Ingreso', value: 'Interés por la tecnología...', requerido: true, tipo: 'lista' },
{ id: '3', label: 'Perfil de Egreso', value: '', requerido: true, tipo: 'texto' },
])
// 2. Estados de edición
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
// 3. Manejadores de acciones (Ahora como funciones locales)
const handleEdit = (campo: DatosGeneralesField) => {
setEditingId(campo.id)
setEditValue(campo.value)
}
const handleCancel = () => {
setEditingId(null)
setEditValue('')
}
const handleSave = (id: string) => {
// Actualizamos el estado local de la lista
setCampos(prev => prev.map(c =>
c.id === id ? { ...c, value: editValue } : c
))
setEditingId(null)
setEditValue('')
//toast.success('Cambios guardados localmente')
}
const handleIARequest = (id: string) => {
//toast.info('La IA está analizando el campo ' + id)
// Aquí conectarías con tu endpoint de IA
}
return (
<div className="container mx-auto px-6 py-6 animate-in fade-in duration-500">
<div className="mb-6">
<h2 className="text-lg font-semibold text-foreground">
Datos Generales del Plan
</h2>
<p className="text-sm text-muted-foreground mt-1">
Información estructural y descriptiva del plan de estudios
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{campos.map((campo) => {
const isEditing = editingId === campo.id
return (
<div
key={campo.id}
className={`border rounded-xl transition-all ${
isEditing ? 'border-teal-500 ring-2 ring-teal-50 shadow-lg' : 'bg-white hover:shadow-md'
}`}
>
{/* Header de la Card */}
<div className="flex items-center justify-between px-5 py-3 border-b bg-slate-50/50">
<div className="flex items-center gap-2">
<h3 className="font-medium text-sm text-slate-700">{campo.label}</h3>
{campo.requerido && <span className="text-red-500 text-xs">*</span>}
</div>
{!isEditing && (
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-teal-600" onClick={() => handleIARequest(campo.id)}>
<Sparkles size={14} />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleEdit(campo)}>
<Pencil size={14} />
</Button>
</div>
)}
</div>
{/* Contenido de la Card */}
<div className="p-5">
{isEditing ? (
<div className="space-y-3">
<Textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="min-h-[120px]"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={handleCancel}>
<X size={14} className="mr-1" /> Cancelar
</Button>
<Button size="sm" className="bg-teal-600 hover:bg-teal-700" onClick={() => handleSave(campo.id)}>
<Check size={14} className="mr-1" /> Guardar
</Button>
</div>
</div>
) : (
<div className="min-h-[100px]">
{campo.value ? (
<div className="text-sm text-slate-600 leading-relaxed">
{campo.tipo === 'lista' ? (
<ul className="space-y-1">
{campo.value.split('\n').map((item, i) => (
<li key={i} className="flex gap-2">
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-teal-500 shrink-0" />
{item}
</li>
))}
</ul>
) : (
<p className="whitespace-pre-wrap">{campo.value}</p>
)}
</div>
) : (
<div className="flex items-center gap-2 text-slate-400 text-sm">
<AlertCircle size={14} />
<span>Sin contenido.</span>
</div>
)}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { createFileRoute, useParams } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import {
FileText,
Download,
@@ -6,103 +6,40 @@ import {
ExternalLink,
CheckCircle2,
Clock,
FileJson,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { fetchPlanPdf } from '@/data/api/document.api'
FileJson
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
component: RouteComponent,
})
function RouteComponent() {
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const loadPdfPreview = useCallback(async () => {
try {
setIsLoading(true)
const pdfBlob = await fetchPlanPdf({ plan_estudio_id: planId })
const url = window.URL.createObjectURL(pdfBlob)
// Limpiar URL anterior si existe para evitar fugas de memoria
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
setPdfUrl(url)
} catch (error) {
console.error('Error cargando preview:', error)
} finally {
setIsLoading(false)
}
}, [planId])
useEffect(() => {
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
}
}, [loadPdfPreview])
const handleDownloadPdf = async () => {
try {
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
})
const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
link.href = url
link.download = 'plan_estudios.pdf'
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
} catch (error) {
console.error(error)
alert('No se pudo generar el PDF')
}
}
return (
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
<div className="flex flex-col gap-6 p-6 bg-slate-50/30 min-h-screen">
{/* HEADER DE ACCIONES */}
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-xl font-bold text-slate-800">
Documento del Plan
</h1>
<p className="text-muted-foreground text-sm">
Vista previa y descarga del documento oficial
</p>
<h1 className="text-xl font-bold text-slate-800">Documento del Plan</h1>
<p className="text-sm text-muted-foreground">Vista previa y descarga del documento oficial</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={loadPdfPreview}
>
<Button variant="outline" size="sm" className="gap-2">
<RefreshCcw size={16} /> Regenerar
</Button>
<Button variant="outline" size="sm" className="gap-2">
<Download size={16} /> Descargar Word
</Button>
<Button
size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={handleDownloadPdf}
>
<Button size="sm" className="gap-2 bg-teal-700 hover:bg-teal-800">
<Download size={16} /> Descargar PDF
</Button>
</div>
</div>
{/* TARJETAS DE ESTADO */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatusCard
icon={<CheckCircle2 className="text-green-500" />}
label="Estado"
@@ -121,42 +58,55 @@ function RouteComponent() {
</div>
{/* CONTENEDOR DEL DOCUMENTO (Visor) */}
{/* CONTENEDOR DEL VISOR REAL */}
<Card className="overflow-hidden border-slate-200 shadow-sm">
<div className="flex items-center justify-between border-b bg-slate-100/50 p-2 px-4">
<div className="flex items-center gap-2 text-xs font-medium text-slate-500">
<FileText size={14} /> Preview_Documento.pdf
<Card className="border-slate-200 shadow-sm overflow-hidden">
<div className="bg-slate-100/50 p-2 border-b flex justify-between items-center px-4">
<div className="flex items-center gap-2 text-xs text-slate-500 font-medium">
<FileText size={14} />
Plan_Estudios_ISC_2024.pdf
</div>
{pdfUrl && (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => window.open(pdfUrl, '_blank')}
>
<Button variant="ghost" size="sm" className="text-xs gap-1 h-7">
Abrir en nueva pestaña <ExternalLink size={12} />
</Button>
)}
</div>
<CardContent className="flex min-h-[800px] justify-center bg-slate-500 p-0">
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 text-white">
<RefreshCcw size={40} className="animate-spin opacity-50" />
<p className="animate-pulse">Generando vista previa del PDF...</p>
<CardContent className="p-0 bg-slate-200/50 flex justify-center py-8 min-h-[800px]">
{/* SIMULACIÓN DE HOJA DE PAPEL */}
<div className="bg-white w-full max-w-[800px] shadow-2xl p-12 md:p-16 min-h-[1000px] border relative">
{/* Contenido del Plan */}
<div className="text-center mb-12">
<p className="text-xs uppercase tracking-widest text-slate-400 font-bold mb-1">Universidad Tecnológica</p>
<h2 className="text-2xl font-bold text-slate-800">Plan de Estudios 2024</h2>
<h3 className="text-lg text-teal-700 font-semibold">Ingeniería en Sistemas Computacionales</h3>
<p className="text-xs text-slate-500 mt-1">Facultad de Ingeniería</p>
</div>
) : pdfUrl ? (
/* 3. VISOR DE PDF REAL */
<iframe
src={`${pdfUrl}#toolbar=0&navpanes=0`}
className="h-[1000px] w-full max-w-[1000px] border-none shadow-2xl"
title="PDF Preview"
/>
) : (
<div className="flex items-center justify-center p-20 text-slate-400">
No se pudo cargar la vista previa.
<div className="space-y-8 text-slate-700">
<section>
<h4 className="font-bold text-sm mb-2">1. Objetivo General</h4>
<p className="text-sm leading-relaxed text-justify">
Formar profesionales altamente capacitados en el desarrollo de soluciones tecnológicas innovadoras, con sólidos conocimientos en programación, bases de datos, redes y seguridad informática.
</p>
</section>
<section>
<h4 className="font-bold text-sm mb-2">2. Perfil de Ingreso</h4>
<p className="text-sm leading-relaxed text-justify">
Egresados de educación media superior con conocimientos básicos de matemáticas, razonamiento lógico y habilidades de comunicación. Interés por la tecnología y la resolución de problemas.
</p>
</section>
<section>
<h4 className="font-bold text-sm mb-2">3. Perfil de Egreso</h4>
<p className="text-sm leading-relaxed text-justify">
Profesional capaz de diseñar, desarrollar e implementar sistemas de software de calidad, administrar infraestructuras de red y liderar proyectos tecnológicos multidisciplinarios.
</p>
</section>
</div>
{/* Marca de agua o decoración lateral (opcional) */}
<div className="absolute top-0 left-0 w-1 h-full bg-slate-100" />
</div>
)}
</CardContent>
</Card>
</div>
@@ -164,23 +114,15 @@ function RouteComponent() {
}
// Componente pequeño para las tarjetas de estado superior
function StatusCard({
icon,
label,
value,
}: {
icon: React.ReactNode
label: string
value: string
}) {
function StatusCard({ icon, label, value }: { icon: React.ReactNode, label: string, value: string }) {
return (
<Card className="border-slate-200 bg-white">
<CardContent className="flex items-center gap-4 p-4">
<div className="rounded-full border bg-slate-50 p-2">{icon}</div>
<Card className="bg-white border-slate-200">
<CardContent className="p-4 flex items-center gap-4">
<div className="p-2 rounded-full bg-slate-50 border">
{icon}
</div>
<div>
<p className="text-[10px] font-bold tracking-tight text-slate-400 uppercase">
{label}
</p>
<p className="text-[10px] uppercase font-bold text-slate-400 tracking-tight">{label}</p>
<p className="text-sm font-semibold text-slate-700">{value}</p>
</div>
</CardContent>

Some files were not shown because too many files have changed in this diff Show More