Merge branch 'fix/merge' into feat/ai-generate-plan

This commit is contained in:
2026-01-21 16:03:22 -06:00
20 changed files with 3770 additions and 2079 deletions

268
bun.lock
View File

@@ -4,12 +4,9 @@
"": {
"name": "acad-ia-2",
"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-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
@@ -19,7 +16,6 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@stepperize/react": "^5.1.9",
"@supabase/supabase-js": "^2.90.1",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5",
@@ -30,7 +26,6 @@
"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",
"react": "^19.2.0",
@@ -38,14 +33,12 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0",
},
"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 +52,6 @@
"jsdom": "^27.0.0",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.2",
"supabase": "^2.72.2",
"typescript": "^5.7.2",
"vite": "^7.1.7",
"vitest": "^3.0.5",
@@ -68,7 +60,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=="],
@@ -76,23 +68,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=="],
@@ -100,25 +92,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=="],
@@ -128,7 +120,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.25", "", {}, "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q=="],
"@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=="],
@@ -230,8 +222,6 @@
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -248,21 +238,17 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@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-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=="],
"@radix-ui/react-compose-refs": ["@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-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@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": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
"@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=="],
@@ -270,8 +256,6 @@
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "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-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "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-menu": "2.1.16", "@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-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@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=="],
@@ -280,8 +264,6 @@
"@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-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=="],
@@ -290,12 +272,10 @@
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.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-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@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-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-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=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.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-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-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.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", "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-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@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=="],
@@ -398,19 +378,7 @@
"@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.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.52.0", "eslint-visitor-keys": "^5.0.0", "espree": "^11.0.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-PsSugIf9ip1H/mWKj4bi/BlEoerxXAda9ByRFsYuwsmr6af9NxJL0AaiNXs8Le7R21QR5KMiD/KdxZZ71LjAxQ=="],
"@supabase/auth-js": ["@supabase/auth-js@2.90.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng=="],
"@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.90.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ=="],
"@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.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.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=="],
"@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=="],
"@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=="],
@@ -468,19 +436,19 @@
"@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.147.3", "", { "dependencies": { "@tanstack/history": "1.145.7", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.147.1", "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-Fp9DoszYiIJclwxU43kyP/cqcWD418DPmV6yhmIOuVedsSMnfh2g7uRQ+bOoaWn996JjuU9yt/x48h66aCQSQA=="],
"@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.149.0", "", { "dependencies": { "@tanstack/router-devtools-core": "1.149.0" }, "peerDependencies": { "@tanstack/react-router": "^1.147.3", "@tanstack/router-core": "^1.147.1", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-QJ6epMhRKTS8WrBmcMFjK1v+jDaimMQuySCSNA8NR1ZROKv3xx0gY8AjyVVgQ1h78HSXXRMYH3aql2kWYjc31g=="],
"@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.147.1", "", { "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-yf8o3CNgJVGO5JnIqiTe0y2eChxEM0w7TrEs1VSumL/zz2bQroYGNr1mOXJ2VeN+7YfJJwjEqq71P5CzWwMzRg=="],
"@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.149.0", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "tiny-invariant": "^1.3.3" }, "peerDependencies": { "@tanstack/router-core": "^1.147.1", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-dy9xb8U9VWAavqKM0sTFhAs2ufVs3d/cGSbqczIgBcAKCjjbsAng1gV4ezPXmfF1pa+2MW6n7SViXsxxvtCRiw=="],
"@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.149.0", "", { "dependencies": { "@tanstack/router-core": "1.147.1", "@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-H+SZbJ9j4G+y/329LlRLb//4sBdPNQpuMddb/rgkfoRZpdztm9Ejm9EEbMJB0rkNDrSgfSPOZ6VtJbndYH/AQA=="],
"@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.149.0", "", { "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.147.1", "@tanstack/router-generator": "1.149.0", "@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.147.3", "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-DYPScneHZ0fm3FJyDhkUCW0w0dOopAKvep57n/Ft2b3RrHSeSeJB/cJWgsUvpcYJfpywkyOLyqVLMoiDvLoG/A=="],
"@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.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=="],
@@ -504,8 +472,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/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=="],
@@ -516,35 +482,31 @@
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
"@types/node": ["@types/node@22.19.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q=="],
"@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.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="],
"@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.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/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/type-utils": "8.53.0", "@typescript-eslint/utils": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg=="],
"@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/parser": ["@typescript-eslint/parser@8.53.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg=="],
"@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/project-service": ["@typescript-eslint/project-service@8.53.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.53.0", "@typescript-eslint/types": "^8.53.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg=="],
"@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/scope-manager": ["@typescript-eslint/scope-manager@8.53.0", "", { "dependencies": { "@typescript-eslint/types": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0" } }, "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g=="],
"@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/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.53.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA=="],
"@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/type-utils": ["@typescript-eslint/type-utils@8.53.0", "", { "dependencies": { "@typescript-eslint/types": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0", "@typescript-eslint/utils": "8.53.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-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.52.0", "", {}, "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.53.0", "", {}, "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ=="],
"@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/typescript-estree": ["@typescript-eslint/typescript-estree@8.53.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.53.0", "@typescript-eslint/tsconfig-utils": "8.53.0", "@typescript-eslint/types": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.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-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw=="],
"@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/utils": ["@typescript-eslint/utils@8.53.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.53.0", "", { "dependencies": { "@typescript-eslint/types": "8.53.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw=="],
"@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=="],
@@ -644,7 +606,7 @@
"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=="],
@@ -652,12 +614,10 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.14", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg=="],
"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=="],
"bin-links": ["bin-links@6.0.0", "", { "dependencies": { "cmd-shim": "^8.0.0", "npm-normalize-package-bin": "^5.0.0", "proc-log": "^6.0.0", "read-cmd-shim": "^6.0.0", "write-file-atomic": "^7.0.0" } }, "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
@@ -666,8 +626,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=="],
@@ -678,7 +636,7 @@
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001764", "", {}, "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g=="],
"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=="],
@@ -688,14 +646,10 @@
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cmd-shim": ["cmd-shim@8.0.0", "", {}, "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA=="],
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -714,14 +668,12 @@
"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=="],
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"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=="],
@@ -730,8 +682,6 @@
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
@@ -750,7 +700,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=="],
@@ -818,9 +768,9 @@
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.0", "", {}, "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["espree@11.0.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-+gMeWRrIh/NsG+3NaLeWHuyeyk70p2tbvZIWBYcqQ4/7Xvars6GYTZNhF1sIeLcc6Wb11He5ffz3hsHyXFrw5A=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
@@ -844,8 +794,6 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -858,9 +806,7 @@
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"framer-motion": ["framer-motion@12.26.1", "", { "dependencies": { "motion-dom": "^12.24.11", "motion-utils": "^12.24.10", "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-Uzc8wGldU4FpmGotthjjcj0SZhigcODjqvKT7lzVZHsmYkzQMFfMIv0vHQoXCeoe/Ahxqp4by4A6QbzFA/lblw=="],
"framer-motion": ["framer-motion@12.24.7", "", { "dependencies": { "motion-dom": "^12.24.3", "motion-utils": "^12.23.28", "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-EolFLm7NdEMhWO/VTMZ0LlR4fLHGDiJItTx3i8dlyQooOOBoYAaysK4paGD4PrwqnoDdeDOS+TxnSBIAnNHs3w=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -916,14 +862,12 @@
"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=="],
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -1070,15 +1014,11 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"motion": ["motion@12.24.7", "", { "dependencies": { "framer-motion": "^12.24.7", "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-0jOoqFlQ7JBvAcRhRv28pwUgZ1xw9WS4+tCU6aqYjxgiNVZCVi34ED2cihW3EgjIIWPBoZJis5og1mx/LmQWVQ=="],
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"motion-dom": ["motion-dom@12.24.3", "", { "dependencies": { "motion-utils": "^12.23.28" } }, "sha512-ZjMZCwhTglim0LM64kC1iFdm4o+2P9IKk3rl/Nb4RKsb5p4O9HJ1C2LWZXOFdsRtp6twpqWRXaFKOduF30ntow=="],
"motion": ["motion@12.26.1", "", { "dependencies": { "framer-motion": "^12.26.1", "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-IVhzx9HOQTiJ9ykthMOlZPnLwrkXziN5Q/yebsqBYlFJb2rHP8yhmKc8O/YUT9byPJlxOeqkzfNYCrVKZx8vqg=="],
"motion-dom": ["motion-dom@12.24.11", "", { "dependencies": { "motion-utils": "^12.24.10" } }, "sha512-DlWOmsXMJrV8lzZyd+LKjG2CXULUs++bkq8GZ2Sr0R0RRhs30K2wtY+LKiTjhmJU3W61HK+rB0GLz6XmPvTA1A=="],
"motion-utils": ["motion-utils@12.24.10", "", {}, "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww=="],
"motion-utils": ["motion-utils@12.23.28", "", {}, "sha512-0W6cWd5Okoyf8jmessVK3spOmbyE0yTdNKujHctHH9XdAE4QDuZ1/LjSXC68rrhsJU+TkzXURC5OdSWh9ibOwQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -1088,16 +1028,10 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"npm-normalize-package-bin": ["npm-normalize-package-bin@5.0.0", "", {}, "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
@@ -1148,8 +1082,6 @@
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
@@ -1166,8 +1098,6 @@
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.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-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"read-cmd-shim": ["read-cmd-shim@6.0.0", "", {}, "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
@@ -1224,8 +1154,6 @@
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"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=="],
@@ -1254,8 +1182,6 @@
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
"supabase": ["supabase@2.72.4", "", { "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-7jbpr9svviXihYhUqBK7k7U3aRo4x8OVSkzxrl+cpH4svDS5+Rl605DW9ijYFeRuNQZEvBkRaJQ93bGORWNFqQ=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
@@ -1268,8 +1194,6 @@
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"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=="],
"tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
@@ -1320,7 +1244,7 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.53.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.53.0", "@typescript-eslint/parser": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0", "@typescript-eslint/utils": "8.53.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw=="],
"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=="],
@@ -1336,13 +1260,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=="],
"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=="],
@@ -1352,8 +1274,6 @@
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"web-vitals": ["web-vitals@5.1.0", "", {}, "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
@@ -1378,15 +1298,13 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"write-file-atomic": ["write-file-atomic@7.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
@@ -1398,34 +1316,64 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@radix-ui/react-alert-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-arrow/@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-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
"@radix-ui/react-checkbox/@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-avatar/@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-checkbox/@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-collection/@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-collection/@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-collection/@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-dialog/@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-dialog/@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-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-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-dismissable-layer/@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-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=="],
"@radix-ui/react-focus-scope/@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-popover/@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-popover/@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-popover/@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-primitive/@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-popper/@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-popper/@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-portal/@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-roving-focus/@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-roving-focus/@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-select/@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-select/@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-select/@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-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-tabs/@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-tabs/@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-tooltip/@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-tooltip/@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-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=="],
"@radix-ui/react-visually-hidden/@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=="],
"@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=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -1446,20 +1394,12 @@
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"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=="],
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"eslint/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"eslint/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"eslint-compat-utils/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -1492,15 +1432,25 @@
"tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"vue-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"vue-eslint-parser/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"vue-eslint-parser/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive/@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=="],
"@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"@radix-ui/react-checkbox/@radix-ui/react-primitive/@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-dismissable-layer/@radix-ui/react-primitive/@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-focus-scope/@radix-ui/react-primitive/@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-popper/@radix-ui/react-primitive/@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-portal/@radix-ui/react-primitive/@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-roving-focus/@radix-ui/react-primitive/@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-tabs/@radix-ui/react-primitive/@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-visually-hidden/@radix-ui/react-primitive/@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=="],
"@tanstack/devtools/@tanstack/devtools-client/@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.3.5", "", {}, "sha512-RL1f5ZlfZMpghrCIdzl6mLOFLTuhqmPNblZgBaeKfdtk5rfbjykurv+VfYydOFXj0vxVIoA2d/zT7xfD7Ph8fw=="],

View File

@@ -1,14 +1,44 @@
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 { useEffect, 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 { useSubjectBibliografia } from '@/data/hooks/useSubjects'
//import { toast } from 'sonner';
//import { mockLibraryResources } from '@/data/mockMateriaData';
@@ -20,7 +50,7 @@ export const mockLibraryResources = [
editorial: 'MIT Press',
anio: 2016,
isbn: '9780262035613',
disponible: true
disponible: true,
},
{
id: 'lib-2',
@@ -29,102 +59,154 @@ export const mockLibraryResources = [
editorial: 'Pearson',
anio: 2020,
isbn: '9780134610993',
disponible: true
disponible: true,
},
{
id: 'lib-3',
titulo: 'Hands-On Machine Learning',
autor: 'Aurélien Géron',
editorial: 'O\'Reilly Media',
editorial: "O'Reilly Media",
anio: 2019,
isbn: '9781492032649',
disponible: false
}
];
disponible: false,
},
]
// --- Interfaces ---
export interface BibliografiaEntry {
id: string;
tipo: 'BASICA' | 'COMPLEMENTARIA';
cita: string;
fuenteBibliotecaId?: string;
fuenteBiblioteca?: any;
id: string
tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string
tipo_fuente?: 'MANUAL' | 'BIBLIOTECA'
biblioteca_item_id?: string | null
fuenteBibliotecaId?: string
fuenteBiblioteca?: any
}
interface BibliografiaTabProps {
bibliografia: BibliografiaEntry[];
onSave: (bibliografia: BibliografiaEntry[]) => void;
isSaving: boolean;
bibliografia: BibliografiaEntry[]
onSave: (bibliografia: BibliografiaEntry[]) => void
isSaving: boolean
}
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');
export function BibliographyItem({
bibliografia,
onSave,
isSaving,
}: BibliografiaTabProps) {
const { data: bibliografia2, isLoading: loadinmateria } =
useSubjectBibliografia('9d4dda6a-488f-428a-8a07-38081592a641')
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',
)
const basicaEntries = entries.filter(e => e.tipo === 'BASICA');
const complementariaEntries = entries.filter(e => e.tipo === 'COMPLEMENTARIA');
useEffect(() => {
if (bibliografia2 && Array.isArray(bibliografia2)) {
setEntries(bibliografia2)
} else if (bibliografia) {
// Fallback a la prop inicial si la API no devuelve nada
setEntries(bibliografia)
}
}, [bibliografia2, bibliografia])
const basicaEntries = entries.filter((e) => e.tipo === 'BASICA')
const complementariaEntries = entries.filter(
(e) => e.tipo === 'COMPLEMENTARIA',
)
console.log(bibliografia2)
const handleAddManual = (cita: string) => {
const newEntry: BibliografiaEntry = { id: `manual-${Date.now()}`, tipo: newEntryType, cita };
setEntries([...entries, newEntry]);
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}.`;
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,
fuenteBibliotecaId: resource.id,
fuenteBiblioteca: resource,
};
setEntries([...entries, newEntry]);
setIsLibraryDialogOpen(false);
}
setEntries([...entries, newEntry])
setIsLibraryDialogOpen(false)
//toast.success('Añadido desde biblioteca');
};
}
const handleUpdateCita = (id: string, cita: string) => {
setEntries(entries.map(e => e.id === id ? { ...e, cita } : e));
};
setEntries(entries.map((e) => (e.id === id ? { ...e, cita } : e)))
}
return (
<div className="max-w-5xl mx-auto py-10 space-y-8 animate-in fade-in duration-500">
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500">
<div className="flex items-center justify-between border-b pb-4">
<div>
<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
<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
</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="w-4 h-4 mr-2" /> Buscar en biblioteca
<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>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<LibrarySearchDialog onSelect={handleAddFromLibrary} existingIds={entries.map(e => e.fuenteBibliotecaId || '')} />
<LibrarySearchDialog
onSelect={handleAddFromLibrary}
existingIds={entries.map((e) => e.fuenteBibliotecaId || '')}
/>
</DialogContent>
</Dialog>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline"><Plus className="w-4 h-4 mr-2" /> Añadir manual</Button>
<Button variant="outline">
<Plus className="mr-2 h-4 w-4" /> 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
onClick={() => onSave(entries)}
disabled={isSaving}
className="bg-blue-600 hover:bg-blue-700"
>
<Save className="mr-2 h-4 w-4" />{' '}
{isSaving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</div>
@@ -133,11 +215,13 @@ export function BibliographyItem({ bibliografia, onSave, isSaving }: Bibliografi
{/* BASICA */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<div className="h-4 w-1 bg-blue-600 rounded-full" />
<h3 className="font-semibold text-slate-800">Bibliografía Básica</h3>
<div className="h-4 w-1 rounded-full bg-blue-600" />
<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}
@@ -154,11 +238,13 @@ export function BibliographyItem({ bibliografia, onSave, isSaving }: Bibliografi
{/* COMPLEMENTARIA */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<div className="h-4 w-1 bg-slate-400 rounded-full" />
<h3 className="font-semibold text-slate-800">Bibliografía Complementaria</h3>
<div className="h-4 w-1 rounded-full bg-slate-400" />
<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}
@@ -177,70 +263,143 @@ export function BibliographyItem({ bibliografia, onSave, isSaving }: Bibliografi
<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={() => { setEntries(entries.filter(e => e.id !== deleteId)); setDeleteId(null); }} 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("w-5 h-5 mt-1", entry.tipo === 'BASICA' ? "text-blue-600" : "text-slate-400")} />
<div className="flex-1 min-w-0">
<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">
{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="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 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>
)}
</div>
)}
</div>
{!isEditing && (
<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 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>
)}
</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 uppercase text-slate-500">Tipo</label>
<label className="text-xs font-bold text-slate-500 uppercase">
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>
@@ -248,44 +407,78 @@ function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
</Select>
</div>
<div className="space-y-2">
<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]" />
<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]"
/>
</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({ 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())
);
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 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" />
<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"
/>
</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] 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 className="max-h-[300px] space-y-2 overflow-y-auto pr-2">
{filtered.map((res) => (
<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>
<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="w-4 h-4 text-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
<Plus className="h-4 w-4 text-blue-600 opacity-0 transition-opacity group-hover:opacity-100" />
</div>
))}
</div>
</div>
);
)
}

View File

@@ -1,10 +1,23 @@
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 { useEffect, 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,
@@ -14,24 +27,22 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
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: Tema[];
id: string
nombre: string
numero: number
temas: Tema[]
}
const initialData: UnidadTematica[] = [
@@ -42,152 +53,297 @@ const initialData: UnidadTematica[] = [
temas: [
{ id: 't1', nombre: 'Tipos de IA y aplicaciones', horasEstimadas: 6 },
{ id: 't2', nombre: 'Ética en IA', horasEstimadas: 3 },
]
}
];
],
},
]
export function ContenidoTematico() {
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);
// Estructura que viene de tu JSON/API
interface ContenidoApi {
unidad: number
titulo: string
temas: string[] | any[] // Acepta strings o objetos
[key: string]: any // Esta línea permite que haya más claves desconocidas
}
// Props del componente
interface ContenidoTematicoProps {
data: {
contenido_tematico: ContenidoApi[]
}
isLoading: boolean
}
export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
const [unidades, setUnidades] = useState<UnidadTematica[]>([])
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)
useEffect(() => {
if (data?.contenido_tematico) {
const transformed = data.contenido_tematico.map(
(u: any, idx: number) => ({
id: `u-${idx}`,
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-${idx}-${tidx}`,
nombre: typeof t === 'string' ? t : t.nombre || 'Tema',
horasEstimadas: t.horasEstimadas || 0,
}))
: [],
}),
)
setUnidades(transformed)
// Expandir la primera unidad automáticamente
if (transformed.length > 0) {
setExpandedUnits(new Set([transformed[0].id]))
}
}
}, [data])
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,
)
// --- 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 newId = `u-${Date.now()}`;
const newId = `u-${Date.now()}`
const newUnidad: UnidadTematica = {
id: newId,
nombre: 'Nueva Unidad',
numero: unidades.length + 1,
temas: [],
};
setUnidades([...unidades, newUnidad]);
setExpandedUnits(new Set([...expandedUnits, newId]));
setEditingUnit(newId);
};
}
setUnidades([...unidades, newUnidad])
setExpandedUnits(new Set([...expandedUnits, newId]))
setEditingUnit(newId)
}
const updateUnidadNombre = (id: string, nombre: string) => {
setUnidades(unidades.map(u => u.id === id ? { ...u, nombre } : u));
};
setUnidades(unidades.map((u) => (u.id === id ? { ...u, nombre } : u)))
}
// --- Lógica de Temas ---
const addTema = (unidadId: string) => {
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;
}));
};
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 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 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;
if (!deleteDialog) return
if (deleteDialog.type === 'unidad') {
setUnidades(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) {
setUnidades(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,
),
)
}
setDeleteDialog(null);
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="max-w-5xl mx-auto py-10 space-y-6 animate-in fade-in duration-500">
<div className="animate-in fade-in mx-auto max-w-5xl space-y-6 py-10 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="text-sm text-slate-500 mt-1">
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
Contenido Temático
</h2>
<p className="mt-1 text-sm text-slate-500">
{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
<Plus className="h-4 w-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
onClick={() => {
setIsSaving(true)
setTimeout(() => {
setIsSaving(false) /*toast.success("Guardado")*/
}, 1000)
}}
disabled={isSaving}
className="bg-blue-600 hover:bg-blue-700"
>
<Save className="mr-2 h-4 w-4" />{' '}
{isSaving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</div>
<div className="space-y-4">
{unidades.map((unidad) => (
<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">
<Card
key={unidad.id}
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">
<div className="flex items-center gap-3">
<GripVertical className="w-4 h-4 text-slate-300 cursor-grab" />
<GripVertical className="h-4 w-4 cursor-grab text-slate-300" />
<CollapsibleTrigger asChild>
<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 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>
</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
value={unidad.nombre}
onChange={(e) => updateUnidadNombre(unidad.id, e.target.value)}
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"
onKeyDown={(e) =>
e.key === 'Enter' && setEditingUnit(null)
}
className="h-8 max-w-md bg-white"
autoFocus
/>
) : (
<CardTitle className="text-base font-semibold cursor-pointer hover:text-blue-600 transition-colors" onClick={() => setEditingUnit(unidad.id)}>
<CardTitle
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
onClick={() => setEditingUnit(unidad.id)}
>
{unidad.nombre}
</CardTitle>
)}
<div className="ml-auto flex items-center gap-3">
<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 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>
<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
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>
</div>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="pt-4 bg-white">
<div className="space-y-1 ml-10 border-l-2 border-slate-50 pl-4">
<CardContent className="bg-white pt-4">
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
{unidad.temas.map((tema, idx) => (
<TemaRow
key={tema.id}
tema={tema}
index={idx + 1}
isEditing={editingTema?.unitId === unidad.id && editingTema?.temaId === tema.id}
onEdit={() => setEditingTema({ unitId: unidad.id, temaId: tema.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 })}
onUpdate={(updates) =>
updateTema(unidad.id, tema.id, updates)
}
onDelete={() =>
setDeleteDialog({
type: 'tema',
id: tema.id,
parentId: unidad.id,
})
}
/>
))}
<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
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>
</div>
</CardContent>
@@ -197,81 +353,137 @@ export function ContenidoTematico() {
))}
</div>
<DeleteConfirmDialog dialog={deleteDialog} setDialog={setDeleteDialog} onConfirm={handleDelete} />
<DeleteConfirmDialog
dialog={deleteDialog}
setDialog={setDeleteDialog}
onConfirm={handleDelete}
/>
</div>
);
)
}
// --- Componentes Auxiliares ---
interface TemaRowProps {
tema: Tema;
index: number;
isEditing: boolean;
onEdit: () => void;
onStopEditing: () => void;
onUpdate: (updates: Partial<Tema>) => 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, onEdit, onStopEditing, onUpdate, onDelete }: TemaRowProps) {
function TemaRow({
tema,
index,
isEditing,
onEdit,
onStopEditing,
onUpdate,
onDelete,
}: TemaRowProps) {
return (
<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>
<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>
{isEditing ? (
<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 className="animate-in slide-in-from-left-2 flex flex-1 items-center gap-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="h-8 bg-emerald-600"
onClick={onStopEditing}
>
Listo
</Button>
</div>
) : (
<>
<div className="flex-1 cursor-pointer" onClick={onEdit}>
<p className="text-sm font-medium text-slate-700">{tema.nombre}</p>
</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>
<Badge variant="secondary" className="text-[10px] opacity-60">
{tema.horasEstimadas}h
</Badge>
<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={onEdit}
>
<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={onDelete}
>
<Trash2 className="h-3 w-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 hover:bg-red-700 text-white">Eliminar</AlertDialogAction>
<AlertDialogAction
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
)
}

View File

@@ -1,8 +1,16 @@
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 { 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,
@@ -13,63 +21,88 @@ import {
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import type { DocumentoMateria, Materia, MateriaStructure } from '@/types/materia';
import { cn } from '@/lib/utils';
} from '@/components/ui/alert-dialog'
import type {
DocumentoMateria,
Materia,
MateriaStructure,
} from '@/types/materia'
import { cn } from '@/lib/utils'
import { useSubjectBibliografia } from '@/data/hooks/useSubjects'
//import { toast } from 'sonner';
//import { format } from 'date-fns';
//import { es } from 'date-fns/locale';
interface DocumentoSEPTabProps {
documento: DocumentoMateria | null;
materia: Materia;
estructura: MateriaStructure;
datosGenerales: Record<string, any>;
onRegenerate: () => void;
isRegenerating: boolean;
documento: DocumentoMateria | null
materia: Materia
estructura: MateriaStructure
datosGenerales: Record<string, any>
onRegenerate: () => void
isRegenerating: boolean
}
export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales, 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 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="space-y-6 animate-fade-in">
<div className="animate-fade-in space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
<FileCheck className="w-6 h-6 text-accent" />
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
<FileCheck className="text-accent h-6 w-6" />
Documento SEP
</h2>
<p className="text-sm text-muted-foreground mt-1">
<p className="text-muted-foreground mt-1 text-sm">
Previsualización del documento oficial para la SEP
</p>
</div>
<div className="flex items-center gap-2">
{documento?.estado === 'listo' && (
<Button variant="outline" onClick={() => console.log("descargando") /*toast.info('Descarga iniciada')*/}>
<Download className="w-4 h-4 mr-2" />
<Button
variant="outline"
onClick={
() =>
console.log('descargando') /*toast.info('Descarga iniciada')*/
}
>
<Download className="mr-2 h-4 w-4" />
Descargar
</Button>
)}
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialog
open={showConfirmDialog}
onOpenChange={setShowConfirmDialog}
>
<AlertDialogTrigger asChild>
<Button disabled={isRegenerating || !isComplete}>
{isRegenerating ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
<RefreshCw className="mr-2 h-4 w-4" />
)}
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
</Button>
@@ -78,8 +111,9 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
<AlertDialogHeader>
<AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle>
<AlertDialogDescription>
Se creará una nueva versión del documento con los datos actuales de la materia.
La versión anterior quedará en el historial.
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>
@@ -93,18 +127,18 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 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">
<div className="bg-muted/30 flex h-full 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">
<FileText className="text-primary h-5 w-5" />
<span className="text-foreground font-medium">
Programa de Estudios - {materia.clave}
</span>
</div>
@@ -114,70 +148,87 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
{/* 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">
<div className="bg-card mx-auto max-w-2xl space-y-6 rounded-lg p-8 shadow-lg">
{/* Header */}
<div className="text-center border-b pb-6">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">
<div className="border-b pb-6 text-center">
<p className="text-muted-foreground mb-2 text-xs tracking-wide uppercase">
Secretaría de Educación Pública
</p>
<h1 className="font-display text-2xl font-bold text-primary mb-1">
<h1 className="font-display text-primary mb-1 text-2xl font-bold">
{materia.nombre}
</h1>
<p className="text-sm text-muted-foreground">
Clave: {materia.clave} | Créditos: {materia.creditos || 'N/A'}
<p className="text-muted-foreground text-sm">
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>}
<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;
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">
<h3 className="text-foreground border-b pb-1 font-semibold">
{campo.nombre}
</h3>
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
<p className="text-foreground text-sm leading-relaxed whitespace-pre-wrap">
{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>
<div className="text-muted-foreground mt-8 border-t pt-6 text-center text-xs">
<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="flex h-full 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>
<Loader2 className="text-accent mx-auto mb-4 h-12 w-12 animate-spin" />
<p className="text-muted-foreground">
Generando documento...
</p>
</div>
</div>
) : (
<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" />
<div className="flex h-full items-center justify-center">
<div className="max-w-sm text-center">
<FileText className="text-muted-foreground/50 mx-auto mb-4 h-12 w-12" />
<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 className="bg-warning/10 text-warning-foreground rounded-lg p-4 text-sm">
<AlertTriangle className="mr-2 inline h-4 w-4" />
Completa todos los campos obligatorios para generar el
documento
</div>
)}
</div>
@@ -191,28 +242,41 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
{/* Status */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Estado del documento</CardTitle>
<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>
<span className="text-muted-foreground text-sm">
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-muted-foreground text-sm">
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"
)}>
<span className="text-muted-foreground text-sm">
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'}
@@ -226,44 +290,60 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
{/* Completeness */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Completitud de datos</CardTitle>
<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>
<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="bg-muted h-2 overflow-hidden rounded-full">
<div
className={cn(
"h-full transition-all duration-500",
completeness === 100 ? "bg-success" : "bg-accent"
'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"
)}>
<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`
}
: `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>
))}
<p className="text-muted-foreground text-xs font-medium">
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="text-warning h-3 w-3" />
<span className="text-foreground">{campo.nombre}</span>
</div>
))}
</div>
)}
</CardContent>
@@ -272,36 +352,62 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
{/* Requirements */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Requisitos SEP</CardTitle>
<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
className={cn(
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
datosGenerales['objetivo_general']
? 'bg-success/20'
: 'bg-muted',
)}
>
{datosGenerales['objetivo_general'] && (
<Check className="text-success h-3 w-3" />
)}
</div>
<span className="text-muted-foreground">Objetivo general definido</span>
<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
className={cn(
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
datosGenerales['competencias']
? 'bg-success/20'
: 'bg-muted',
)}
>
{datosGenerales['competencias'] && (
<Check className="text-success h-3 w-3" />
)}
</div>
<span className="text-muted-foreground">Competencias especificadas</span>
<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
className={cn(
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
datosGenerales['evaluacion']
? 'bg-success/20'
: 'bg-muted',
)}
>
{datosGenerales['evaluacion'] && (
<Check className="text-success h-3 w-3" />
)}
</div>
<span className="text-muted-foreground">Criterios de evaluación</span>
<span className="text-muted-foreground">
Criterios de evaluación
</span>
</li>
</ul>
</CardContent>
@@ -309,13 +415,19 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
</div>
</div>
</div>
);
)
}
function Check({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<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,196 +1,354 @@
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 { useState, useMemo } from 'react'
import {
History,
FileText,
List,
BookMarked,
Sparkles,
FileCheck,
User,
Filter,
Calendar,
Loader2,
Eye,
} 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 type { CambioMateria } from '@/types/materia';
import { cn } from '@/lib/utils';
import { format, formatDistanceToNow } from 'date-fns';
import { es } from 'date-fns/locale';
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { format, parseISO } from 'date-fns'
import { es } from 'date-fns/locale'
import { useSubjectHistorial } from '@/data/hooks/useSubjects'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
interface HistorialTabProps {
historial: CambioMateria[];
}
const tipoConfig: Record<string, { label: string; icon: any; 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',
},
ia: { label: 'IA', icon: Sparkles, color: 'text-amber-500' },
documento: {
label: 'Documento SEP',
icon: FileCheck,
color: 'text-primary',
},
}
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' },
ia: { label: 'IA', icon: Sparkles, color: 'text-amber-500' },
documento: { label: 'Documento SEP', icon: FileCheck, color: 'text-primary' },
};
export function HistorialTab() {
// 1. Obtenemos los datos directamente dentro del componente
const { data: rawData, isLoading } = useSubjectHistorial(
'9d4dda6a-488f-428a-8a07-38081592a641',
)
export function HistorialTab({ historial }: HistorialTabProps) {
const [filtros, setFiltros] = useState<Set<string>>(new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']));
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)
}
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)
}
const filteredHistorial = historial.filter(cambio => filtros.has(cambio.tipo));
// 3. Aplicamos filtros y agrupamiento sobre los datos transformados
const filteredHistorial = historialTransformado.filter((cambio) =>
filtros.has(cambio.tipo),
)
// 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 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, any[]>,
)
const sortedDates = Object.keys(groupedHistorial).sort((a, b) => b.localeCompare(a));
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>
)
}
return (
<div className="space-y-6 animate-fade-in">
<div className="animate-fade-in space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
<History className="w-6 h-6 text-accent" />
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
<History className="text-accent h-6 w-6" />
Historial de cambios
</h2>
<p className="text-sm text-muted-foreground mt-1">
{historial.length} cambios registrados
<p className="text-muted-foreground mt-1 text-sm">
{historialTransformado.length} cambios registrados
</p>
</div>
{/* Dropdown de Filtros (Igual al anterior) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Filter className="w-4 h-4 mr-2" />
<Filter className="mr-2 h-4 w-4" />
Filtrar ({filtros.size})
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{Object.entries(tipoConfig).map(([tipo, config]) => {
const Icon = config.icon;
return (
<DropdownMenuCheckboxItem
key={tipo}
checked={filtros.has(tipo)}
onCheckedChange={() => toggleFiltro(tipo)}
>
<Icon className={cn("w-4 h-4 mr-2", config.color)} />
{config.label}
</DropdownMenuCheckboxItem>
);
})}
{Object.entries(tipoConfig).map(([tipo, config]) => (
<DropdownMenuCheckboxItem
key={tipo}
checked={filtros.has(tipo)}
onCheckedChange={() => toggleFiltro(tipo)}
>
<config.icon className={cn('mr-2 h-4 w-4', config.color)} />
{config.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{filteredHistorial.length === 0 ? (
<Card className="card-elevated">
<Card>
<CardContent className="py-12 text-center">
<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>
<History className="text-muted-foreground/50 mx-auto mb-4 h-12 w-12" />
<p className="text-muted-foreground">No se encontraron cambios.</p>
</CardContent>
</Card>
) : (
<div className="space-y-8">
{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;
{sortedDates.map((dateKey) => (
<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,
})}
</h3>
</div>
return (
<div key={dateKey}>
{/* 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>
{/* 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">
{/* 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(
"p-2 rounded-lg bg-muted flex-shrink-0",
config.color
)}>
<Icon className="w-4 h-4" />
<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
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}`,
)}
/>
<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>
<div className="flex-1">
<div className="flex justify-between">
<p className="font-medium">
{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">
{format(cambio.fecha, 'HH:mm')}
</span>
</div>
<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>
<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="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 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}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
);
})}
</div>
</div>
</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,161 +1,260 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { ArrowLeft, GraduationCap, Pencil, Sparkles } from 'lucide-react'
import { useCallback, useState, useEffect } from 'react'
import { BibliographyItem } from './BibliographyItem'
import { ContenidoTematico } from './ContenidoTematico'
import { DocumentoSEPTab } from './DocumentoSEPTab'
import { HistorialTab } from './HistorialTab'
import { IAMateriaTab } from './IAMateriaTab'
import type { CampoEstructura, IAMessage, IASugerencia } from '@/types/materia'
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
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 { useSubject } from '@/data/hooks/useSubjects'
import {
mockMateria,
mockEstructura,
mockDocumentoSep,
mockHistorial
} from '@/data/mockMateriaData';
import { DocumentoSEPTab } from './DocumentoSEPTab'
import { HistorialTab } from './HistorialTab'
} from '@/data/mockMateriaData'
export interface BibliografiaEntry {
id: string;
tipo: 'BASICA' | 'COMPLEMENTARIA';
cita: string;
fuenteBibliotecaId?: string;
fuenteBiblioteca?: any;
id: string
tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string
fuenteBibliotecaId?: string
fuenteBiblioteca?: any
}
export interface BibliografiaTabProps {
bibliografia: BibliografiaEntry[];
onSave: (bibliografia: BibliografiaEntry[]) => void;
isSaving: boolean;
bibliografia: Array<BibliografiaEntry>
onSave: (bibliografia: Array<BibliografiaEntry>) => void
isSaving: boolean
}
export interface AsignaturaDatos {
[key: string]: string
}
export interface AsignaturaResponse {
datos: AsignaturaDatos
}
function EditableHeaderField({
value,
onSave,
className,
}: {
value: string | number
onSave: (val: string) => void
className?: string
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
;(e.currentTarget as HTMLElement).blur() // Quita el foco
}
}
const handleBlur = (e: React.FocusEvent<HTMLElement>) => {
const newValue = e.currentTarget.textContent || ''
if (newValue !== value.toString()) {
onSave(newValue)
}
}
return (
<span
contentEditable
suppressContentEditableWarning
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className={`cursor-text rounded px-1 transition-all outline-none focus:ring-2 focus:ring-blue-400 ${className}`}
>
{value}
</span>
)
}
export default function MateriaDetailPage() {
const routerState = useRouterState()
const state = routerState.location.state as any
const { data: asignaturasApi, isLoading: loadingAsig } = useSubject(
state?.realId,
)
// 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[]>([]);
const [messages, setMessages] = useState<Array<IAMessage>>([])
const [datosGenerales, setDatosGenerales] = useState({})
const [campos, setCampos] = useState<Array<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]);
// Dentro de MateriaDetailPage
const [headerData, setHeaderData] = useState({
codigo: '',
nombre: '',
creditos: 0,
ciclo: 0,
})
// Aquí llamarías a tu API de OpenAI/Claude
//toast.info("Enviando consulta a la IA...");
};
// Sincronizar cuando llegue la API
useEffect(() => {
if (asignaturasApi) {
setHeaderData({
codigo: asignaturasApi?.codigo ?? '',
nombre: asignaturasApi?.nombre ?? '',
creditos: asignaturasApi?.creditos ?? '',
ciclo: asignaturasApi?.numero_ciclo ?? 0,
})
}
}, [asignaturasApi])
const handleAcceptSuggestion = (sugerencia: IASugerencia) => {
// Lógica para actualizar el valor del campo en tu estado de datosGenerales
//toast.success(`Sugerencia aplicada a ${sugerencia.campoNombre}`);
};
const handleUpdateHeader = (key: string, value: string | number) => {
const newData = { ...headerData, [key]: value }
setHeaderData(newData)
console.log('💾 Guardando en estado y base de datos:', key, value)
}
/* ---------- sincronizar API ---------- */
useEffect(() => {
if (asignaturasApi?.datos) {
setDatosGenerales(asignaturasApi.datos)
}
}, [asignaturasApi])
// 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 [bibliografia, setBibliografia] = useState<Array<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);
const handleSaveBibliografia = (data: Array<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);
// Simulamos un guardado
setTimeout(() => {
setIsRegenerating(false);
}, 2000);
}, []);
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 ================= */}
{/* ================= HEADER ACTUALIZADO ================= */}
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<div className="max-w-7xl mx-auto px-6 py-10">
<div className="mx-auto max-w-7xl px-6 py-10">
<Link
to="/planes"
className="flex items-center gap-2 text-sm text-blue-200 hover:text-white mb-4"
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white"
>
<ArrowLeft className="w-4 h-4" />
Volver al plan
<ArrowLeft className="h-4 w-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
{/* CÓDIGO EDITABLE */}
<Badge className="border border-blue-700 bg-blue-900/50">
<EditableHeaderField
value={headerData.codigo}
onSave={(val) => handleUpdateHeader('codigo', val)}
/>
</Badge>
{/* NOMBRE EDITABLE */}
<h1 className="text-3xl font-bold">
Inteligencia Artificial Aplicada
<EditableHeaderField
value={headerData.nombre}
onSave={(val) => handleUpdateHeader('nombre', val)}
/>
</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
<GraduationCap className="h-4 w-4" />
{asignaturasApi?.planes_estudio?.datos?.nombre}
</span>
<span>
{asignaturasApi?.planes_estudio?.carreras?.facultades?.nombre}
</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 className="cursor-pointer underline">
{asignaturasApi?.planes_estudio?.nombre}
</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 className="flex flex-col items-end gap-2 text-right">
{/* CRÉDITOS EDITABLES */}
<Badge variant="secondary" className="gap-1">
<EditableHeaderField
value={headerData.creditos}
onSave={(val) =>
handleUpdateHeader('creditos', parseInt(val) || 0)
}
/>
<span>créditos</span>
</Badge>
{/* SEMESTRE EDITABLE */}
<Badge variant="secondary" className="gap-1">
<EditableHeaderField
value={headerData.ciclo}
onSave={(val) =>
handleUpdateHeader('ciclo', parseInt(val) || 0)
}
/>
<span>° ciclo</span>
</Badge>
<Badge variant="secondary">{asignaturasApi?.tipo}</Badge>
</div>
</div>
</div>
</section>
{/* ================= TABS ================= */}
<section className="bg-white border-b">
<div className="max-w-7xl mx-auto px-6">
<section className="border-b bg-white">
<div className="mx-auto max-w-7xl px-6">
<Tabs defaultValue="datos">
<TabsList className="h-auto bg-transparent p-0 gap-6">
<TabsList className="h-auto gap-6 bg-transparent p-0">
<TabsTrigger value="datos">Datos generales</TabsTrigger>
<TabsTrigger value="contenido">Contenido temático</TabsTrigger>
<TabsTrigger value="bibliografia">Bibliografía</TabsTrigger>
@@ -168,11 +267,14 @@ const handleRegenerateDocument = useCallback(() => {
{/* ================= TAB: DATOS GENERALES ================= */}
<TabsContent value="datos">
<DatosGenerales />
<DatosGenerales data={datosGenerales} isLoading={loadingAsig} />
</TabsContent>
<TabsContent value="contenido">
<ContenidoTematico></ContenidoTematico>
<ContenidoTematico
data={asignaturasApi}
isLoading={loadingAsig}
></ContenidoTematico>
</TabsContent>
<TabsContent value="bibliografia">
@@ -185,13 +287,18 @@ const handleRegenerateDocument = useCallback(() => {
<TabsContent value="ia">
<IAMateriaTab
campos={campos}
datosGenerales={datosGenerales}
messages={messages}
onSendMessage={handleSendMessage}
onAcceptSuggestion={handleAcceptSuggestion}
onRejectSuggestion={(id) => console.log("Rechazada") /*toast.error("Sugerencia rechazada")*/}
/>
campos={campos}
datosGenerales={datosGenerales}
messages={messages}
onSendMessage={handleSendMessage}
onAcceptSuggestion={handleAcceptSuggestion}
onRejectSuggestion={
(id) =>
console.log(
'Rechazada',
) /* toast.error("Sugerencia rechazada")*/
}
/>
</TabsContent>
<TabsContent value="sep">
@@ -206,7 +313,7 @@ const handleRegenerateDocument = useCallback(() => {
</TabsContent>
<TabsContent value="historial">
<HistorialTab historial={mockHistorial} />
<HistorialTab />
</TabsContent>
</Tabs>
</div>
@@ -216,81 +323,81 @@ const handleRegenerateDocument = useCallback(() => {
}
/* ================= TAB CONTENT ================= */
interface DatosGeneralesProps {
data: AsignaturaDatos
isLoading: boolean
}
function DatosGenerales({ data, isLoading }: DatosGeneralesProps) {
const formatTitle = (key: string): string =>
key.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase())
function DatosGenerales() {
return (
<div className="max-w-7xl mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500">
<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 md:flex-row md:items-center justify-between gap-4 border-b pb-6">
<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="text-slate-500 mt-1">
<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 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">
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* 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`}
/>
<div className="space-y-6 md:col-span-2">
{isLoading && <p>Cargando información...</p>}
<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>
{!isLoading &&
Object.entries(data).map(([key, value]) => (
<InfoCard
key={key}
title={formatTitle(key)}
initialContent={value}
onEnhanceAI={(contenido) => {
console.log('Llevar a IA:', contenido)
// Aquí tu lógica: setPestañaActiva('mejorar-con-ia');
}}
/>
))}
</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 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>
{/* 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>
@@ -298,37 +405,59 @@ function DatosGenerales() {
}
interface InfoCardProps {
title: string,
subtitle?: string
isList?:boolean
initialContent: any // Puede ser string o array de objetos
type?: 'text' | 'list' | 'requirements' | 'evaluation'
title: string
initialContent: any
type?: 'text' | 'requirements' | 'evaluation'
onEnhanceAI?: (content: any) => void // Nueva prop para la acción de IA
}
function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
function InfoCard({
title,
initialContent,
type = 'text',
onEnhanceAI,
}: 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
type === 'text' ? initialContent : JSON.stringify(initialContent, null, 2),
)
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>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<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>
<div className="flex gap-1">
{/* NUEVO: Botón de Mejorar con IA */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-blue-500 hover:bg-blue-50 hover:text-blue-600"
onClick={() => onEnhanceAI?.(data)} // Enviamos la data actual a la IA
title="Mejorar con IA"
>
<Sparkles className="h-4 w-4" />
</Button>
{/* Botón de Editar original */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400"
onClick={() => setIsEditing(true)}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
)}
</CardHeader>
@@ -338,11 +467,23 @@ function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
<Textarea
value={tempText}
onChange={(e) => setTempText(e.target.value)}
className="text-xs min-h-[100px]"
className="min-h-[100px] text-xs"
/>
<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>
<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>
) : (
@@ -358,13 +499,20 @@ function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
}
// Vista de Requisitos
function RequirementsView({ items }: { items: any[] }) {
function RequirementsView({ items }: { items: Array<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
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>
@@ -372,11 +520,14 @@ function RequirementsView({ items }: { items: any[] }) {
}
// Vista de Evaluación
function EvaluationView({ items }: { items: any[] }) {
function EvaluationView({ items }: { items: Array<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">
<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>
@@ -384,13 +535,3 @@ function EvaluationView({ items }: { items: any[] }) {
</div>
)
}
function EmptyTab({ title }: { title: string }) {
return (
<div className="py-16 text-center text-muted-foreground">
{title} (pendiente)
</div>
)
}

View File

@@ -1,7 +1,7 @@
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 {
Asignatura,
@@ -13,60 +13,59 @@ import type {
PlanEstudio,
TipoCiclo,
UUID,
} from "../types/domain";
import type { UploadedFile } from "@/components/planes/wizard/PasoDetallesPanel/FileDropZone";
} from '../types/domain'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
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;
};
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();
};
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
}
export async function plans_list(
filters: PlanListFilters = {},
): Promise<Paged<PlanEstudio>> {
const supabase = supabaseBrowser();
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 carreraModifier =
filters.facultadId && filters.facultadId !== 'todas' ? '!inner' : ''
let q = supabase
.from("planes_estudio")
.from('planes_estudio')
.select(
`
*,
@@ -77,56 +76,56 @@ export async function plans_list(
estructuras_plan (*),
estados_plan (*)
`,
{ count: "exact" },
{ count: 'exact' },
)
.order("actualizado_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());
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_search', `%${cleanTerm}%`)
}
if (filters.carreraId && filters.carreraId !== "todas") {
q = q.eq("carrera_id", filters.carreraId);
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 (filters.estadoId && filters.estadoId !== 'todos') {
q = q.eq('estado_actual_id', filters.estadoId)
}
if (typeof filters.activo === "boolean") {
q = q.eq("activo", filters.activo);
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);
if (filters.facultadId && filters.facultadId !== 'todas') {
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> {
const supabase = supabaseBrowser();
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from("planes_estudio")
.from('planes_estudio')
.select(
`
*,
@@ -135,219 +134,217 @@ 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.");
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,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",
'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): Promise<Array<CambioPlan>> {
const supabase = supabaseBrowser();
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from("cambios_plan")
.from('cambios_plan')
.select(
"id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,interaccion_ia_id",
'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 });
.eq('plan_estudio_id', planId)
.order('cambiado_en', { ascending: false })
throwIfError(error);
return data ?? [];
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> {
return invokeEdge<PlanEstudio>(EDGE.plans_create_manual, input);
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
estructuraPlanId: UUID
}
iaConfig: {
descripcionEnfoque: string;
notasAdicionales?: string;
archivosReferencia?: Array<UUID>;
repositoriosIds?: Array<UUID>;
archivosAdjuntos: Array<UploadedFile>;
usarMCP?: boolean;
};
};
descripcionEnfoque: string
notasAdicionales?: string
archivosReferencia?: Array<UUID>
repositoriosIds?: Array<UUID>
archivosAdjuntos: Array<UploadedFile>
usarMCP?: boolean
}
}
export async function ai_generate_plan(
input: AIGeneratePlanInput,
): Promise<any> {
console.log("input ai generate", input);
console.log('input ai generate', input)
const edgeFunctionBody = new FormData();
edgeFunctionBody.append("datosBasicos", JSON.stringify(input.datosBasicos));
const edgeFunctionBody = new FormData()
edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos))
edgeFunctionBody.append(
"iaConfig",
'iaConfig',
JSON.stringify({
...input.iaConfig,
archivosAdjuntos: undefined, // los manejamos aparte
}),
);
)
input.iaConfig.archivosAdjuntos.forEach((file, index) => {
edgeFunctionBody.append(`archivosAdjuntos`, file.file);
});
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
})
return invokeEdge<any>(
EDGE.ai_generate_plan,
edgeFunctionBody,
undefined,
supabaseBrowser(),
);
)
}
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;
archivoMateriasExcelId?: 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> {
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(
@@ -355,26 +352,26 @@ 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 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", {
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

@@ -3,7 +3,7 @@ import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
} from '@tanstack/react-query'
import {
ai_generate_plan,
@@ -22,16 +22,16 @@ import {
plans_transition_state,
plans_update_fields,
plans_update_map,
} from "../api/plans.api";
import { qk } from "../query/keys";
} 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.
@@ -47,146 +47,146 @@ 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],
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) {
return useQuery({
queryKey: planId
? qk.planAsignaturas(planId)
: ["planes", "asignaturas", null],
: ['planes', 'asignaturas', null],
queryFn: () => plan_asignaturas_list(planId as UUID),
enabled: Boolean(planId),
});
})
}
export function usePlanHistorial(planId: UUID | null | undefined) {
return useQuery({
queryKey: planId ? qk.planHistorial(planId) : ["planes", "historial", null],
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: ["catalogos_planes"],
queryKey: ['catalogos_planes'],
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();
const qc = useQueryClient()
return useMutation({
mutationFn: ai_generate_plan,
onSuccess: (data) => {
// Asumiendo que la Edge Function devuelve { ok: true, plan: { id: ... } }
const newPlan = data.plan;
const newPlan = data.plan
if (newPlan) {
// 1. Invalidar la lista para que aparezca el nuevo plan
qc.invalidateQueries({ queryKey: ["planes", "list"] });
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> }) =>
@@ -194,61 +194,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 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) })
},
});
})
}

View File

@@ -1,11 +1,11 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../query/keys";
import type { UUID } from "../types/domain";
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";
} from '../api/subjects.api'
import {
ai_generate_subject,
subjects_bibliografia_list,
@@ -20,147 +20,177 @@ import {
subjects_update_bibliografia,
subjects_update_contenido,
subjects_update_fields,
} from "../api/subjects.api";
} 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,
});
})
}
/* ------------------ Mutations ------------------ */
export function useCreateSubjectManual() {
const qc = useQueryClient();
const qc = useQueryClient()
return useMutation({
mutationFn: (payload: SubjectsCreateManualInput) => 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; jsonMateria: 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), 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: any[] }) =>
subjects_update_contenido(vars.subjectId, vars.unidades),
onSuccess: (updated) => {
qc.setQueryData(qk.asignatura(updated.id), updated);
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) })
},
});
})
}

View File

@@ -1,40 +1,53 @@
import { usePlan } from '@/data';
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { Pencil, Check, X, Sparkles, AlertCircle } from 'lucide-react'
import { useState, useEffect } 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
import { usePlan } from '@/data'
// 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,
})
const formatLabel = (key: string) => {
const result = key.replace(/_/g, ' ')
return result.charAt(0).toUpperCase() + result.slice(1)
}
function DatosGeneralesPage() {
const {data, isFetching} = usePlan('0e0aea4d-b8b4-4e75-8279-6224c3ac769f');
if(!isFetching && !data) {
return <div>No se encontró el plan de estudios.</div>
}
console.log(data);
// 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 { planId } = Route.useParams()
const { data } = usePlan(planId)
const navigate = useNavigate()
// Inicializamos campos como un arreglo vacío
const [campos, setCampos] = useState<Array<DatosGeneralesField>>([])
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
// Efecto para transformar data?.datos en el arreglo de campos
useEffect(() => {
// 2. Validación de seguridad para sourceData
const sourceData = data?.datos
if (sourceData && typeof sourceData === 'object') {
const datosTransformados: Array<DatosGeneralesField> = Object.entries(
sourceData,
).map(([key, value], index) => ({
id: (index + 1).toString(),
label: formatLabel(key),
// Forzamos el valor a string de forma segura
value: typeof value === 'string' ? value : value?.toString() || '',
requerido: true,
tipo: 'texto',
}))
setCampos(datosTransformados)
}
console.log(data)
}, [data])
// 3. Manejadores de acciones (Ahora como funciones locales)
const handleEdit = (campo: DatosGeneralesField) => {
setEditingId(campo.id)
@@ -48,54 +61,76 @@ function DatosGeneralesPage() {
const handleSave = (id: string) => {
// Actualizamos el estado local de la lista
setCampos(prev => prev.map(c =>
c.id === id ? { ...c, value: editValue } : c
))
setCampos((prev) =>
prev.map((c) => (c.id === id ? { ...c, value: editValue } : c)),
)
setEditingId(null)
setEditValue('')
//toast.success('Cambios guardados localmente')
// 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
const handleIARequest = (descripcion: string) => {
navigate({
to: '/planes/$planId/iaplan',
params: {
planId: '1', // o dinámico
},
state: {
prefill: descripcion,
} as any,
})
}
return (
<div className="container mx-auto px-6 py-6 animate-in fade-in duration-500">
<div className="animate-in fade-in container mx-auto px-6 py-6 duration-500">
<div className="mb-6">
<h2 className="text-lg font-semibold text-foreground">
<h2 className="text-foreground text-lg font-semibold">
Datos Generales del Plan
</h2>
<p className="text-sm text-muted-foreground mt-1">
<p className="text-muted-foreground mt-1 text-sm">
Información estructural y descriptiva del plan de estudios
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{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'
className={`rounded-xl border transition-all ${
isEditing
? 'border-teal-500 shadow-lg ring-2 ring-teal-50'
: '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 justify-between border-b bg-slate-50/50 px-5 py-3">
<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>}
<h3 className="text-sm font-medium text-slate-700">
{campo.label}
</h3>
{campo.requerido && (
<span className="text-xs text-red-500">*</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)}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-teal-600"
onClick={() => handleIARequest(campo.value)}
>
<Sparkles size={14} />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => handleEdit(campo)}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEdit(campo)}
>
<Pencil size={14} />
</Button>
</div>
@@ -112,10 +147,18 @@ function DatosGeneralesPage() {
className="min-h-[120px]"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={handleCancel}>
<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)}>
<Button
size="sm"
className="bg-teal-600 hover:bg-teal-700"
onClick={() => handleSave(campo.id)}
>
<Check size={14} className="mr-1" /> Guardar
</Button>
</div>
@@ -123,12 +166,12 @@ function DatosGeneralesPage() {
) : (
<div className="min-h-[100px]">
{campo.value ? (
<div className="text-sm text-slate-600 leading-relaxed">
<div className="text-sm leading-relaxed text-slate-600">
{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" />
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-teal-500" />
{item}
</li>
))}
@@ -138,7 +181,7 @@ function DatosGeneralesPage() {
)}
</div>
) : (
<div className="flex items-center gap-2 text-slate-400 text-sm">
<div className="flex items-center gap-2 text-sm text-slate-400">
<AlertCircle size={14} />
<span>Sin contenido.</span>
</div>

View File

@@ -1,49 +1,64 @@
import { createFileRoute } from '@tanstack/react-router'
import { CheckCircle2, Circle, Clock } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Textarea } from "@/components/ui/textarea"
import { CheckCircle2, Circle, Clock } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Textarea } from '@/components/ui/textarea'
import { usePlanHistorial } from '@/data/hooks/usePlans'
export const Route = createFileRoute('/planes/$planId/_detalle/flujo')({
component: RouteComponent,
})
function RouteComponent() {
const { data: rawData, isLoading } = usePlanHistorial(
'0e0aea4d-b8b4-4e75-8279-6224c3ac769f',
)
console.log(rawData)
return (
<div className="flex flex-col gap-6 p-6">
{/* Header Informativo (Opcional, si no viene del layout padre) */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold italic">Flujo de Aprobación</h1>
<p className="text-sm text-muted-foreground">Gestiona el proceso de revisión y aprobación del plan</p>
<p className="text-muted-foreground text-sm">
Gestiona el proceso de revisión y aprobación del plan
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* LADO IZQUIERDO: Timeline del Flujo */}
<div className="lg:col-span-2 space-y-4">
<div className="space-y-4 lg:col-span-2">
{/* Estado: Completado */}
<div className="relative flex gap-4 pb-4">
<div className="flex flex-col items-center">
<div className="rounded-full bg-green-100 p-1 text-green-600">
<CheckCircle2 className="h-6 w-6" />
</div>
<div className="w-px flex-1 bg-green-200 mt-2" />
<div className="mt-2 w-px flex-1 bg-green-200" />
</div>
<Card className="flex-1">
<CardHeader className="flex flex-row items-center justify-between py-3">
<div>
<CardTitle className="text-lg">Borrador</CardTitle>
<p className="text-xs text-muted-foreground">14 de enero de 2024</p>
<p className="text-muted-foreground text-xs">
14 de enero de 2024
</p>
</div>
<Badge variant="secondary" className="bg-green-100 text-green-700">Completado</Badge>
<Badge
variant="secondary"
className="bg-green-100 text-green-700"
>
Completado
</Badge>
</CardHeader>
<CardContent className="text-sm border-t pt-3">
<p className="font-semibold text-muted-foreground mb-2">Comentarios</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<CardContent className="border-t pt-3 text-sm">
<p className="text-muted-foreground mb-2 font-semibold">
Comentarios
</p>
<ul className="text-muted-foreground list-inside list-disc space-y-1">
<li>Documento inicial creado</li>
<li>Estructura base definida</li>
</ul>
@@ -57,19 +72,27 @@ function RouteComponent() {
<div className="rounded-full bg-blue-100 p-1 text-blue-600 ring-2 ring-blue-500 ring-offset-2">
<Clock className="h-6 w-6" />
</div>
<div className="w-px flex-1 bg-slate-200 mt-2" />
<div className="mt-2 w-px flex-1 bg-slate-200" />
</div>
<Card className="flex-1 border-blue-500 bg-blue-50/10">
<CardHeader className="flex flex-row items-center justify-between py-3">
<div>
<CardTitle className="text-lg text-blue-700">En Revisión</CardTitle>
<p className="text-xs text-muted-foreground">19 de febrero de 2024</p>
<CardTitle className="text-lg text-blue-700">
En Revisión
</CardTitle>
<p className="text-muted-foreground text-xs">
19 de febrero de 2024
</p>
</div>
<Badge variant="default" className="bg-blue-500">En curso</Badge>
<Badge variant="default" className="bg-blue-500">
En curso
</Badge>
</CardHeader>
<CardContent className="text-sm border-t border-blue-100 pt-3">
<p className="font-semibold text-muted-foreground mb-2">Comentarios</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<CardContent className="border-t border-blue-100 pt-3 text-sm">
<p className="text-muted-foreground mb-2 font-semibold">
Comentarios
</p>
<ul className="text-muted-foreground list-inside list-disc space-y-1">
<li>Revisión de objetivo general pendiente</li>
<li>Mapa curricular aprobado preliminarmente</li>
</ul>
@@ -91,7 +114,6 @@ function RouteComponent() {
</CardHeader>
</Card>
</div>
</div>
{/* LADO DERECHO: Formulario de Transición */}
@@ -101,20 +123,22 @@ function RouteComponent() {
<CardTitle className="text-lg">Transición de Estado</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-lg text-sm border">
<div className="flex items-center justify-between rounded-lg border bg-slate-50 p-3 text-sm">
<div className="text-center">
<p className="text-xs text-muted-foreground">Estado actual</p>
<p className="text-muted-foreground text-xs">Estado actual</p>
<p className="font-bold">En Revisión</p>
</div>
<div className="h-px flex-1 bg-slate-300 mx-4" />
<div className="mx-4 h-px flex-1 bg-slate-300" />
<div className="text-center">
<p className="text-xs text-muted-foreground">Siguiente</p>
<p className="font-bold text-primary">Revisión Expertos</p>
<p className="text-muted-foreground text-xs">Siguiente</p>
<p className="text-primary font-bold">Revisión Expertos</p>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Comentario de transición</label>
<label className="text-sm font-medium">
Comentario de transición
</label>
<Textarea
placeholder="Agrega un comentario para la transición..."
className="min-h-[120px]"
@@ -127,7 +151,6 @@ function RouteComponent() {
</CardContent>
</Card>
</div>
</div>
</div>
)

View File

@@ -1,142 +1,284 @@
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import {
GitBranch,
Edit3,
PlusCircle,
FileText,
RefreshCw,
User
} from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
User,
Loader2,
Clock,
Eye,
History,
Calendar,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { usePlanHistorial } from '@/data/hooks/usePlans'
import { format, formatDistanceToNow, parseISO } from 'date-fns'
import { es } from 'date-fns/locale'
export const Route = createFileRoute('/planes/$planId/_detalle/historial')({
component: RouteComponent,
})
function RouteComponent() {
const historyEvents = [
{
id: 1,
type: 'Cambio de estado',
user: 'Dr. Juan Pérez',
description: 'Plan pasado de Borrador a En Revisión',
date: 'Hace 2 días',
icon: <GitBranch className="h-4 w-4" />,
details: { from: 'Borrador', to: 'En Revisión' }
},
{
id: 2,
type: 'Edición',
user: 'Lic. María García',
description: 'Actualizado perfil de egreso',
date: 'Hace 3 días',
icon: <Edit3 className="h-4 w-4" />,
},
{
id: 3,
type: 'Reorganización',
user: 'Ing. Carlos López',
description: 'Movida materia BD102 de ciclo 3 a ciclo 4',
date: 'Hace 5 días',
icon: <RefreshCw className="h-4 w-4" />,
details: { from: 'Ciclo 3', to: 'Ciclo 4' }
},
{
id: 4,
type: 'Creación',
user: 'Dr. Juan Pérez',
description: 'Añadida nueva materia: Inteligencia Artificial',
date: 'Hace 1 semana',
const getEventConfig = (tipo: string, campo: string) => {
if (tipo === 'CREACION')
return {
label: 'Creación',
icon: <PlusCircle className="h-4 w-4" />,
},
{
id: 5,
type: 'Documento',
user: 'Lic. María García',
description: 'Generado documento oficial v1.0',
date: 'Hace 1 semana',
icon: <FileText className="h-4 w-4" />,
color: 'teal',
}
]
if (campo === 'estado')
return {
label: 'Cambio de estado',
icon: <GitBranch className="h-4 w-4" />,
color: 'blue',
}
if (campo === 'datos')
return {
label: 'Edición de Datos',
icon: <Edit3 className="h-4 w-4" />,
color: 'amber',
}
return {
label: 'Actualización',
icon: <RefreshCw className="h-4 w-4" />,
color: 'slate',
}
}
function RouteComponent() {
const { planId } = Route.useParams()
const { data: rawData, isLoading } = usePlanHistorial(
'0e0aea4d-b8b4-4e75-8279-6224c3ac769f',
)
// ESTADOS PARA EL MODAL
const [selectedEvent, setSelectedEvent] = useState<any>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const historyEvents = useMemo(() => {
if (!rawData) return []
return rawData.map((item: any) => {
const config = getEventConfig(item.tipo, item.campo)
return {
id: item.id,
type: config.label,
user:
item.cambiado_por === '11111111-1111-1111-1111-111111111111'
? 'Administrador'
: 'Usuario Staff',
description:
item.campo === 'datos'
? `Actualización general de: ${item.valor_nuevo?.nombre || 'información del plan'}`
: `Se modificó el campo ${item.campo}`,
date: parseISO(item.cambiado_en),
icon: config.icon,
campo: item.campo,
details: {
from: item.valor_anterior,
to: item.valor_nuevo,
},
}
})
}, [rawData])
const openCompareModal = (event: any) => {
setSelectedEvent(event)
setIsModalOpen(true)
}
const renderValue = (val: any) => {
if (!val) return 'Sin información'
if (typeof val === 'object') return JSON.stringify(val, null, 2)
return String(val)
}
if (isLoading)
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-teal-600" />
</div>
)
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="mb-8">
<h1 className="text-xl font-bold text-slate-800">Historial de Cambios</h1>
<p className="text-sm text-muted-foreground">Registro de todas las modificaciones realizadas al plan</p>
<div className="mx-auto max-w-5xl p-6">
<div className="mb-8 flex items-end justify-between">
<div>
<h1 className="flex items-center gap-2 text-xl font-bold text-slate-800">
<Clock className="h-5 w-5 text-teal-600" /> Historial de Cambios del
Plan
</h1>
<p className="text-muted-foreground text-sm">
Registro cronológico de modificaciones realizadas
</p>
</div>
</div>
<div className="relative space-y-0">
{/* Línea vertical de fondo */}
<div className="absolute left-9 top-0 bottom-0 w-px bg-slate-200" />
<div className="absolute top-0 bottom-0 left-9 w-px bg-slate-200" />
{historyEvents.length === 0 ? (
<div className="ml-20 py-10 text-slate-500">No hay registros.</div>
) : (
historyEvents.map((event) => (
<div key={event.id} className="group relative flex gap-6 pb-8">
<div className="relative z-10 flex h-18 flex-col items-center">
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600">
{event.icon}
</div>
</div>
{historyEvents.map((event) => (
<div key={event.id} className="relative flex gap-6 pb-8 group">
<Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200">
<CardContent className="p-4">
<div className="flex flex-col gap-1">
{/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-slate-800">
{event.type}
</span>
<Badge
variant="outline"
className="h-5 py-0 text-[10px] font-normal"
>
{formatDistanceToNow(event.date, {
addSuffix: true,
locale: es,
})}
</Badge>
</div>
{/* Indicador con Icono */}
<div className="relative z-10 flex h-18 flex-col items-center">
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm group-hover:bg-teal-50 group-hover:text-teal-600 transition-colors">
{event.icon}
{/* Grupo de elementos alineados a la derecha */}
<div className="flex items-center gap-4 text-slate-500">
{/* Usuario e Icono */}
<div className="flex items-center gap-2 text-xs">
<User className="h-3.5 w-3.5" />
<span className="text-muted-foreground">
{event.user}
</span>
</div>
{/* Botón Ver Cambios */}
<button
onClick={() => openCompareModal(event)}
className="group/btn flex items-center gap-1.5 text-xs transition-colors hover:text-teal-600"
>
<Eye className="h-4 w-4 text-slate-400 group-hover/btn:text-teal-600" />
<span>Ver cambios</span>
</button>
{/* Fecha exacta (Solo visible en desktop para no amontonar) */}
<span className="hidden text-[11px] text-slate-400 md:block">
{format(event.date, 'yyyy-MM-dd HH:mm')}
</span>
</div>
</div>
{/* LÍNEA INFERIOR: Descripción */}
<div className="mt-1">
<p className="text-sm text-slate-600">
{event.description}
</p>
{/* Badges de transición opcionales (de estado) */}
{event.details &&
typeof event.details.from === 'string' &&
event.campo === 'estado' && (
<div className="mt-2 flex items-center gap-1.5">
<Badge
variant="secondary"
className="bg-red-50 px-1.5 text-[9px] text-red-700"
>
{event.details.from}
</Badge>
<span className="text-[10px] text-slate-400">
</span>
<Badge
variant="secondary"
className="bg-emerald-50 px-1.5 text-[9px] text-emerald-700"
>
{event.details.to}
</Badge>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
))
)}
</div>
{/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b bg-slate-50/50 p-6">
<DialogTitle className="flex items-center gap-2">
<History className="h-5 w-5 text-teal-600" /> Comparación de
Versiones
</DialogTitle>
<div className="text-muted-foreground flex items-center gap-4 pt-2 text-xs">
<span className="flex items-center gap-1">
<User className="h-3 w-3" /> {selectedEvent?.user}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />{' '}
{selectedEvent &&
format(selectedEvent.date, "d 'de' MMMM, HH:mm", {
locale: es,
})}
</span>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid h-full grid-cols-2 gap-6">
{/* Lado Antes */}
<div className="flex flex-col space-y-2">
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
<div className="h-2 w-2 rounded-full bg-red-400" />
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
Versión Anterior
</span>
</div>
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-red-100 bg-red-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
{renderValue(selectedEvent?.details.from)}
</div>
</div>
{/* Lado Después */}
<div className="flex flex-col space-y-2">
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
<div className="h-2 w-2 rounded-full bg-emerald-400" />
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
Nueva Versión
</span>
</div>
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-emerald-100 bg-emerald-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
{renderValue(selectedEvent?.details.to)}
</div>
</div>
</div>
{/* Tarjeta de Contenido */}
<Card className="flex-1 shadow-none border-slate-200 hover:border-teal-200 transition-colors">
<CardContent className="p-4">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<span className="font-bold text-slate-800 text-sm">{event.type}</span>
<Badge variant="outline" className="text-[10px] font-normal py-0">
{event.date}
</Badge>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Avatar className="h-5 w-5 border">
<AvatarFallback className="text-[8px] bg-slate-50"><User size={10}/></AvatarFallback>
</Avatar>
{event.user}
</div>
</div>
<p className="text-sm text-slate-600 mb-3">{event.description}</p>
{/* Badges de transición (si existen) */}
{event.details && (
<div className="flex items-center gap-2 mt-2">
<Badge variant="secondary" className="bg-orange-50 text-orange-700 hover:bg-orange-50 border-orange-100 text-[10px]">
{event.details.from}
</Badge>
<span className="text-slate-400 text-xs"></span>
<Badge variant="secondary" className="bg-green-50 text-green-700 hover:bg-green-50 border-green-100 text-[10px]">
{event.details.to}
</Badge>
</div>
)}
</CardContent>
</Card>
</div>
))}
{/* Evento inicial de creación */}
<div className="relative flex gap-6 group">
<div className="relative z-10 flex items-center">
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-teal-600 text-white shadow-sm">
<PlusCircle className="h-4 w-4" />
</div>
<div className="flex justify-center border-t bg-slate-50 p-4">
<Badge variant="outline" className="font-mono text-[10px]">
Campo: {selectedEvent?.campo}
</Badge>
</div>
<Card className="flex-1 bg-teal-50/30 border-teal-100 shadow-none">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-1">
<span className="font-bold text-teal-900 text-sm">Creación</span>
<span className="text-[10px] text-teal-600 font-medium">14 Ene 2024</span>
</div>
<p className="text-sm text-teal-800/80">Plan de estudios creado</p>
</CardContent>
</Card>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,118 +1,371 @@
import { createFileRoute } from '@tanstack/react-router'
import { Sparkles, Send, Paperclip, Target, UserCheck, Lightbulb, FileText } from "lucide-react"
import { useState } from 'react' // Importamos useState
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { createFileRoute, 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 { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { usePlan } from '@/data/hooks/usePlans'
const PRESETS = [
{
id: 'objetivo',
label: 'Mejorar objetivo general',
icon: Target,
prompt: 'Mejora la redacción del objetivo general...',
},
{
id: 'perfil-egreso',
label: 'Redactar perfil de egreso',
icon: GraduationCap,
prompt: 'Genera un perfil de egreso detallado...',
},
{
id: 'competencias',
label: 'Sugerir competencias',
icon: BookOpen,
prompt: 'Genera una lista de competencias...',
},
{
id: 'pertinencia',
label: 'Justificar pertinencia',
icon: FileText,
prompt: 'Redacta una justificación de pertinencia...',
},
]
// --- Tipado y Helpers ---
interface SelectedField {
key: string
label: string
value: string
}
const formatLabel = (key: string) => {
const result = key.replace(/_/g, ' ')
return result.charAt(0).toUpperCase() + result.slice(1)
}
export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({
component: RouteComponent,
})
function RouteComponent() {
// 1. Estado para el texto del input
const [inputValue, setInputValue] = useState('')
const { planId } = Route.useParams()
// Usamos el ID dinámico del plan o el hardcoded según tu necesidad
const { data } = usePlan('0e0aea4d-b8b4-4e75-8279-6224c3ac769f')
const routerState = useRouterState()
// 2. Estado para la lista de mensajes (iniciamos con los de la imagen)
const [messages, setMessages] = useState([
{ id: 1, role: 'ai', text: 'Hola, soy tu asistente de IA para el diseño del plan de estudios...' },
{ id: 2, role: 'user', text: 'jkasakj' },
{ id: 3, role: 'ai', text: 'Entendido. Estoy procesando tu solicitud.' },
// ESTADOS PRINCIPALES
const [messages, setMessages] = useState<Array<any>>([
{
id: '1',
role: 'assistant',
content:
'¡Hola! Soy tu asistente de IA. ¿Qué campos deseas mejorar? Puedes escribir ":" para seleccionar uno.',
},
])
const [input, setInput] = useState('')
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
// 3. Función para enviar el mensaje
const handleSend = () => {
if (!inputValue.trim()) return
const scrollRef = useRef<HTMLDivElement>(null)
// Agregamos el mensaje del usuario
const newMessage = {
id: Date.now(),
role: 'user',
text: inputValue
// 1. Transformar datos de la API para el menú de selección
const availableFields = useMemo(() => {
if (!data?.datos) return []
return Object.entries(data.datos).map(([key, value]) => ({
key,
label: formatLabel(key),
value: String(value || ''),
}))
}, [data])
// 2. Manejar el estado inicial si viene de "Datos Generales"
useEffect(() => {
const state = routerState.location.state as any
if (state?.prefill && availableFields.length > 0) {
// Intentamos encontrar qué campo es por su valor o si mandaste el fieldKey
const field = availableFields.find(
(f) => f.value === state.prefill || f.key === state.fieldKey,
)
if (field && !selectedFields.find((sf) => sf.key === field.key)) {
setSelectedFields([field])
}
setInput(`Mejora este campo: `)
}
}, [availableFields])
setMessages([...messages, newMessage])
setInputValue('') // Limpiamos el input
// 3. Lógica para el disparador ":"
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value
setInput(val)
if (val.endsWith(':')) {
setShowSuggestions(true)
} else {
setShowSuggestions(false)
}
}
const toggleField = (field: SelectedField) => {
setSelectedFields((prev) =>
prev.find((f) => f.key === field.key)
? prev.filter((f) => f.key !== field.key)
: [...prev, field],
)
if (input.endsWith(':')) setInput(input.slice(0, -1))
setShowSuggestions(false)
}
const handleSend = async (promptOverride?: string) => {
const textToSend = promptOverride || input
if (!textToSend.trim() && selectedFields.length === 0) return
const userMsg = {
id: Date.now().toString(),
role: 'user',
content: textToSend,
}
setMessages((prev) => [...prev, userMsg])
setInput('')
setIsLoading(true)
// Aquí simularías la llamada a la API enviando 'selectedFields' como contexto
setTimeout(() => {
const mockText =
'Sugerencia generada basada en los campos seleccionados...'
setMessages((prev) => [
...prev,
{
id: Date.now().toString(),
role: 'assistant',
content: `He analizado ${selectedFields.length > 0 ? selectedFields.map((f) => f.label).join(', ') : 'tu solicitud'}. Aquí tienes una propuesta:\n\n${mockText}`,
},
])
setPendingSuggestion({ text: mockText })
setIsLoading(false)
}, 1200)
}
return (
<div className="flex h-[calc(100vh-200px)] gap-6 p-4">
<div className="flex flex-col flex-1 bg-slate-50/50 rounded-xl border relative overflow-hidden">
<ScrollArea className="flex-1 p-6">
<div className="space-y-6 max-w-3xl mx-auto">
{/* 4. Mapeamos los mensajes dinámicamente */}
{messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} gap-3`}>
{msg.role === 'ai' && (
<Avatar className="h-8 w-8 border bg-teal-50">
<AvatarFallback className="text-teal-600"><Sparkles size={16}/></AvatarFallback>
</Avatar>
)}
<div className={msg.role === 'ai' ? 'space-y-2' : ''}>
{msg.role === 'ai' && <p className="text-xs font-bold text-teal-700 uppercase tracking-wider">Asistente IA</p>}
<div className={`p-4 rounded-2xl text-sm shadow-sm ${
msg.role === 'user'
? 'bg-teal-600 text-white rounded-tr-none'
: 'bg-white border text-slate-700 rounded-tl-none'
}`}>
{msg.text}
</div>
</div>
<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">
{/* NUEVO: Barra superior de campos seleccionados */}
<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 text-slate-400 uppercase">
Campos a mejorar:
</span>
{selectedFields.map((field) => (
<div
key={field.key}
className="animate-in zoom-in-95 flex items-center gap-1.5 rounded-lg border border-teal-100 bg-teal-50 px-2 py-1 text-xs font-medium text-teal-700"
>
{field.label}
<button
onClick={() => toggleField(field)}
className="hover:text-red-500"
>
<X size={12} />
</button>
</div>
))}
{selectedFields.length === 0 && (
<span className="text-xs text-slate-400 italic">
Escribe ":" para añadir campos
</span>
)}
</div>
</ScrollArea>
</div>
{/* 5. Input vinculado al estado */}
<div className="p-4 bg-white border-t">
<div className="max-w-4xl mx-auto flex gap-2 items-center bg-slate-50 border rounded-lg px-3 py-1 shadow-sm focus-within:ring-1 focus-within:ring-teal-500 transition-all">
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()} // Enviar con Enter
className="border-none bg-transparent focus-visible:ring-0 text-sm"
placeholder='Escribe tu solicitud... Usa ":" para mencionar campos'
/>
<Button variant="ghost" size="icon" className="text-slate-400">
<Paperclip size={18} />
</Button>
<Button
onClick={handleSend}
size="icon"
className="bg-teal-600 hover:bg-teal-700 h-8 w-8"
>
<Send size={16} />
</Button>
{/* 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={`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>
</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>
{/* Botones flotantes de aplicación */}
{pendingSuggestion && !isLoading && (
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 gap-2 rounded-full border bg-white p-1.5 shadow-2xl">
<Button
variant="ghost"
size="sm"
onClick={() => setPendingSuggestion(null)}
className="h-8 rounded-full text-xs"
>
<X className="mr-1 h-3 w-3" /> Descartar
</Button>
<Button
size="sm"
className="h-8 rounded-full bg-teal-600 text-xs text-white hover:bg-teal-700"
>
<Check className="mr-1 h-3 w-3" /> Aplicar cambios
</Button>
</div>
)}
</div>
{/* INPUT FIJO AL FONDO CON SUGERENCIAS : */}
{/* INPUT FIJO AL FONDO CON SUGERENCIAS : */}
<div className="shrink-0 border-t bg-white p-4">
<div className="relative mx-auto max-w-4xl">
{/* MENÚ DE SUGERENCIAS FLOTANTE (Se mantiene igual) */}
{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 para IA
</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 TRANSFORMADO */}
<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">
{/* 1. Visualización de campos dentro del input (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>
)}
{/* 2. Área de escritura */}
<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
? 'Escribe instrucciones adicionales...'
: '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 (se mantiene igual) */}
<div className="w-72 space-y-4">
<div className="flex items-center gap-2 text-orange-500 font-semibold text-sm mb-4">
<Lightbulb size={18} />
Acciones rápidas
</div>
{/* PANEL LATERAL (PRESETS) - SE MANTIENE COMO LO TENÍAS */}
<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">
<ActionButton icon={<Target className="text-teal-500" size={18} />} text="Mejorar objetivo general" />
<ActionButton icon={<UserCheck className="text-slate-500" size={18} />} text="Redactar perfil de egreso" />
<ActionButton icon={<Lightbulb className="text-blue-500" size={18} />} text="Sugerir competencias" />
<ActionButton icon={<FileText className="text-teal-500" size={18} />} text="Justificar pertinencia" />
{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>
)
}
function ActionButton({ icon, text }: { icon: React.ReactNode, text: string }) {
return (
<Button variant="outline" className="w-full justify-start gap-3 h-auto py-3 px-4 text-sm font-normal hover:bg-slate-50 border-slate-200 shadow-sm text-slate-700">
{icon}
{text}
</Button>
)
}

View File

@@ -1,302 +1,725 @@
/* eslint-disable jsx-a11y/label-has-associated-control */
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import {
Plus,
ChevronDown,
AlertTriangle,
GripVertical,
Trash2
Trash2,
} from 'lucide-react'
import { useMemo, useState, useEffect } from 'react'
import type { Materia, LineaCurricular } from '@/types/plan'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog, DialogContent, DialogHeader, DialogTitle
} from "@/components/ui/dialog"
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { usePlanAsignaturas, usePlanLineas } from '@/data'
export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
component: MapaCurricularPage,
})
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
const mapLineasToLineaCurricular = (
lineasApi: Array<any> = [],
): Array<LineaCurricular> => {
return lineasApi.map((linea) => ({
id: linea.id,
nombre: linea.nombre,
orden: linea.orden ?? 0,
color: '#1976d2',
}))
}
// --- Constantes de Estilo y Datos ---
const INITIAL_LINEAS: LineaCurricular[] = [
{ id: 'l1', nombre: 'Formación Básica', orden: 1 },
{ id: 'l2', nombre: 'Ciencias de la Computación', orden: 2 },
];
const INITIAL_MATERIAS: Materia[] = [
{ id: "1", clave: 'MAT101', nombre: 'Cálculo Diferencial', creditos: 8, hd: 4, hi: 4, ciclo: 1, lineaCurricularId: 'l1', tipo: 'obligatoria', estado: 'aprobada' },
{ id: "2", clave: 'FIS101', nombre: 'Física Mecánica', creditos: 6, hd: 3, hi: 3, ciclo: 1, lineaCurricularId: 'l1', tipo: 'obligatoria', estado: 'aprobada' },
{ id: "3", clave: 'PRO101', nombre: 'Fundamentos de Programación', creditos: 8, hd: 4, hi: 4, ciclo: null, lineaCurricularId: null, tipo: 'obligatoria', estado: 'borrador' },
];
const mapAsignaturasToMaterias = (asigApi: Array<any> = []): Array<Materia> => {
return asigApi.map((asig) => ({
id: asig.id,
clave: asig.codigo,
nombre: asig.nombre,
creditos: asig.creditos ?? 0,
ciclo: asig.numero_ciclo ?? null,
lineaCurricularId: asig.linea_plan_id ?? null,
tipo: asig.tipo === 'OBLIGATORIA' ? 'obligatoria' : 'optativa',
estado: 'borrador',
orden: asig.orden_celda ?? 0,
hd: Math.floor((asig.horas_semana ?? 0) / 2),
hi: Math.ceil((asig.horas_semana ?? 0) / 2),
prerrequisitos: [],
}))
}
const lineColors = [
'bg-blue-50 border-blue-200 text-blue-700',
'bg-purple-50 border-purple-200 text-purple-700',
'bg-orange-50 border-orange-200 text-orange-700',
'bg-emerald-50 border-emerald-200 text-emerald-700',
];
]
const statusBadge: Record<string, string> = {
borrador: 'bg-slate-100 text-slate-600',
revisada: 'bg-amber-100 text-amber-700',
aprobada: 'bg-emerald-100 text-emerald-700',
};
}
// --- Subcomponentes ---
function StatItem({ label, value, total }: { label: string, value: number, total?: number }) {
function StatItem({
label,
value,
total,
}: {
label: string
value: number
total?: number
}) {
return (
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">{label}:</span>
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
{label}:
</span>
<span className="text-sm font-bold text-slate-700">
{value}{total ? <span className="text-slate-400 font-normal">/{total}</span> : ''}
{value}
{total ? (
<span className="font-normal text-slate-400">/{total}</span>
) : (
''
)}
</span>
</div>
)
}
function MateriaCardItem({ materia, onDragStart, isDragging, onClick }: {
materia: Materia,
onDragStart: (e: React.DragEvent, id: string) => void,
isDragging: boolean,
function MateriaCardItem({
materia,
onDragStart,
isDragging,
onClick,
}: {
materia: Materia
onDragStart: (e: React.DragEvent, id: string) => void
isDragging: boolean
onClick: () => void
}) {
return (
<div
<button
draggable
onDragStart={(e) => onDragStart(e, materia.id)}
onClick={onClick}
className={`group p-3 rounded-lg border bg-white shadow-sm cursor-grab active:cursor-grabbing transition-all ${
isDragging ? 'opacity-40 scale-95' : 'hover:border-teal-400 hover:shadow-md'
className={`group cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all active:cursor-grabbing ${
isDragging
? 'scale-95 opacity-40'
: 'hover:border-teal-400 hover:shadow-md'
}`}
>
<div className="flex justify-between items-start mb-1">
<span className="text-[10px] font-mono font-bold text-slate-400">{materia.clave}</span>
<Badge variant="outline" className={`text-[9px] px-1 py-0 uppercase ${statusBadge[materia.estado] || ''}`}>
<div className="mb-1 flex items-start justify-between">
<span className="font-mono text-[10px] font-bold text-slate-400">
{materia.clave}
</span>
<Badge
variant="outline"
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[materia.estado] || ''}`}
>
{materia.estado}
</Badge>
</div>
<p className="text-xs font-bold text-slate-700 leading-tight mb-1">{materia.nombre}</p>
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-slate-500">{materia.creditos} CR HD:{materia.hd} HI:{materia.hi}</span>
<GripVertical size={12} className="text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity" />
<p className="mb-1 text-xs leading-tight font-bold text-slate-700">
{materia.nombre}
</p>
<div className="mt-2 flex items-center justify-between">
<span className="text-[10px] text-slate-500">
{materia.creditos} CR HD:{materia.hd} HI:{materia.hi}
</span>
<GripVertical
size={12}
className="text-slate-300 opacity-0 transition-opacity group-hover:opacity-100"
/>
</div>
</div>
</button>
)
}
// --- Componente Principal ---
function MapaCurricularPage() {
const [materias, setMaterias] = useState<Materia[]>(INITIAL_MATERIAS);
const [lineas, setLineas] = useState<LineaCurricular[]>(INITIAL_LINEAS);
const [draggedMateria, setDraggedMateria] = useState<string | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [selectedMateria, setSelectedMateria] = useState<Materia | null>(null);
export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
component: MapaCurricularPage,
})
const ciclosTotales = 9;
const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1);
function MapaCurricularPage() {
const { planId } = Route.useParams() // Idealmente usa el ID de la ruta
// 1. Fetch de Datos
const { data: asignaturasApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
// 2. Estado Local (Para interactividad)
const [materias, setMaterias] = useState<Array<Materia>>([])
const [lineas, setLineas] = useState<Array<LineaCurricular>>([])
const [draggedMateria, setDraggedMateria] = useState<string | null>(null)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [selectedMateria, setSelectedMateria] = useState<Materia | null>(null)
const [hasAreaComun, setHasAreaComun] = useState(false)
const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado
const manejarAgregarLinea = (nombre: string) => {
const nombreNormalizado = nombre.trim()
// Validar si es Área Común (insensible a mayúsculas/minúsculas)
const esAreaComun =
nombreNormalizado.toLowerCase() === 'área común' ||
nombreNormalizado.toLowerCase() === 'area comun'
if (esAreaComun && hasAreaComun) {
alert('El Área Común ya ha sido agregada.')
return
}
const nueva = {
id: crypto.randomUUID(),
nombre: nombreNormalizado,
orden: lineas.length + 1,
}
setLineas([...lineas, nueva])
if (esAreaComun) {
setHasAreaComun(true)
}
setNombreNuevaLinea('') // Limpiar input
}
const tieneAreaComun = useMemo(() => {
return lineas.some(
(l) =>
l.nombre.toLowerCase() === 'área común' ||
l.nombre.toLowerCase() === 'area comun',
)
}, [lineas])
// 3. Sincronizar API -> Estado Local
useEffect(() => {
if (asignaturasApi) setMaterias(mapAsignaturasToMaterias(asignaturasApi))
}, [asignaturasApi])
useEffect(() => {
if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi))
}, [lineasApi])
const ciclosTotales = 9
const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1)
// Nuevo estado para controlar los datos temporales del modal de edición
const [editingData, setEditingData] = useState<Materia | null>(null)
// 1. FUNCION DE GUARDAR MODAL
const handleSaveChanges = () => {
if (!editingData) return
console.log(materias)
setMaterias((prev) =>
prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)),
)
setIsEditModalOpen(false)
}
// 2. MODIFICACIÓN: Zona de soltado siempre visible
// Cambiamos la condición: Mostramos la sección si hay materias sin asignar
// O si simplemente queremos tener el "depósito" disponible.
const unassignedMaterias = materias.filter((m) => m.ciclo === null)
// --- Lógica de Gestión ---
const agregarLinea = (nombre: string) => {
const nueva = { id: crypto.randomUUID(), nombre, orden: lineas.length + 1 };
setLineas([...lineas, nueva]);
};
const nueva = { id: crypto.randomUUID(), nombre, orden: lineas.length + 1 }
setLineas([...lineas, nueva])
}
const borrarLinea = (id: string) => {
setMaterias(prev => prev.map(m => m.lineaCurricularId === id ? { ...m, ciclo: null, lineaCurricularId: null } : m));
setLineas(prev => prev.filter(l => l.id !== id));
};
setMaterias((prev) =>
prev.map((m) =>
m.lineaCurricularId === id
? { ...m, ciclo: null, lineaCurricularId: null }
: m,
),
)
setLineas((prev) => prev.filter((l) => l.id !== id))
}
// --- Selectores/Cálculos ---
const getTotalesCiclo = (ciclo: number) => {
return materias.filter(m => m.ciclo === ciclo).reduce((acc, m) => ({
cr: acc.cr + (m.creditos || 0), hd: acc.hd + (m.hd || 0), hi: acc.hi + (m.hi || 0)
}), { cr: 0, hd: 0, hi: 0 });
};
return materias
.filter((m) => m.ciclo === ciclo)
.reduce(
(acc, m) => ({
cr: acc.cr + (m.creditos || 0),
hd: acc.hd + (m.hd || 0),
hi: acc.hi + (m.hi || 0),
}),
{ cr: 0, hd: 0, hi: 0 },
)
}
const getSubtotalLinea = (lineaId: string) => {
return materias.filter(m => m.lineaCurricularId === lineaId && m.ciclo !== null).reduce((acc, m) => ({
cr: acc.cr + (m.creditos || 0), hd: acc.hd + (m.hd || 0), hi: acc.hi + (m.hi || 0)
}), { cr: 0, hd: 0, hi: 0 });
};
return materias
.filter((m) => m.lineaCurricularId === lineaId && m.ciclo !== null)
.reduce(
(acc, m) => ({
cr: acc.cr + (m.creditos || 0),
hd: acc.hd + (m.hd || 0),
hi: acc.hi + (m.hi || 0),
}),
{ cr: 0, hd: 0, hi: 0 },
)
}
// --- Handlers Drag & Drop ---
const handleDragStart = (e: React.DragEvent, id: string) => { setDraggedMateria(id); e.dataTransfer.effectAllowed = 'move'; };
const handleDragOver = (e: React.DragEvent) => e.preventDefault();
const handleDrop = (e: React.DragEvent, ciclo: number | null, lineaId: string | null) => {
e.preventDefault();
const handleDragStart = (e: React.DragEvent, id: string) => {
setDraggedMateria(id)
e.dataTransfer.effectAllowed = 'move'
}
const handleDragOver = (e: React.DragEvent) => e.preventDefault()
const handleDrop = (
e: React.DragEvent,
ciclo: number | null,
lineaId: string | null,
) => {
e.preventDefault()
if (draggedMateria) {
setMaterias(prev => prev.map(m => m.id === draggedMateria ? { ...m, ciclo, lineaCurricularId: lineaId } : m));
setDraggedMateria(null);
setMaterias((prev) =>
prev.map((m) =>
m.id === draggedMateria
? { ...m, ciclo, lineaCurricularId: lineaId }
: m,
),
)
setDraggedMateria(null)
}
};
}
// --- Estadísticas Generales ---
const stats = materias.reduce((acc, m) => {
if (m.ciclo !== null) {
acc.cr += m.creditos || 0; acc.hd += m.hd || 0; acc.hi += m.hi || 0;
}
return acc;
}, { cr: 0, hd: 0, hi: 0 });
const stats = useMemo(
() =>
materias.reduce(
(acc, m) => {
if (m.ciclo !== null) {
acc.cr += m.creditos || 0
acc.hd += m.hd || 0
acc.hi += m.hi || 0
}
return acc
},
{ cr: 0, hd: 0, hi: 0 },
),
[materias],
)
if (loadingAsig || loadingLineas)
return <div className="p-10 text-center">Cargando mapa curricular...</div>
return (
<div className="container mx-auto px-2 py-6">
{/* Header */}
<div className="flex justify-between items-center mb-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">Mapa Curricular</h2>
<p className="text-sm text-slate-500">Organiza las materias por línea curricular y ciclo</p>
<p className="text-sm text-slate-500">
Organiza las materias de la petición por línea y ciclo
</p>
</div>
<div className="flex items-center gap-3">
{materias.filter(m => !m.ciclo).length > 0 && (
<Badge className="bg-amber-50 text-amber-600 border-amber-100 hover:bg-amber-50">
<AlertTriangle size={14} className="mr-1" /> {materias.filter(m => !m.ciclo).length} materias sin asignar
{materias.filter((m) => !m.ciclo).length > 0 && (
<Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50">
<AlertTriangle size={14} className="mr-1" />{' '}
{materias.filter((m) => !m.ciclo).length} sin asignar
</Badge>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="bg-teal-700 hover:bg-teal-800 text-white">
<Plus size={16} className="mr-2" /> Agregar <ChevronDown size={14} className="ml-2" />
<Button className="bg-teal-700 text-white hover:bg-teal-800">
<Plus size={16} className="mr-2" /> Agregar{' '}
<ChevronDown size={14} className="ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => agregarLinea("Nueva Línea")}>Nueva Línea Curricular</DropdownMenuItem>
<DropdownMenuItem onClick={() => agregarLinea("Área Común")}>Agregar Área Común</DropdownMenuItem>
{!tieneAreaComun && (
<>
<DropdownMenuItem
onClick={() => manejarAgregarLinea('Área Común')}
className="font-bold text-teal-700"
>
+ Agregar Área Común
</DropdownMenuItem>
<div className="my-1 border-t border-slate-100" />
</>
)}
{/* Input para nombre personalizado */}
<div className="p-2">
<label className="text-[10px] font-bold text-slate-400 uppercase">
Nombre de Línea
</label>
<div className="mt-1 flex gap-1">
<Input
value={nombreNuevaLinea}
onChange={(e) => setNombreNuevaLinea(e.target.value)}
placeholder="Ej: Optativas"
className="h-8 text-xs"
/>
<Button
size="sm"
className="h-8 px-2"
onClick={() => manejarAgregarLinea(nombreNuevaLinea)}
disabled={!nombreNuevaLinea.trim()}
>
<Plus size={14} />
</Button>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Barra Totales */}
<div className="bg-slate-50/80 border border-slate-200 rounded-xl p-4 mb-8 flex gap-10">
<div className="mb-8 flex gap-10 rounded-xl border border-slate-200 bg-slate-50/80 p-4">
<StatItem label="Total Créditos" value={stats.cr} total={320} />
<StatItem label="Total HD" value={stats.hd} />
<StatItem label="Total HI" value={stats.hi} />
<StatItem label="Total Horas" value={stats.hd + stats.hi} />
</div>
{/* Grid Principal */}
<div className="overflow-x-auto pb-6">
<div className="min-w-[1500px]">
{/* Header Ciclos */}
<div className="grid gap-3 mb-4" style={{ gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px` }}>
<div className="text-xs font-bold text-slate-400 self-end px-2">LÍNEA CURRICULAR</div>
{ciclosArray.map(n => <div key={n} className="bg-slate-100 rounded-lg p-2 text-center text-sm font-bold text-slate-600">Ciclo {n}</div>)}
<div className="text-xs font-bold text-slate-400 self-end text-center">SUBTOTAL</div>
<div
className="mb-4 grid gap-3"
style={{
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
}}
>
<div className="self-end px-2 text-xs font-bold text-slate-400">
LÍNEA CURRICULAR
</div>
{ciclosArray.map((n) => (
<div
key={n}
className="rounded-lg bg-slate-100 p-2 text-center text-sm font-bold text-slate-600"
>
Ciclo {n}
</div>
))}
<div className="self-end text-center text-xs font-bold text-slate-400">
SUBTOTAL
</div>
</div>
{/* Filas por Línea */}
{lineas.map((linea, idx) => {
const sub = getSubtotalLinea(linea.id);
const sub = getSubtotalLinea(linea.id)
return (
<div key={linea.id} className="grid gap-3 mb-3" style={{ gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px` }}>
<div className={`p-4 rounded-xl border-l-4 flex justify-between items-center ${lineColors[idx % lineColors.length]}`}>
<div
key={linea.id}
className="mb-3 grid gap-3"
style={{
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
}}
>
<div
className={`flex items-center justify-between rounded-xl border-l-4 p-4 ${lineColors[idx % lineColors.length]}`}
>
<span className="text-xs font-bold">{linea.nombre}</span>
<Trash2 size={14} className="text-slate-400 hover:text-red-500 cursor-pointer" onClick={() => borrarLinea(linea.id)} />
<Trash2
size={14}
className="cursor-pointer text-slate-400 hover:text-red-500"
onClick={() => borrarLinea(linea.id)}
/>
</div>
{ciclosArray.map(ciclo => (
{ciclosArray.map((ciclo) => (
<div
key={ciclo}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, ciclo, linea.id)}
className="min-h-[140px] p-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 space-y-2"
className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2"
>
{materias.filter(m => m.ciclo === ciclo && m.lineaCurricularId === linea.id).map(m => (
<MateriaCardItem key={m.id} materia={m} isDragging={draggedMateria === m.id} onDragStart={handleDragStart} onClick={() => { setSelectedMateria(m); setIsEditModalOpen(true); }} />
))}
{materias
.filter(
(m) =>
m.ciclo === ciclo && m.lineaCurricularId === linea.id,
)
.map((m) => (
<MateriaCardItem
key={m.id}
materia={m}
isDragging={draggedMateria === m.id}
onDragStart={handleDragStart}
onClick={() => {
setEditingData(m)
setIsEditModalOpen(true)
}}
/>
))}
</div>
))}
<div className="p-4 bg-slate-50 rounded-xl flex flex-col justify-center text-[10px] text-slate-500 font-medium border border-slate-100">
<div>Cr: {sub.cr}</div><div>HD: {sub.hd}</div><div>HI: {sub.hi}</div>
<div className="flex flex-col justify-center rounded-xl border border-slate-100 bg-slate-50 p-4 text-[10px] font-medium text-slate-500">
<div>Cr: {sub.cr}</div>
<div>HD: {sub.hd}</div>
<div>HI: {sub.hi}</div>
</div>
</div>
)
})}
{/* Fila Totales Ciclo */}
<div className="grid gap-3 mt-6 border-t pt-4" style={{ gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px` }}>
<div className="p-2 font-bold text-slate-600">Totales por Ciclo</div>
{ciclosArray.map(ciclo => {
const t = getTotalesCiclo(ciclo);
<div
className="mt-6 grid gap-3 border-t pt-4"
style={{
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
}}
>
<div className="p-2 font-bold text-slate-600">
Totales por Ciclo
</div>
{ciclosArray.map((ciclo) => {
const t = getTotalesCiclo(ciclo)
return (
<div key={ciclo} className="text-[10px] text-center p-2 bg-slate-50 rounded-lg">
<div
key={ciclo}
className="rounded-lg bg-slate-50 p-2 text-center text-[10px]"
>
<div className="font-bold text-slate-700">Cr: {t.cr}</div>
<div>HD: {t.hd} HI: {t.hi}</div>
<div>
HD: {t.hd} HI: {t.hi}
</div>
</div>
)
})}
<div className="bg-teal-50 rounded-lg p-2 text-center text-teal-800 font-bold text-xs flex flex-col justify-center">
<div>{stats.cr} Cr</div><div>{stats.hd + stats.hi} Hrs</div>
<div className="flex flex-col justify-center rounded-lg bg-teal-50 p-2 text-center text-xs font-bold text-teal-800">
<div>{stats.cr} Cr</div>
<div>{stats.hd + stats.hi} Hrs</div>
</div>
</div>
</div>
</div>
{/* Modal de Edición */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader><DialogTitle>Editar Materia</DialogTitle></DialogHeader>
{selectedMateria && (
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-2"><label className="text-xs font-bold uppercase">Clave</label><Input defaultValue={selectedMateria.clave} /></div>
<div className="space-y-2"><label className="text-xs font-bold uppercase">Nombre</label><Input defaultValue={selectedMateria.nombre} /></div>
<div className="space-y-2"><label className="text-xs font-bold uppercase">Créditos</label><Input type="number" defaultValue={selectedMateria.creditos} /></div>
<div className="flex gap-2">
<div className="space-y-2"><label className="text-xs font-bold uppercase">HD</label><Input type="number" defaultValue={selectedMateria.hd} /></div>
<div className="space-y-2"><label className="text-xs font-bold uppercase">HI</label><Input type="number" defaultValue={selectedMateria.hi} /></div>
</div>
</div>
)}
<div className="flex justify-end gap-3 mt-4">
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}>Cancelar</Button>
<Button className="bg-teal-700 text-white">Guardar Cambios</Button>
</div>
</DialogContent>
</Dialog>
{/* 4. Materias Pendientes (Sin Asignar) */}
{materias.filter(m => m.ciclo === null).length > 0 && (
<div className="mt-10 p-6 bg-slate-50 rounded-2xl border border-slate-200 shadow-sm animate-in slide-in-from-bottom-4 duration-500">
<div className="flex items-center gap-2 mb-4 text-amber-600">
<AlertTriangle size={20} />
<h3 className="font-bold text-sm uppercase tracking-tight">
Materias pendientes de asignar ({materias.filter(m => m.ciclo === null).length})
{/* Materias Sin Asignar */}
{/* SECCIÓN DE MATERIAS SIN ASIGNAR (Mejorada para estar siempre disponible) */}
<div className="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-6">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-600">
<h3 className="text-sm font-bold tracking-wider uppercase">
Bandeja de Entrada / Materias sin asignar
</h3>
<Badge variant="secondary">{unassignedMaterias.length}</Badge>
</div>
<div
className={`flex flex-wrap gap-4 min-h-[100px] p-4 rounded-xl border-2 border-dashed transition-all ${
draggedMateria ? 'border-amber-200 bg-amber-50/50' : 'border-slate-200 bg-white/50'
}`}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, null, null)} // null devuelve la materia al estado "sin asignar"
>
{materias
.filter(m => m.ciclo === null)
.map(m => (
<div key={m.id} className="w-[200px]">
<MateriaCardItem
materia={m}
isDragging={draggedMateria === m.id}
onDragStart={handleDragStart}
onClick={() => { setSelectedMateria(m); setIsEditModalOpen(true); }}
/>
</div>
))}
</div>
<p className="mt-3 text-[11px] text-slate-400 italic text-center">
Arrastra las materias desde aquí hacia cualquier ciclo y línea del mapa curricular.
<p className="text-xs text-slate-400">
Arrastra una materia aquí para quitarla del mapa
</p>
</div>
)}
<div
className={`flex min-h-[120px] flex-wrap gap-4 rounded-xl border-2 border-dashed p-4 transition-colors ${
draggedMateria
? 'border-teal-300 bg-teal-50/50'
: 'border-slate-200 bg-white/50'
}`}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, null, null)} // Limpia ciclo y línea
>
{unassignedMaterias.map((m) => (
<div key={m.id} className="w-[200px]">
<MateriaCardItem
materia={m}
isDragging={draggedMateria === m.id}
onDragStart={handleDragStart}
onClick={() => {
setEditingData(m) // Cargamos los datos en el estado de edición
setIsEditModalOpen(true)
}}
/>
</div>
))}
{unassignedMaterias.length === 0 && (
<div className="flex w-full items-center justify-center text-sm text-slate-400">
No hay materias pendientes. Arrastra una materia aquí para
desasignarla.
</div>
)}
</div>
</div>
{/* Modal de Edición */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent className="sm:max-w-[550px]">
<DialogHeader>
<DialogTitle className="font-bold text-slate-700">
Editar Materia
</DialogTitle>
</DialogHeader>
{/* Verificación de seguridad: solo renderiza si hay datos */}
{editingData ? (
<div className="grid gap-4 py-4">
{/* Fila 1: Clave y Nombre */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Clave
</label>
<Input
value={editingData.clave}
onChange={(e) =>
setEditingData({ ...editingData, clave: e.target.value })
}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Nombre
</label>
<Input
value={editingData.nombre}
onChange={(e) =>
setEditingData({ ...editingData, nombre: e.target.value })
}
/>
</div>
</div>
{/* Fila 2: Créditos y Horas */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Créditos
</label>
<Input type="number" value={editingData.creditos} />
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
HD (Horas Docente)
</label>
<Input type="number" value={editingData.hd} />
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
HI (Horas Indep.)
</label>
<Input type="number" value={editingData.hi} />
</div>
</div>
{/* Fila 3: Ciclo y Línea */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Ciclo
</label>
<Select value={editingData.ciclo?.toString() || 'null'}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ciclosArray.map((n) => (
<SelectItem key={n} value={n.toString()}>
Ciclo {n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Línea Curricular
</label>
<Select value={editingData.lineaCurricularId || 'null'}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{lineas.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Fila 4: Seriación (Igual a tu imagen) */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Seriación (Prerrequisitos)
</label>
<Select>
<SelectTrigger>
<SelectValue placeholder="Seleccionar materia..." />
</SelectTrigger>
<SelectContent>
{materias.map((m) => (
<SelectItem key={m.id} value={m.clave}>
{m.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-2 flex gap-2">
{/* Aquí usamos el array vacío que inicializamos en el mapeador */}
{editingData.prerrequisitos.map((pre) => (
<Badge
key={pre}
variant="secondary"
className="bg-slate-100 text-slate-600"
>
{pre} <span className="ml-1 cursor-pointer">×</span>
</Badge>
))}
</div>
</div>
{/* Fila 5: Tipo */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Tipo
</label>
<Select value={editingData.tipo}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="obligatoria">Obligatoria</SelectItem>
<SelectItem value="optativa">Optativa</SelectItem>
</SelectContent>
</Select>
</div>
<div className="mt-4 flex justify-end gap-3">
<Button
variant="outline"
onClick={() => setIsEditModalOpen(false)}
>
Cancelar
</Button>
<Button
className="bg-teal-700 text-white"
onClick={handleSaveChanges}
>
Guardar
</Button>
</div>
</div>
) : (
<div className="py-20 text-center">No hay datos seleccionados</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,9 +1,27 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import {
Plus,
Copy,
Search,
Filter,
ChevronRight,
BookOpen,
Loader2,
} from 'lucide-react'
import { useState, useMemo } from 'react'
import type { Materia } from '@/types/plan'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
@@ -12,204 +30,257 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { usePlanAsignaturas, usePlanLineas } from '@/data'
export const Route = createFileRoute('/planes/$planId/_detalle/materias')({
component: Materias,
})
type Materia = {
id: string;
clave: string
nombre: string
creditos: number
hd: number
hi: number
ciclo: string
linea: string
tipo: 'Obligatoria' | 'Optativa' | 'Troncal'
estado: 'Aprobada' | 'Revisada' | 'Borrador'
// --- Configuración de Estilos ---
const statusConfig: Record<string, { label: string; className: string }> = {
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 MATERIAS: Materia[] = [
{
id: "1",
clave: 'MAT101',
nombre: 'Cálculo Diferencial',
creditos: 8,
hd: 4,
hi: 4,
ciclo: 'Ciclo 1',
linea: 'Formación Básica',
tipo: 'Obligatoria',
estado: 'Aprobada',
},
{
id: "2",
clave: 'FIS101',
nombre: 'Física Mecánica',
creditos: 6,
hd: 3,
hi: 3,
ciclo: 'Ciclo 1',
linea: 'Formación Básica',
tipo: 'Obligatoria',
estado: 'Aprobada',
},
{
id: "3",
clave: 'PRO101',
nombre: 'Fundamentos de Programación',
creditos: 8,
hd: 4,
hi: 4,
ciclo: 'Ciclo 1',
linea: 'Ciencias de la Computación',
tipo: 'Obligatoria',
estado: 'Revisada',
},
{
id: "4",
clave: 'EST101',
nombre: 'Estructura de Datos',
creditos: 6,
hd: 3,
hi: 3,
ciclo: 'Ciclo 2',
linea: 'Ciencias de la Computación',
tipo: 'Obligatoria',
estado: 'Borrador',
},
]
const tipoConfig: Record<string, { 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' },
}
function Materias() {
const [search, setSearch] = useState('')
const [filtro, setFiltro] = useState<'Todas' | Materia['tipo']>('Todas')
// --- Mapeadores de API ---
const mapAsignaturas = (asigApi: Array<any> = []): Array<Materia> => {
return asigApi.map((asig) => ({
id: asig.id,
clave: asig.codigo,
nombre: asig.nombre,
creditos: asig.creditos ?? 0,
ciclo: asig.numero_ciclo ?? null,
lineaCurricularId: asig.linea_plan_id ?? null,
tipo:
asig.tipo?.toLowerCase() === 'obligatoria' ? 'obligatoria' : 'optativa',
estado: 'borrador', // O el campo que venga de tu API
hd: Math.floor((asig.horas_semana ?? 0) / 2),
hi: Math.ceil((asig.horas_semana ?? 0) / 2),
}))
}
const materiasFiltradas = MATERIAS.filter((m) => {
const okFiltro = filtro === 'Todas' || m.tipo === filtro
const okSearch =
m.nombre.toLowerCase().includes(search.toLowerCase()) ||
m.clave.toLowerCase().includes(search.toLowerCase())
export const Route = createFileRoute('/planes/$planId/_detalle/materias')({
component: MateriasPage,
})
return okFiltro && okSearch
function MateriasPage() {
const { planId } = Route.useParams()
const navigate = useNavigate()
// 1. Fetch de datos reales
const { data: asignaturasApi, 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 materias = useMemo(
() => mapAsignaturas(asignaturasApi),
[asignaturasApi],
)
const lineas = useMemo(() => lineasApi || [], [lineasApi])
const filteredMaterias = materias.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 totalCreditos = materiasFiltradas.reduce(
(acc, m) => acc + m.creditos,
0
)
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="space-y-6">
<div className="container mx-auto space-y-6 px-6 py-6">
{/* Header */}
<div className="flex justify-between items-start">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold">Materias del Plan</h2>
<p className="text-sm text-muted-foreground">
{materiasFiltradas.length} materias · {totalCreditos} créditos
<h2 className="text-foreground text-xl font-bold">
Materias del Plan
</h2>
<p className="text-muted-foreground mt-1 text-sm">
{materias.length} materias en total {filteredMaterias.length}{' '}
filtradas
</p>
</div>
<div className="flex gap-2">
<Button variant="outline">Clonar de mi Facultad</Button>
<Button variant="outline">Clonar de otra Facultad</Button>
<Button variant="outline" size="sm">
<Copy className="mr-2 h-4 w-4" /> Clonar
</Button>
<Button className="bg-emerald-700 hover:bg-emerald-800">
+ Nueva Materia
<Plus className="mr-2 h-4 w-4" /> Nueva Materia
</Button>
</div>
</div>
{/* Buscador y filtros */}
<div className="flex items-center gap-4">
<Input
placeholder="Buscar por nombre o clave..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-64"
/>
{/* 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-[240px] 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 gap-2">
{['Todas', 'Obligatoria', 'Optativa', 'Troncal'].map((t) => (
<Button
key={t}
variant={filtro === t ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setFiltro(t as any)}
>
{t === 'Obligatoria' ? 'Obligatorias' : t}
</Button>
))}
<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-[140px] 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-[140px] 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-[180px] 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 */}
<div className="rounded-md border">
{/* Tabla Pro */}
<div className="overflow-hidden rounded-xl border bg-white shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead>Clave</TableHead>
<TableRow className="bg-slate-50/50">
<TableHead className="w-[120px]">Clave</TableHead>
<TableHead>Nombre</TableHead>
<TableHead className="text-center">Créditos</TableHead>
<TableHead className="text-center">HD</TableHead>
<TableHead className="text-center">HI</TableHead>
<TableHead>Ciclo</TableHead>
<TableHead>Línea</TableHead>
<TableHead className="text-center">Ciclo</TableHead>
<TableHead>Línea Curricular</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Estado</TableHead>
<TableHead className="text-center">Acciones</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materiasFiltradas.map((m) => (
<TableRow key={m.clave}>
<TableCell className="text-muted-foreground">
{m.clave}
{filteredMaterias.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 materias</p>
<p className="text-xs">
Intenta cambiar los filtros de búsqueda
</p>
</div>
</TableCell>
<TableCell className="font-medium">{m.nombre}</TableCell>
<TableCell className="text-center">{m.creditos}</TableCell>
<TableCell className="text-center">{m.hd}</TableCell>
<TableCell className="text-center">{m.hi}</TableCell>
<TableCell>{m.ciclo}</TableCell>
<TableCell>{m.linea}</TableCell>
<TableCell>
<Badge variant="secondary">{m.tipo}</Badge>
</TableCell>
<TableCell>
<Badge
variant="secondary"
className={
m.estado === 'Aprobada'
? 'bg-emerald-100 text-emerald-700'
: m.estado === 'Revisada'
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-500'
</TableRow>
) : (
filteredMaterias.map((materia) => (
<TableRow
key={materia.id}
className="group cursor-pointer transition-colors hover:bg-slate-50/80"
onClick={() =>
navigate({
to: '/planes/$planId/asignaturas/$asignaturaId',
params: {
planId,
asignaturaId: materia.id, // 👈 puede ser índice, consecutivo o slug
},
state: {
realId: materia.id, // 👈 ID largo oculto
} as any,
})
}
>
{m.estado}
</Badge>
</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="icon">
</Button>
</TableCell>
</TableRow>
))}
{materiasFiltradas.length === 0 && (
<TableRow>
<TableCell
colSpan={10}
className="text-center py-6 text-muted-foreground"
>
No se encontraron materias
</TableCell>
</TableRow>
<TableCell className="font-mono text-xs font-bold text-slate-400">
{materia.clave}
</TableCell>
<TableCell className="font-semibold text-slate-700">
{materia.nombre}
</TableCell>
<TableCell className="text-center font-medium">
{materia.creditos}
</TableCell>
<TableCell className="text-center">
{materia.ciclo ? (
<Badge variant="outline" className="font-normal">
Ciclo {materia.ciclo}
</Badge>
) : (
<span className="text-slate-300"></span>
)}
</TableCell>
<TableCell className="text-sm text-slate-600">
{getLineaNombre(materia.lineaCurricularId)}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`capitalize shadow-sm ${tipoConfig[materia.tipo]?.className}`}
>
{tipoConfig[materia.tipo]?.label}
</Badge>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={`capitalize shadow-sm ${statusConfig[materia.estado]?.className}`}
>
{statusConfig[materia.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>

View File

@@ -1,6 +1,16 @@
import { createFileRoute, Outlet, Link } from '@tanstack/react-router'
import { ChevronLeft, GraduationCap, Clock, Hash, CalendarDays, Rocket, BookOpen, CheckCircle2 } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import {
ChevronLeft,
GraduationCap,
Clock,
Hash,
CalendarDays,
Rocket,
BookOpen,
CheckCircle2,
} from 'lucide-react'
import { Badge } from '@/components/ui/badge'
export const Route = createFileRoute('/planes/$planId/_detalle')({
component: RouteComponent,
@@ -12,11 +22,11 @@ function RouteComponent() {
return (
<div className="min-h-screen bg-white">
{/* 1. Header Superior con Sombra (Volver a planes) */}
<div className="border-b bg-white/50 backdrop-blur-sm sticky top-0 z-20 shadow-sm">
<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 items-center gap-1 text-xs text-gray-500 hover:text-gray-800 transition-colors w-fit"
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>
@@ -24,54 +34,91 @@ function RouteComponent() {
</div>
{/* 2. Contenido Principal con Padding */}
<div className="p-8 max-w-[1600px] mx-auto space-y-8">
<div className="mx-auto max-w-[1600px] space-y-8 p-8">
{/* Header del Plan y Badges */}
<div className="flex flex-col md:flex-row justify-between items-start gap-4">
<div className="flex flex-col items-start justify-between gap-4 md:flex-row">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">Plan de Estudios 2024</h1>
<p className="text-lg text-slate-500 font-medium mt-1">
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Plan de Estudios 2024
</h1>
<p className="mt-1 text-lg font-medium text-slate-500">
Ingeniería en Sistemas Computacionales
</p>
</div>
{/* Badges de la derecha */}
<div className="flex gap-2">
<Badge variant="secondary" className="bg-blue-50 text-blue-700 border-blue-100 gap-1 px-3">
<Badge
variant="secondary"
className="gap-1 border-blue-100 bg-blue-50 px-3 text-blue-700"
>
<Rocket size={12} /> Ingeniería
</Badge>
<Badge variant="secondary" className="bg-orange-50 text-orange-700 border-orange-100 gap-1 px-3">
<Badge
variant="secondary"
className="gap-1 border-orange-100 bg-orange-50 px-3 text-orange-700"
>
<BookOpen size={12} /> Licenciatura
</Badge>
<Badge className="bg-teal-50 text-teal-700 border-teal-200 gap-1 px-3 hover:bg-teal-100">
<Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
<CheckCircle2 size={12} /> En Revisión
</Badge>
</div>
</div>
{/* 3. Cards de Información (Nivel, Duración, etc.) */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<InfoCard icon={<GraduationCap className="text-slate-400" />} label="Nivel" value="Superior" />
<InfoCard icon={<Clock className="text-slate-400" />} label="Duración" value="9 Semestres" />
<InfoCard icon={<Hash className="text-slate-400" />} label="Créditos" value="320" />
<InfoCard icon={<CalendarDays className="text-slate-400" />} label="Creación" value="14 ene 2024" />
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<InfoCard
icon={<GraduationCap className="text-slate-400" />}
label="Nivel"
value="Superior"
/>
<InfoCard
icon={<Clock className="text-slate-400" />}
label="Duración"
value="9 Semestres"
/>
<InfoCard
icon={<Hash className="text-slate-400" />}
label="Créditos"
value="320"
/>
<InfoCard
icon={<CalendarDays className="text-slate-400" />}
label="Creación"
value="14 ene 2024"
/>
</div>
{/* 4. Navegación de Tabs */}
<div className="border-b overflow-x-auto scrollbar-hide">
<nav className="flex gap-8 min-w-max">
<Tab to="/planes/$planId/datos" params={{ planId }}>Datos Generales</Tab>
<Tab to="/planes/$planId/mapa" params={{ planId }}>Mapa Curricular</Tab>
<Tab to="/planes/$planId/materias" params={{ planId }}>Materias</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>
<div className="scrollbar-hide overflow-x-auto border-b">
<nav className="flex min-w-max gap-8">
<Tab to="/planes/$planId/datos" params={{ planId }}>
Datos Generales
</Tab>
<Tab to="/planes/$planId/mapa" params={{ planId }}>
Mapa Curricular
</Tab>
<Tab to="/planes/$planId/materias" params={{ planId }}>
Materias
</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>
{/* 5. Contenido del Tab */}
<main className="pt-2 animate-in fade-in duration-500">
<main className="animate-in fade-in pt-2 duration-500">
<Outlet />
</main>
</div>
@@ -79,16 +126,23 @@ function RouteComponent() {
)
}
// Sub-componente para las tarjetas de información
function InfoCard({ icon, label, value }: { icon: React.ReactNode, label: string, value: string }) {
function InfoCard({
icon,
label,
value,
}: {
icon: React.ReactNode
label: string
value: string
}) {
return (
<div className="flex items-center gap-4 bg-slate-50/50 border border-slate-200/60 p-4 rounded-xl shadow-sm">
<div className="p-2 bg-white rounded-lg border shadow-sm">
{icon}
</div>
<div className="flex items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm">
<div className="rounded-lg border bg-white p-2 shadow-sm">{icon}</div>
<div>
<p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider leading-none mb-1">{label}</p>
<p className="mb-1 text-[10px] leading-none font-bold tracking-wider text-slate-400 uppercase">
{label}
</p>
<p className="text-sm font-semibold text-slate-700">{value}</p>
</div>
</div>
@@ -96,19 +150,19 @@ function InfoCard({ icon, label, value }: { icon: React.ReactNode, label: string
}
function Tab({
to,
params,
children
}: {
to: string;
params?: any;
children: React.ReactNode
}) {
to,
params,
children,
}: {
to: string
params?: any
children: React.ReactNode
}) {
return (
<Link
to={to}
params={params}
className="pb-3 text-sm font-medium text-slate-500 border-b-2 border-transparent hover:text-slate-800 transition-all"
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',
}}

View File

@@ -1,146 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import {
BookOpen,
Sparkles,
FileText,
Library,
LayoutTemplate,
History,
ArrowRight,
GraduationCap,
} from 'lucide-react'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/'
)({
component: MateriasLandingPage,
export const Route = createFileRoute('/planes/$planId/asignaturas/')({
beforeLoad: ({ params }) => {
throw redirect({
to: '/planes/$planId/materias',
params,
})
},
})
export default function MateriasLandingPage() {
return (
<div className="w-full">
{/* ================= HERO ================= */}
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<div className="max-w-7xl mx-auto px-6 py-28">
<div className="flex items-center gap-2 mb-6 text-sm text-blue-200">
<GraduationCap className="w-5 h-5 text-yellow-400" />
<span>SISTEMA DE GESTIÓN CURRICULAR</span>
</div>
<h1 className="text-5xl font-bold mb-6">
Universidad La Salle
</h1>
<p className="max-w-xl text-lg text-blue-100 mb-10">
Diseña, documenta y mejora programas de estudio con herramientas
de inteligencia artificial integradas y cumplimiento normativo SEP.
</p>
<Button
size="lg"
className="bg-yellow-400 text-black hover:bg-yellow-300 font-semibold"
>
Ver materia de ejemplo
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</section>
{/* ================= FEATURES ================= */}
<section className="bg-white py-24">
<div className="max-w-7xl mx-auto px-6">
<h2 className="text-center text-2xl font-semibold mb-14">
Características principales
</h2>
<div className="grid gap-8 md:grid-cols-3">
<FeatureCard
icon={<BookOpen />}
title="Gestión de Materias"
description="Edita datos generales, contenido temático y bibliografía con una interfaz intuitiva."
/>
<FeatureCard
icon={<Sparkles />}
title="IA Integrada"
description="Usa inteligencia artificial para mejorar objetivos, competencias y alinear con perfiles de egreso."
/>
<FeatureCard
icon={<FileText />}
title="Documentos SEP"
description="Genera automáticamente documentos oficiales para la Secretaría de Educación Pública."
/>
<FeatureCard
icon={<Library />}
title="Biblioteca Digital"
description="Busca y vincula recursos del repositorio de Biblioteca La Salle directamente."
/>
<FeatureCard
icon={<LayoutTemplate />}
title="Plantillas Flexibles"
description="Adapta la estructura de materias según plantillas SEP o institucionales."
/>
<FeatureCard
icon={<History />}
title="Historial Completo"
description="Rastrea todos los cambios con historial detallado por usuario y fecha."
/>
</div>
</div>
</section>
{/* ================= CTA ================= */}
<section className="bg-gray-50 py-20">
<div className="max-w-3xl mx-auto text-center px-6">
<h3 className="text-xl font-semibold mb-4">
Explora la vista de detalle de materia
</h3>
<p className="text-muted-foreground mb-8">
Navega por las diferentes pestañas para ver cómo funciona el sistema
de gestión curricular.
</p>
<Button size="lg" className="bg-[#0e2a5c] hover:bg-[#0b1d3a]">
Ir a Inteligencia Artificial Aplicada
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</section>
</div>
)
}
/* ================= FEATURE CARD ================= */
function FeatureCard({
icon,
title,
description,
}: {
icon: React.ReactNode
title: string
description: string
}) {
return (
<Card className="border border-gray-200 shadow-sm">
<CardContent className="p-6 space-y-4">
<div className="w-10 h-10 rounded-md bg-yellow-100 text-yellow-600 flex items-center justify-center">
{icon}
</div>
<h4 className="font-semibold">{title}</h4>
<p className="text-sm text-muted-foreground">
{description}
</p>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,10 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/planes/$planId/')({
beforeLoad: ({ params }) => {
throw redirect({
to: '/planes/$planId/datos',
params,
})
},
})

View File

@@ -223,7 +223,17 @@ function RouteComponent() {
estado={estado?.etiqueta ?? 'Desconocido'}
claseColorEstado={estadoColor}
colorFacultad={facultad?.color ?? '#000000'}
onClick={() => console.log('Ver plan', plan.id)}
onClick={() =>
navigate({
to: '/planes/$planId',
params: {
planId: plan.id,
},
state: {
realId: plan.id, // 👈 ID largo oculto
} as any,
})
}
/>
)
})}

View File

@@ -4,92 +4,97 @@ export type PlanStatus =
| 'expertos'
| 'consejo'
| 'aprobado'
| 'rechazado';
| 'rechazado'
export type TipoPlan = 'Licenciatura' | 'Maestría' | 'Doctorado' | 'Especialidad';
export type TipoPlan =
| 'Licenciatura'
| 'Maestría'
| 'Doctorado'
| 'Especialidad'
export type TipoMateria = 'obligatoria' | 'optativa' | 'troncal';
export type TipoMateria = 'obligatoria' | 'optativa' | 'troncal'
export type MateriaStatus = 'borrador' | 'revisada' | 'aprobada';
export type MateriaStatus = 'borrador' | 'revisada' | 'aprobada'
export interface Facultad {
id: string;
nombre: string;
color: string;
icono: string;
id: string
nombre: string
color: string
icono: string
}
export interface Carrera {
id: string;
nombre: string;
facultadId: string;
id: string
nombre: string
facultadId: string
}
export interface LineaCurricular {
id: string;
nombre: string;
orden: number;
color?: string;
id: string
nombre: string
orden: number
color?: string
}
export interface Materia {
id: string;
clave: string;
nombre: string;
creditos: number;
ciclo: number | null;
lineaCurricularId: string | null;
tipo: TipoMateria;
estado: MateriaStatus;
orden?: number;
hd: number; // <--- Añadir
hi: number; // <--- Añadir
id: string
clave: string
nombre: string
creditos: number
ciclo: number | null
lineaCurricularId: string | null
tipo: TipoMateria
estado: MateriaStatus
orden?: number
hd: number // <--- Añadir
hi: number // <--- Añadir
prerrequisitos: Array<string>
}
export interface Plan {
id: string;
nombre: string;
carrera: Carrera;
facultad: Facultad;
tipoPlan: TipoPlan;
nivel?: string;
modalidad?: string;
duracionCiclos: number;
creditosTotales: number;
fechaCreacion: string;
estadoActual: PlanStatus;
id: string
nombre: string
carrera: Carrera
facultad: Facultad
tipoPlan: TipoPlan
nivel?: string
modalidad?: string
duracionCiclos: number
creditosTotales: number
fechaCreacion: string
estadoActual: PlanStatus
}
export interface DatosGeneralesField {
id: string;
label: string;
value: string;
tipo: 'texto' | 'lista' | 'parrafo';
requerido: boolean;
id: string
label: string
value: string
tipo: 'texto' | 'lista' | 'parrafo'
requerido: boolean
}
export interface CambioPlan {
id: string;
fecha: string;
usuario: string;
tab: string;
descripcion: string;
detalle?: string;
id: string
fecha: string
usuario: string
tab: string
descripcion: string
detalle?: string
}
export interface ComentarioFlujo {
id: string;
usuario: string;
fecha: string;
texto: string;
fase: PlanStatus;
id: string
usuario: string
fecha: string
texto: string
fase: PlanStatus
}
export interface DocumentoPlan {
id: string;
fechaGeneracion: string;
version: number;
url?: string;
id: string
fechaGeneracion: string
version: number
url?: string
}
export type PlanTab =
@@ -99,4 +104,4 @@ export type PlanTab =
| 'flujo'
| 'ia'
| 'documento'
| 'historial';
| 'historial'