2 Commits

78 changed files with 4899 additions and 9690 deletions

View File

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

1
.gitignore vendored
View File

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

View File

@@ -8,7 +8,6 @@
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
@@ -20,7 +19,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@stepperize/react": "^5.1.9", "@stepperize/react": "^5.1.9",
"@supabase/supabase-js": "^2.98.0", "@supabase/supabase-js": "^2.90.1",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0", "@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5", "@tanstack/react-query": "^5.66.5",
@@ -36,14 +35,12 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"motion": "^12.24.7", "motion": "^12.24.7",
"radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.6", "tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0", "use-debounce": "^10.1.0",
"vaul": "^1.1.2",
}, },
"devDependencies": { "devDependencies": {
"@tanstack/devtools-vite": "^0.3.11", "@tanstack/devtools-vite": "^0.3.11",
@@ -253,16 +250,10 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.7", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A=="],
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], "@radix-ui/react-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-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="], "@radix-ui/react-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-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=="],
@@ -275,8 +266,6 @@
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
@@ -289,24 +278,12 @@
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-form": ["@radix-ui/react-form@0.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ=="],
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], "@radix-ui/react-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-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="],
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="],
"@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg=="],
"@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], "@radix-ui/react-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=="], "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
@@ -317,10 +294,6 @@
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], "@radix-ui/react-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-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
@@ -329,22 +302,10 @@
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="],
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
"@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="],
"@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
@@ -441,17 +402,17 @@
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.7.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.53.1", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-zjTUwIsEfT+k9BmXwq1QEFYsb4afBlsI1AXFyWQBgggMzwBFOuu92pGrE5OFx90IOjNl+lUbQoTG7f8S0PkOdg=="], "@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.7.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.53.1", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-zjTUwIsEfT+k9BmXwq1QEFYsb4afBlsI1AXFyWQBgggMzwBFOuu92pGrE5OFx90IOjNl+lUbQoTG7f8S0PkOdg=="],
"@supabase/auth-js": ["@supabase/auth-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg=="], "@supabase/auth-js": ["@supabase/auth-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-pC0Ek4xk4z6q7A/3+UuZ/eYgfFUUQTg3DhapzrAgJnFGDJDFDyGCj6v9nIz8+3jfLqSZ3QKGe6AoEodYjShghg=="],
"@supabase/functions-js": ["@supabase/functions-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg=="], "@supabase/functions-js": ["@supabase/functions-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Ott2IcIXHGupaC0nX9WNEiJAX4OdlGRu9upkkURaQHbaLdz9JuCcHxlwTERgtgjMpikbIWHfMM1M9QTQFYABiA=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg=="], "@supabase/postgrest-js": ["@supabase/postgrest-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uRKKQJBDnfi6XFNFPNMh9+u3HT2PCgp065PcMPmG7e0xGuqvLtN89QxO2/SZcGbw2y1+mNBz0yUs5KmyNqF2fA=="],
"@supabase/realtime-js": ["@supabase/realtime-js@2.98.0", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw=="], "@supabase/realtime-js": ["@supabase/realtime-js@2.93.1", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-2WaP/KVHPlQDjWM6qe4wOZz6zSRGaXw1lfXf4thbfvk3C3zPPKqXRyspyYnk3IhphyxSsJ2hQ/cXNOz48008tg=="],
"@supabase/storage-js": ["@supabase/storage-js@2.98.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ=="], "@supabase/storage-js": ["@supabase/storage-js@2.93.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-3KVwd4S1i1BVPL6KIywe5rnruNQXSkLyvrdiJmwnqwbCcDujQumARdGWBPesqCjOPKEU2M9ORWKAsn+2iLzquA=="],
"@supabase/supabase-js": ["@supabase/supabase-js@2.98.0", "", { "dependencies": { "@supabase/auth-js": "2.98.0", "@supabase/functions-js": "2.98.0", "@supabase/postgrest-js": "2.98.0", "@supabase/realtime-js": "2.98.0", "@supabase/storage-js": "2.98.0" } }, "sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw=="], "@supabase/supabase-js": ["@supabase/supabase-js@2.93.1", "", { "dependencies": { "@supabase/auth-js": "2.93.1", "@supabase/functions-js": "2.93.1", "@supabase/postgrest-js": "2.93.1", "@supabase/realtime-js": "2.93.1", "@supabase/storage-js": "2.93.1" } }, "sha512-FJTgS5s0xEgRQ3u7gMuzGObwf3jA4O5Ki/DgCDXx94w1pihLM4/WG3XFa4BaCJYfuzLxLcv6zPPA5tDvBUjAUg=="],
"@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=="], "@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=="],
@@ -1197,8 +1158,6 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
@@ -1389,8 +1348,6 @@
"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=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vite": ["vite@7.3.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-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=="], "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=="],
@@ -1459,8 +1416,6 @@
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-form/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -1473,8 +1428,6 @@
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-toolbar/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@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=="],
@@ -1531,14 +1484,6 @@
"is-bun-module/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "is-bun-module/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"radix-ui/@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
"radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
"radix-ui/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],

View File

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

View File

@@ -33,7 +33,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@stepperize/react": "^5.1.9", "@stepperize/react": "^5.1.9",
"@supabase/supabase-js": "^2.98.0", "@supabase/supabase-js": "^2.90.1",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0", "@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5", "@tanstack/react-query": "^5.66.5",
@@ -49,14 +49,12 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"motion": "^12.24.7", "motion": "^12.24.7",
"radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.6", "tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6", "tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0", "use-debounce": "^10.1.0"
"vaul": "^1.1.2"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/devtools-vite": "^0.3.11", "@tanstack/devtools-vite": "^0.3.11",

View File

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

Before

Width:  |  Height:  |  Size: 10 KiB

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */ import {
/* eslint-disable jsx-a11y/label-has-associated-control */ Plus,
/* eslint-disable jsx-a11y/no-static-element-interactions */ Search,
import { useParams } from '@tanstack/react-router' BookOpen,
import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react' Trash2,
import { useState } from 'react' Library,
Edit3,
Save,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { import {
AlertDialog, AlertDialog,
@@ -34,13 +38,40 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import { useSubjectBibliografia } from '@/data/hooks/useSubjects'
useCreateBibliografia,
useDeleteBibliografia,
useSubjectBibliografia,
useUpdateBibliografia,
} from '@/data/hooks/useSubjects'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
// import { toast } from 'sonner';
// import { mockLibraryResources } from '@/data/mockMateriaData';
export const mockLibraryResources = [
{
id: 'lib-1',
titulo: 'Deep Learning',
autor: 'Goodfellow, I., Bengio, Y., & Courville, A.',
editorial: 'MIT Press',
anio: 2016,
isbn: '9780262035613',
disponible: true,
},
{
id: 'lib-2',
titulo: 'Artificial Intelligence: A Modern Approach',
autor: 'Russell, S., & Norvig, P.',
editorial: 'Pearson',
anio: 2020,
isbn: '9780134610993',
disponible: true,
},
{
id: 'lib-3',
titulo: 'Hands-On Machine Learning',
autor: 'Aurélien Géron',
editorial: "O'Reilly Media",
anio: 2019,
isbn: '9781492032649',
disponible: false,
},
]
// --- Interfaces --- // --- Interfaces ---
export interface BibliografiaEntry { export interface BibliografiaEntry {
@@ -53,21 +84,24 @@ export interface BibliografiaEntry {
fuenteBiblioteca?: any fuenteBiblioteca?: any
} }
export function BibliographyItem() { interface BibliografiaTabProps {
const { asignaturaId } = useParams({ id: string
from: '/planes/$planId/asignaturas/$asignaturaId', bibliografia: Array<BibliografiaEntry>
}) onSave: (bibliografia: Array<BibliografiaEntry>) => void
isSaving: boolean
}
// --- 1. Única fuente de verdad: La Query --- export function BibliographyItem({
const { data: bibliografia = [], isLoading } = bibliografia,
useSubjectBibliografia(asignaturaId) id,
onSave,
isSaving,
}: BibliografiaTabProps) {
console.log(id)
// --- 2. Mutaciones --- const { data: bibliografia2, isLoading: loadinmateria } =
const { mutate: crearBibliografia } = useCreateBibliografia() useSubjectBibliografia(id)
const { mutate: actualizarBibliografia } = useUpdateBibliografia(asignaturaId) const [entries, setEntries] = useState<Array<BibliografiaEntry>>(bibliografia)
const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId)
// --- 3. Estados de UI (Solo para diálogos y edición) ---
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false) const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = useState<string | null>(null)
@@ -76,27 +110,30 @@ export function BibliographyItem() {
'BASICA', 'BASICA',
) )
console.log('Datos actuales en el front:', bibliografia) useEffect(() => {
// --- 4. Derivación de datos (Se calculan en cada render) --- if (bibliografia2 && Array.isArray(bibliografia2)) {
const basicaEntries = bibliografia.filter((e) => e.tipo === 'BASICA') setEntries(bibliografia2)
const complementariaEntries = bibliografia.filter( } 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', (e) => e.tipo === 'COMPLEMENTARIA',
) )
console.log(bibliografia2)
// --- Handlers Conectados a la Base de Datos ---
const handleAddManual = (cita: string) => { const handleAddManual = (cita: string) => {
crearBibliografia( const newEntry: BibliografiaEntry = {
{ id: `manual-${Date.now()}`,
asignatura_id: asignaturaId,
tipo: newEntryType, tipo: newEntryType,
cita, cita,
tipo_fuente: 'MANUAL', }
}, setEntries([...entries, newEntry])
{ setIsAddDialogOpen(false)
onSuccess: () => setIsAddDialogOpen(false), // toast.success('Referencia manual añadida');
},
)
} }
const handleAddFromLibrary = ( const handleAddFromLibrary = (
@@ -104,43 +141,22 @@ export function BibliographyItem() {
tipo: 'BASICA' | 'COMPLEMENTARIA', tipo: 'BASICA' | 'COMPLEMENTARIA',
) => { ) => {
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.` const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`
crearBibliografia( const newEntry: BibliografiaEntry = {
{ id: `lib-ref-${Date.now()}`,
asignatura_id: asignaturaId,
tipo, tipo,
cita, cita,
tipo_fuente: 'BIBLIOTECA', fuenteBibliotecaId: resource.id,
biblioteca_item_id: resource.id, fuenteBiblioteca: resource,
}, }
{ setEntries([...entries, newEntry])
onSuccess: () => setIsLibraryDialogOpen(false), setIsLibraryDialogOpen(false)
}, // toast.success('Añadido desde biblioteca');
)
} }
const handleUpdateCita = (id: string, nuevaCita: string) => { const handleUpdateCita = (id: string, cita: string) => {
actualizarBibliografia( setEntries(entries.map((e) => (e.id === id ? { ...e, cita } : e)))
{
id,
updates: { cita: nuevaCita },
},
{
onSuccess: () => setEditingId(null),
},
)
} }
const onConfirmDelete = () => {
if (deleteId) {
eliminarBibliografia(deleteId, {
onSuccess: () => setDeleteId(null),
})
}
}
if (isLoading)
return <div className="p-10 text-center">Cargando bibliografía...</div>
return ( return (
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 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 className="flex items-center justify-between border-b pb-4">
@@ -168,13 +184,8 @@ export function BibliographyItem() {
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<LibrarySearchDialog <LibrarySearchDialog
// CORRECCIÓN: Usamos 'bibliografia' en lugar de 'bibliografia2'
resources={[]} // Aquí deberías pasar el catálogo general, no la bibliografía de la asignatura
onSelect={handleAddFromLibrary} onSelect={handleAddFromLibrary}
// CORRECCIÓN: Usamos 'bibliografia' en lugar de 'entries' existingIds={entries.map((e) => e.fuenteBibliotecaId || '')}
existingIds={bibliografia.map(
(e) => e.biblioteca_item_id || '',
)}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -193,6 +204,15 @@ export function BibliographyItem() {
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<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>
</div> </div>
@@ -254,7 +274,13 @@ export function BibliographyItem() {
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel> <AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={onConfirmDelete} className="bg-red-600"> <AlertDialogAction
onClick={() => {
setEntries(entries.filter((e) => e.id !== deleteId))
setDeleteId(null)
}}
className="bg-red-600"
>
Eliminar Eliminar
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
@@ -407,16 +433,14 @@ function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
) )
} }
function LibrarySearchDialog({ resources, onSelect, existingIds }: any) { function LibrarySearchDialog({ onSelect, existingIds }: any) {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA') const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA')
const filtered = (resources || []).filter( const filtered = mockLibraryResources.filter(
(r: any) => (r) =>
!existingIds.includes(r.id) && !existingIds.includes(r.id) &&
r.titulo?.toLowerCase().includes(search.toLowerCase()), r.titulo.toLowerCase().includes(search.toLowerCase()),
) )
console.log(filtered)
console.log(resources)
return ( return (
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
@@ -444,7 +468,7 @@ function LibrarySearchDialog({ resources, onSelect, existingIds }: any) {
</Select> </Select>
</div> </div>
<div className="max-h-[300px] space-y-2 overflow-y-auto pr-2"> <div className="max-h-[300px] space-y-2 overflow-y-auto pr-2">
{filtered.map((res: any) => ( {filtered.map((res) => (
<div <div
key={res.id} key={res.id}
onClick={() => onSelect(res, tipo)} onClick={() => onSelect(res, tipo)}

View File

@@ -1,4 +1,4 @@
import { useParams } from '@tanstack/react-router' import { useEffect, useState } from 'react'
import { import {
Plus, Plus,
GripVertical, GripVertical,
@@ -7,12 +7,17 @@ import {
Edit3, Edit3,
Trash2, Trash2,
Clock, Clock,
Save,
} from 'lucide-react' } from 'lucide-react'
import { useEffect, useRef, useState } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api' import { Input } from '@/components/ui/input'
import type { FocusEvent, KeyboardEvent } from 'react' import { Badge } from '@/components/ui/badge'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -23,18 +28,8 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { useSubject, useUpdateSubjectContenido } from '@/data/hooks/useSubjects'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
// import { toast } from 'sonner'; //import { toast } from 'sonner';
export interface Tema { export interface Tema {
id: string id: string
@@ -47,306 +42,78 @@ export interface UnidadTematica {
id: string id: string
nombre: string nombre: string
numero: number numero: number
temas: Array<Tema> temas: Tema[]
} }
function isRecord(value: unknown): value is Record<string, unknown> { const initialData: UnidadTematica[] = [
return typeof value === 'object' && value !== null && !Array.isArray(value) {
} id: 'u1',
numero: 1,
nombre: 'Fundamentos de Inteligencia Artificial',
temas: [
{ id: 't1', nombre: 'Tipos de IA y aplicaciones', horasEstimadas: 6 },
{ id: 't2', nombre: 'Ética en IA', horasEstimadas: 3 },
],
},
]
function coerceNumber(value: unknown): number | undefined { // Estructura que viene de tu JSON/API
if (typeof value === 'number' && Number.isFinite(value)) return value interface ContenidoApi {
if (typeof value === 'string') { unidad: number
const trimmed = value.trim() titulo: string
if (!trimmed) return undefined temas: string[] | any[] // Acepta strings o objetos
const parsed = Number(trimmed) [key: string]: any // Esta línea permite que haya más claves desconocidas
return Number.isFinite(parsed) ? parsed : undefined
}
return undefined
}
function coerceString(value: unknown): string | undefined {
if (typeof value === 'string') return value
return undefined
}
function mapTemaValue(value: unknown): ContenidoTemaApi | null {
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed ? trimmed : null
}
if (isRecord(value)) {
const nombre = coerceString(value.nombre)
if (!nombre) return null
const horasEstimadas = coerceNumber(value.horasEstimadas)
const descripcion = coerceString(value.descripcion)
return {
...value,
nombre,
horasEstimadas,
descripcion,
}
}
return null
}
function mapContenidoItem(value: unknown, index: number): ContenidoApi | null {
if (!isRecord(value)) return null
const unidad = coerceNumber(value.unidad) ?? index + 1
const titulo = coerceString(value.titulo) ?? 'Sin título'
let temas: Array<ContenidoTemaApi> = []
if (Array.isArray(value.temas)) {
temas = value.temas
.map(mapTemaValue)
.filter((t): t is ContenidoTemaApi => t !== null)
} else if (typeof value.temas === 'string' && value.temas.trim()) {
temas = value.temas
.split(/\r?\n|,/)
.map((t) => t.trim())
.filter(Boolean)
}
return { unidad, titulo, temas }
}
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
if (value == null) return []
if (typeof value === 'string') {
try {
return mapContenidoTematicoFromDb(JSON.parse(value))
} catch {
return []
}
}
if (Array.isArray(value)) {
return value
.map((item, idx) => mapContenidoItem(item, idx))
.filter((x): x is ContenidoApi => x !== null)
}
if (isRecord(value)) {
if (Array.isArray(value.contenido_tematico)) {
return mapContenidoTematicoFromDb(value.contenido_tematico)
}
if (Array.isArray(value.unidades)) {
return mapContenidoTematicoFromDb(value.unidades)
}
}
return []
}
function serializeUnidadesToApi(
unidades: Array<UnidadTematica>,
): Array<ContenidoApi> {
return unidades
.slice()
.sort((a, b) => a.numero - b.numero)
.map((u, idx) => ({
unidad: u.numero || idx + 1,
titulo: u.nombre || 'Sin título',
temas: u.temas.map((t) => ({
nombre: t.nombre || 'Tema',
horasEstimadas: t.horasEstimadas ?? 0,
descripcion: t.descripcion,
})),
}))
} }
// Props del componente // Props del componente
interface ContenidoTematicoProps {
export function ContenidoTematico() { data: {
const updateContenido = useUpdateSubjectContenido() contenido_tematico: ContenidoApi[]
const { asignaturaId } = useParams({ }
from: '/planes/$planId/asignaturas/$asignaturaId', isLoading: boolean
}) }
export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
const { data: data, isLoading: isLoading } = useSubject(asignaturaId) const [unidades, setUnidades] = useState<UnidadTematica[]>([])
const [unidades, setUnidades] = useState<Array<UnidadTematica>>([]) const [expandedUnits, setExpandedUnits] = useState<Set<string>>(
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(new Set()) new Set(['u1']),
const unitContainerRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const unitTitleInputRef = useRef<HTMLInputElement | null>(null)
const temaNombreInputElRef = useRef<HTMLInputElement | null>(null)
const [pendingScrollUnitId, setPendingScrollUnitId] = useState<string | null>(
null,
) )
const cancelNextBlurRef = useRef(false)
const [deleteDialog, setDeleteDialog] = useState<{ const [deleteDialog, setDeleteDialog] = useState<{
type: 'unidad' | 'tema' type: 'unidad' | 'tema'
id: string id: string
parentId?: string parentId?: string
} | null>(null) } | null>(null)
const [editingUnit, setEditingUnit] = useState<string | null>(null) const [editingUnit, setEditingUnit] = useState<string | null>(null)
const [unitDraftNombre, setUnitDraftNombre] = useState('')
const [unitOriginalNombre, setUnitOriginalNombre] = useState('')
const [editingTema, setEditingTema] = useState<{ const [editingTema, setEditingTema] = useState<{
unitId: string unitId: string
temaId: string temaId: string
} | null>(null) } | null>(null)
const [temaDraftNombre, setTemaDraftNombre] = useState('') const [isSaving, setIsSaving] = useState(false)
const [temaOriginalNombre, setTemaOriginalNombre] = useState('')
const [temaDraftHoras, setTemaDraftHoras] = useState('')
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
const payload = serializeUnidadesToApi(nextUnidades)
await updateContenido.mutateAsync({
subjectId: asignaturaId,
unidades: payload,
})
}
const beginEditUnit = (unitId: string) => {
const unit = unidades.find((u) => u.id === unitId)
const nombre = unit?.nombre ?? ''
setEditingUnit(unitId)
setUnitDraftNombre(nombre)
setUnitOriginalNombre(nombre)
setExpandedUnits((prev) => {
const next = new Set(prev)
next.add(unitId)
return next
})
}
const commitEditUnit = () => {
if (!editingUnit) return
const next = unidades.map((u) =>
u.id === editingUnit ? { ...u, nombre: unitDraftNombre } : u,
)
setUnidades(next)
setEditingUnit(null)
void persistUnidades(next)
}
const cancelEditUnit = () => {
setEditingUnit(null)
setUnitDraftNombre(unitOriginalNombre)
}
const beginEditTema = (unitId: string, temaId: string) => {
const unit = unidades.find((u) => u.id === unitId)
const tema = unit?.temas.find((t) => t.id === temaId)
const nombre = tema?.nombre ?? ''
const horas = tema?.horasEstimadas ?? 0
setEditingTema({ unitId, temaId })
setTemaDraftNombre(nombre)
setTemaOriginalNombre(nombre)
setTemaDraftHoras(String(horas))
setTemaOriginalHoras(horas)
setExpandedUnits((prev) => {
const next = new Set(prev)
next.add(unitId)
return next
})
}
const commitEditTema = () => {
if (!editingTema) return
const parsedHoras = Number.parseInt(temaDraftHoras, 10)
const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
const next = unidades.map((u) => {
if (u.id !== editingTema.unitId) return u
return {
...u,
temas: u.temas.map((t) =>
t.id === editingTema.temaId
? { ...t, nombre: temaDraftNombre, horasEstimadas }
: t,
),
}
})
setUnidades(next)
setEditingTema(null)
void persistUnidades(next)
}
const cancelEditTema = () => {
setEditingTema(null)
setTemaDraftNombre(temaOriginalNombre)
setTemaDraftHoras(String(temaOriginalHoras))
}
const handleTemaEditorBlurCapture = (e: FocusEvent<HTMLDivElement>) => {
if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false
return
}
const nextFocus = e.relatedTarget as Node | null
if (nextFocus && e.currentTarget.contains(nextFocus)) return
commitEditTema()
}
const handleTemaEditorKeyDownCapture = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
if (e.target instanceof HTMLElement) e.target.blur()
return
}
if (e.key === 'Escape') {
e.preventDefault()
cancelNextBlurRef.current = true
cancelEditTema()
if (e.target instanceof HTMLElement) e.target.blur()
}
}
useEffect(() => { useEffect(() => {
const contenido = mapContenidoTematicoFromDb( if (data?.contenido_tematico) {
data ? data.contenido_tematico : undefined, const transformed = data.contenido_tematico.map(
) (u: any, idx: number) => ({
id: `u-${idx}`,
const transformed = contenido.map((u, idx) => ({
id: `u-${u.unidad || idx + 1}`,
numero: u.unidad || idx + 1, numero: u.unidad || idx + 1,
nombre: u.titulo || 'Sin título', nombre: u.titulo || 'Sin título',
temas: Array.isArray(u.temas) temas: Array.isArray(u.temas)
? u.temas.map((t: any, tidx: number) => ({ ? u.temas.map((t: any, tidx: number) => ({
id: `t-${u.unidad || idx + 1}-${tidx + 1}`, id: `t-${idx}-${tidx}`,
nombre: typeof t === 'string' ? t : t?.nombre || 'Tema', nombre: typeof t === 'string' ? t : t.nombre || 'Tema',
horasEstimadas: t?.horasEstimadas || 0, horasEstimadas: t.horasEstimadas || 0,
})) }))
: [], : [],
})) }),
setUnidades(transformed)
// Mantener las unidades ya expandidas si existen; si no, expandir la primera.
setExpandedUnits((prev) => {
const validIds = new Set(transformed.map((u) => u.id))
const filtered = new Set(
Array.from(prev).filter((id) => validIds.has(id)),
) )
if (filtered.size > 0) return filtered setUnidades(transformed)
return transformed.length > 0 ? new Set([transformed[0].id]) : new Set()
}) // Expandir la primera unidad automáticamente
if (transformed.length > 0) {
setExpandedUnits(new Set([transformed[0].id]))
}
}
}, [data]) }, [data])
useEffect(() => {
if (!editingUnit) return
// Foco controlado (evitamos autoFocus por lint/a11y)
setTimeout(() => unitTitleInputRef.current?.focus(), 0)
}, [editingUnit])
useEffect(() => {
if (!editingTema) return
setTimeout(() => temaNombreInputElRef.current?.focus(), 0)
}, [editingTema])
useEffect(() => {
if (!pendingScrollUnitId) return
const el = unitContainerRefs.current.get(pendingScrollUnitId)
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
setPendingScrollUnitId(null)
}, [pendingScrollUnitId, unidades.length])
if (isLoading) if (isLoading)
return <div className="p-10 text-center">Cargando contenido...</div> return <div className="p-10 text-center">Cargando contenido...</div>
@@ -365,77 +132,80 @@ export function ContenidoTematico() {
} }
const addUnidad = () => { const addUnidad = () => {
const newNumero = unidades.length + 1 const newId = `u-${Date.now()}`
const newId = `u-${newNumero}`
const newUnidad: UnidadTematica = { const newUnidad: UnidadTematica = {
id: newId, id: newId,
nombre: 'Nueva Unidad', nombre: 'Nueva Unidad',
numero: newNumero, numero: unidades.length + 1,
temas: [], temas: [],
} }
const next = [...unidades, newUnidad] setUnidades([...unidades, newUnidad])
setUnidades(next) setExpandedUnits(new Set([...expandedUnits, newId]))
setExpandedUnits((prev) => {
const n = new Set(prev)
n.add(newId)
return n
})
setPendingScrollUnitId(newId)
// Abrir edición del título inmediatamente
setEditingUnit(newId) setEditingUnit(newId)
setUnitDraftNombre(newUnidad.nombre) }
setUnitOriginalNombre(newUnidad.nombre)
const updateUnidadNombre = (id: string, nombre: string) => {
setUnidades(unidades.map((u) => (u.id === id ? { ...u, nombre } : u)))
} }
// --- Lógica de Temas --- // --- Lógica de Temas ---
const addTema = (unidadId: string) => { const addTema = (unidadId: string) => {
const unit = unidades.find((u) => u.id === unidadId) setUnidades(
const unitNumero = unit?.numero ?? 0 unidades.map((u) => {
const newTemaIndex = (unit?.temas.length ?? 0) + 1 if (u.id === unidadId) {
const newTemaId = `t-${unitNumero}-${newTemaIndex}` const newTemaId = `t-${Date.now()}`
const newTema: Tema = { const newTema: Tema = {
id: newTemaId, id: newTemaId,
nombre: 'Nuevo tema', nombre: 'Nuevo tema',
horasEstimadas: 2, horasEstimadas: 2,
} }
const next = unidades.map((u) =>
u.id === unidadId ? { ...u, temas: [...u.temas, newTema] } : u,
)
setUnidades(next)
// Expandir unidad y poner el subtema en edición con foco en el nombre
setExpandedUnits((prev) => {
const n = new Set(prev)
n.add(unidadId)
return n
})
setEditingTema({ unitId: unidadId, temaId: newTemaId }) setEditingTema({ unitId: unidadId, temaId: newTemaId })
setTemaDraftNombre(newTema.nombre) return { ...u, temas: [...u.temas, newTema] }
setTemaOriginalNombre(newTema.nombre) }
setTemaDraftHoras(String(newTema.horasEstimadas ?? 0)) return u
setTemaOriginalHoras(newTema.horasEstimadas ?? 0) }),
)
}
const updateTema = (
unidadId: string,
temaId: string,
updates: Partial<Tema>,
) => {
setUnidades(
unidades.map((u) => {
if (u.id === unidadId) {
return {
...u,
temas: u.temas.map((t) =>
t.id === temaId ? { ...t, ...updates } : t,
),
}
}
return u
}),
)
} }
const handleDelete = () => { const handleDelete = () => {
if (!deleteDialog) return if (!deleteDialog) return
let next: Array<UnidadTematica> = unidades
if (deleteDialog.type === 'unidad') { if (deleteDialog.type === 'unidad') {
next = unidades setUnidades(
unidades
.filter((u) => u.id !== deleteDialog.id) .filter((u) => u.id !== deleteDialog.id)
.map((u, i) => ({ ...u, numero: i + 1 })) .map((u, i) => ({ ...u, numero: i + 1 })),
)
} else if (deleteDialog.parentId) { } else if (deleteDialog.parentId) {
next = unidades.map((u) => setUnidades(
unidades.map((u) =>
u.id === deleteDialog.parentId u.id === deleteDialog.parentId
? { ...u, temas: u.temas.filter((t) => t.id !== deleteDialog.id) } ? { ...u, temas: u.temas.filter((t) => t.id !== deleteDialog.id) }
: u, : u,
),
) )
} }
setUnidades(next)
setDeleteDialog(null) setDeleteDialog(null)
void persistUnidades(next) //toast.success("Eliminado correctamente");
// toast.success("Eliminado correctamente");
} }
return ( return (
@@ -449,18 +219,32 @@ export function ContenidoTematico() {
{unidades.length} unidades {totalHoras} horas estimadas totales {unidades.length} unidades {totalHoras} horas estimadas totales
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={addUnidad} className="gap-2">
<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="mr-2 h-4 w-4" />{' '}
{isSaving ? 'Guardando...' : 'Guardar'}
</Button>
</div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{unidades.map((unidad) => ( {unidades.map((unidad) => (
<div <Card
key={unidad.id} key={unidad.id}
ref={(el) => { className="overflow-hidden border-slate-200 shadow-sm"
if (el) unitContainerRefs.current.set(unidad.id, el)
else unitContainerRefs.current.delete(unidad.id)
}}
> >
<Card className="overflow-hidden border-slate-200 shadow-sm">
<Collapsible <Collapsible
open={expandedUnits.has(unidad.id)} open={expandedUnits.has(unidad.id)}
onOpenChange={() => toggleUnit(unidad.id)} onOpenChange={() => toggleUnit(unidad.id)}
@@ -483,35 +267,21 @@ export function ContenidoTematico() {
{editingUnit === unidad.id ? ( {editingUnit === unidad.id ? (
<Input <Input
ref={unitTitleInputRef} value={unidad.nombre}
value={unitDraftNombre} onChange={(e) =>
onChange={(e) => setUnitDraftNombre(e.target.value)} updateUnidadNombre(unidad.id, e.target.value)
onBlur={() => {
if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false
return
} }
commitEditUnit() onBlur={() => setEditingUnit(null)}
}} onKeyDown={(e) =>
onKeyDown={(e) => { e.key === 'Enter' && setEditingUnit(null)
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
return
} }
if (e.key === 'Escape') {
e.preventDefault()
cancelNextBlurRef.current = true
cancelEditUnit()
e.currentTarget.blur()
}
}}
className="h-8 max-w-md bg-white" className="h-8 max-w-md bg-white"
autoFocus
/> />
) : ( ) : (
<CardTitle <CardTitle
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600" className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
onClick={() => beginEditUnit(unidad.id)} onClick={() => setEditingUnit(unidad.id)}
> >
{unidad.nombre} {unidad.nombre}
</CardTitle> </CardTitle>
@@ -548,22 +318,16 @@ export function ContenidoTematico() {
tema={tema} tema={tema}
index={idx + 1} index={idx + 1}
isEditing={ isEditing={
!!editingTema && editingTema?.unitId === unidad.id &&
editingTema.unitId === unidad.id && editingTema?.temaId === tema.id
editingTema.temaId === tema.id
} }
draftNombre={temaDraftNombre} onEdit={() =>
draftHoras={temaDraftHoras} setEditingTema({ unitId: unidad.id, temaId: tema.id })
onBeginEdit={() => beginEditTema(unidad.id, tema.id)} }
onDraftNombreChange={setTemaDraftNombre} onStopEditing={() => setEditingTema(null)}
onDraftHorasChange={setTemaDraftHoras} onUpdate={(updates) =>
onEditorBlurCapture={handleTemaEditorBlurCapture} updateTema(unidad.id, tema.id, updates)
onEditorKeyDownCapture={
handleTemaEditorKeyDownCapture
} }
onNombreInputRef={(el) => {
temaNombreInputElRef.current = el
}}
onDelete={() => onDelete={() =>
setDeleteDialog({ setDeleteDialog({
type: 'tema', type: 'tema',
@@ -586,24 +350,9 @@ export function ContenidoTematico() {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</Card> </Card>
</div>
))} ))}
</div> </div>
<div className="flex justify-center pt-2">
<Button
variant="outline"
className="gap-2"
onClick={(e) => {
// Evita que Enter vuelva a disparar el click sobre el botón.
e.currentTarget.blur()
addUnidad()
}}
>
<Plus className="h-4 w-4" /> Nueva unidad
</Button>
</div>
<DeleteConfirmDialog <DeleteConfirmDialog
dialog={deleteDialog} dialog={deleteDialog}
setDialog={setDeleteDialog} setDialog={setDeleteDialog}
@@ -618,14 +367,9 @@ interface TemaRowProps {
tema: Tema tema: Tema
index: number index: number
isEditing: boolean isEditing: boolean
draftNombre: string onEdit: () => void
draftHoras: string onStopEditing: () => void
onBeginEdit: () => void onUpdate: (updates: Partial<Tema>) => void
onDraftNombreChange: (value: string) => void
onDraftHorasChange: (value: string) => void
onEditorBlurCapture: (e: FocusEvent<HTMLDivElement>) => void
onEditorKeyDownCapture: (e: KeyboardEvent<HTMLDivElement>) => void
onNombreInputRef: (el: HTMLInputElement | null) => void
onDelete: () => void onDelete: () => void
} }
@@ -633,14 +377,9 @@ function TemaRow({
tema, tema,
index, index,
isEditing, isEditing,
draftNombre, onEdit,
draftHoras, onStopEditing,
onBeginEdit, onUpdate,
onDraftNombreChange,
onDraftHorasChange,
onEditorBlurCapture,
onEditorKeyDownCapture,
onNombreInputRef,
onDelete, onDelete,
}: TemaRowProps) { }: TemaRowProps) {
return ( return (
@@ -652,49 +391,44 @@ function TemaRow({
> >
<span className="w-4 font-mono text-xs text-slate-400">{index}.</span> <span className="w-4 font-mono text-xs text-slate-400">{index}.</span>
{isEditing ? ( {isEditing ? (
<div <div className="animate-in slide-in-from-left-2 flex flex-1 items-center gap-2">
className="animate-in slide-in-from-left-2 flex flex-1 items-center gap-2"
onBlurCapture={onEditorBlurCapture}
onKeyDownCapture={onEditorKeyDownCapture}
>
<Input <Input
ref={onNombreInputRef} value={tema.nombre}
value={draftNombre} onChange={(e) => onUpdate({ nombre: e.target.value })}
onChange={(e) => onDraftNombreChange(e.target.value)}
className="h-8 flex-1 bg-white" className="h-8 flex-1 bg-white"
placeholder="Nombre" placeholder="Nombre"
autoFocus
/> />
<Input <Input
type="number" type="number"
value={draftHoras} value={tema.horasEstimadas}
onChange={(e) => onDraftHorasChange(e.target.value)} onChange={(e) =>
onUpdate({ horasEstimadas: parseInt(e.target.value) || 0 })
}
className="h-8 w-16 bg-white" className="h-8 w-16 bg-white"
/> />
<Button
size="sm"
className="h-8 bg-emerald-600"
onClick={onStopEditing}
>
Listo
</Button>
</div> </div>
) : ( ) : (
<> <>
<button <div className="flex-1 cursor-pointer" onClick={onEdit}>
type="button"
className="flex flex-1 items-center gap-3 text-left"
onClick={(e) => {
e.stopPropagation()
onBeginEdit()
}}
>
<p className="text-sm font-medium text-slate-700">{tema.nombre}</p> <p className="text-sm font-medium text-slate-700">{tema.nombre}</p>
</div>
<Badge variant="secondary" className="text-[10px] opacity-60"> <Badge variant="secondary" className="text-[10px] opacity-60">
{tema.horasEstimadas}h {tema.horasEstimadas}h
</Badge> </Badge>
</button>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-slate-400 hover:text-blue-600" className="h-7 w-7 text-slate-400 hover:text-blue-600"
onClick={(e) => { onClick={onEdit}
e.stopPropagation()
onBeginEdit()
}}
> >
<Edit3 className="h-3 w-3" /> <Edit3 className="h-3 w-3" />
</Button> </Button>
@@ -702,10 +436,7 @@ function TemaRow({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-slate-400 hover:text-red-500" className="h-7 w-7 text-slate-400 hover:text-red-500"
onClick={(e) => { onClick={onDelete}
e.stopPropagation()
onDelete()
}}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>

View File

@@ -1,6 +1,16 @@
import { FileCheck, Download, RefreshCw, Loader2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import {
FileText,
Download,
RefreshCw,
Calendar,
FileCheck,
AlertTriangle,
Loader2,
} from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -12,34 +22,54 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import type {
import { Card } from '@/components/ui/card' 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 { interface DocumentoSEPTabProps {
pdfUrl: string | null documento: DocumentoMateria | null
isLoading: boolean materia: Materia
onDownload: () => void estructura: MateriaStructure
datosGenerales: Record<string, any>
onRegenerate: () => void onRegenerate: () => void
isRegenerating: boolean isRegenerating: boolean
} }
export function DocumentoSEPTab({ export function DocumentoSEPTab({
pdfUrl, documento,
isLoading, materia,
onDownload, estructura,
datosGenerales,
onRegenerate, onRegenerate,
isRegenerating, isRegenerating,
}: DocumentoSEPTabProps) { }: DocumentoSEPTabProps) {
const [showConfirmDialog, setShowConfirmDialog] = useState(false) const [showConfirmDialog, setShowConfirmDialog] = useState(false)
// Check completeness
const camposObligatorios = estructura.campos.filter((c) => c.obligatorio)
const camposCompletos = camposObligatorios.filter((c) =>
datosGenerales[c.id]?.trim(),
)
const completeness = Math.round(
(camposCompletos.length / camposObligatorios.length) * 100,
)
const isComplete = completeness === 100
const handleRegenerate = () => { const handleRegenerate = () => {
setShowConfirmDialog(false) setShowConfirmDialog(false)
onRegenerate() onRegenerate()
//toast.success('Regenerando documento...');
} }
return ( return (
<div className="animate-fade-in space-y-6"> <div className="animate-fade-in space-y-6">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold"> <h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
@@ -47,24 +77,28 @@ export function DocumentoSEPTab({
Documento SEP Documento SEP
</h2> </h2>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-muted-foreground mt-1 text-sm">
Previsualización del documento oficial generado Previsualización del documento oficial para la SEP
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{pdfUrl && !isLoading && ( {documento?.estado === 'listo' && (
<Button variant="outline" onClick={onDownload}> <Button
variant="outline"
onClick={
() =>
console.log('descargando') /*toast.info('Descarga iniciada')*/
}
>
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
Descargar Descargar
</Button> </Button>
)} )}
<AlertDialog <AlertDialog
open={showConfirmDialog} open={showConfirmDialog}
onOpenChange={setShowConfirmDialog} onOpenChange={setShowConfirmDialog}
> >
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button disabled={isRegenerating}> <Button disabled={isRegenerating || !isComplete}>
{isRegenerating ? ( {isRegenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
@@ -73,16 +107,15 @@ export function DocumentoSEPTab({
{isRegenerating ? 'Generando...' : 'Regenerar documento'} {isRegenerating ? 'Generando...' : 'Regenerar documento'}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle> <AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Se generará una nueva versión del documento con la información Se creará una nueva versión del documento con los datos
actual. actuales de la materia. La versión anterior quedará en el
historial.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel> <AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleRegenerate}> <AlertDialogAction onClick={handleRegenerate}>
@@ -94,24 +127,307 @@ export function DocumentoSEPTab({
</div> </div>
</div> </div>
{/* PDF Preview */} <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="h-[800px] overflow-hidden"> {/* Document preview */}
{isLoading ? ( <div className="lg:col-span-2">
<div className="flex h-full items-center justify-center"> <Card className="card-elevated h-[700px] overflow-hidden">
<Loader2 className="h-10 w-10 animate-spin" /> {documento?.estado === 'listo' ? (
<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="text-primary h-5 w-5" />
<span className="text-foreground font-medium">
Programa de Estudios - {materia.clave}
</span>
</div>
<Badge variant="outline">Versión {documento.version}</Badge>
</div>
</div>
{/* Document content simulation */}
<div className="flex-1 overflow-y-auto p-8">
<div className="bg-card mx-auto max-w-2xl space-y-6 rounded-lg p-8 shadow-lg">
{/* Header */}
<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-primary mb-1 text-2xl font-bold">
{materia.nombre}
</h1>
<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>
)}
</div>
{/* Campos del documento */}
{estructura.campos.map((campo) => {
const valor = datosGenerales[campo.id]
if (!valor) return null
return (
<div key={campo.id} className="space-y-2">
<h3 className="text-foreground border-b pb-1 font-semibold">
{campo.nombre}
</h3>
<p className="text-foreground text-sm leading-relaxed whitespace-pre-wrap">
{valor}
</p>
</div>
)
})}
{/* Footer */}
<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="flex h-full items-center justify-center">
<div className="text-center">
<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>
) : pdfUrl ? (
<iframe
src={`${pdfUrl}#toolbar=0`}
className="h-full w-full border-none"
title="Documento SEP"
/>
) : ( ) : (
<div className="text-muted-foreground flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
No se pudo cargar el documento. <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="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>
</div> </div>
)} )}
</Card> </Card>
</div> </div>
{/* Info sidebar */}
<div className="space-y-4">
{/* Status */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Estado del documento
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{documento && (
<>
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Versión
</span>
<Badge variant="outline">{documento.version}</Badge>
</div>
<div className="flex items-center justify-between">
<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-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'}
</Badge>
</div>
</>
)}
</CardContent>
</Card>
{/* Completeness */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Completitud de datos
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Campos obligatorios
</span>
<span className="font-medium">
{camposCompletos.length}/{camposObligatorios.length}
</span>
</div>
<div className="bg-muted h-2 overflow-hidden rounded-full">
<div
className={cn(
'h-full transition-all duration-500',
completeness === 100 ? 'bg-success' : 'bg-accent',
)}
style={{ width: `${completeness}%` }}
/>
</div>
<p
className={cn(
'text-xs',
completeness === 100
? 'text-success'
: 'text-muted-foreground',
)}
>
{completeness === 100
? 'Todos los campos obligatorios están completos'
: `Faltan ${camposObligatorios.length - camposCompletos.length} campos por completar`}
</p>
</div>
{/* Missing fields */}
{!isComplete && (
<div className="space-y-2">
<p className="text-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>
</Card>
{/* Requirements */}
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Requisitos SEP
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
<li className="flex items-start gap-2">
<div
className={cn(
'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>
</li>
<li className="flex items-start gap-2">
<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>
</li>
<li className="flex items-start gap-2">
<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>
</li>
</ul>
</CardContent>
</Card>
</div>
</div>
</div>
)
}
function Check({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) )
} }

View File

@@ -1,4 +1,3 @@
import { useParams } from '@tanstack/react-router'
import { format, parseISO } from 'date-fns' import { format, parseISO } from 'date-fns'
import { es } from 'date-fns/locale' import { es } from 'date-fns/locale'
import { import {
@@ -54,10 +53,7 @@ const tipoConfig: Record<string, { label: string; icon: any; color: string }> =
}, },
} }
export function HistorialTab() { export function HistorialTab({ asignaturaId }) {
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId/historial',
})
// 1. Obtenemos los datos directamente dentro del componente // 1. Obtenemos los datos directamente dentro del componente
const { data: rawData, isLoading } = useSubjectHistorial(asignaturaId) const { data: rawData, isLoading } = useSubjectHistorial(asignaturaId)

View File

@@ -1,4 +1,4 @@
import { useParams, useRouterState } from '@tanstack/react-router' import { useRouterState } from '@tanstack/react-router'
import { import {
Sparkles, Sparkles,
Send, Send,
@@ -13,17 +13,16 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import type { IAMessage, IASugerencia } from '@/types/asignatura' import type { IAMessage, IASugerencia, CampoEstructura } from '@/types/materia'
import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { useSubject } from '@/data'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
// Tipos importados de tu archivo de asignatura // Tipos importados de tu archivo de materia
const PRESETS = [ const PRESETS = [
{ {
@@ -36,7 +35,7 @@ const PRESETS = [
id: 'contenido-tematico', id: 'contenido-tematico',
label: 'Sugerir contenido', label: 'Sugerir contenido',
icon: BookOpen, icon: BookOpen,
prompt: 'Genera un desglose de temas para esta asignatura...', prompt: 'Genera un desglose de temas para esta materia...',
}, },
{ {
id: 'actividades', id: 'actividades',
@@ -58,27 +57,25 @@ interface SelectedField {
value: string value: string
} }
interface IAAsignaturaTabProps { interface IAMateriaTabProps {
asignatura: Record<string, any> campos: Array<CampoEstructura>
datosGenerales: Record<string, any>
messages: Array<IAMessage> messages: Array<IAMessage>
onSendMessage: (message: string, campoId?: string) => void onSendMessage: (message: string, campoId?: string) => void
onAcceptSuggestion: (sugerencia: IASugerencia) => void onAcceptSuggestion: (sugerencia: IASugerencia) => void
onRejectSuggestion: (messageId: string) => void onRejectSuggestion: (messageId: string) => void
} }
export function IAAsignaturaTab({ export function IAMateriaTab({
campos,
datosGenerales,
messages, messages,
onSendMessage, onSendMessage,
onAcceptSuggestion, onAcceptSuggestion,
onRejectSuggestion, onRejectSuggestion,
}: IAAsignaturaTabProps) { }: IAMateriaTabProps) {
const routerState = useRouterState() const routerState = useRouterState()
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: datosGenerales, isLoading: loadingAsig } =
useSubject(asignaturaId)
// ESTADOS PRINCIPALES (Igual que en Planes) // ESTADOS PRINCIPALES (Igual que en Planes)
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([]) const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
@@ -86,29 +83,29 @@ export function IAAsignaturaTab({
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
// 1. Transformar datos de la asignatura para el menú // 1. Transformar datos de la materia para el menú
const availableFields = useMemo(() => { const availableFields = useMemo(() => {
if (!datosGenerales?.datos) return [] // Extraemos las claves directamente del objeto datosGenerales
// ["nombre", "descripcion", "perfil_de_egreso", "fines_de_aprendizaje_o_formacion"]
const estructuraProps = if (!datosGenerales.datos) return []
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
return Object.keys(datosGenerales.datos).map((key) => { return Object.keys(datosGenerales.datos).map((key) => {
const estructuraCampo = estructuraProps[key] // Buscamos si existe un nombre amigable en la estructura de campos
const estructuraCampo = campos.find((c) => c.id === key)
// Si existe en 'campos', usamos su nombre; si no, formateamos la clave (ej: perfil_de_egreso -> Perfil De Egreso)
const labelAmigable = const labelAmigable =
estructuraCampo?.title || estructuraCampo?.nombre ||
key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
return { return {
key, key: key,
label: labelAmigable, label: labelAmigable,
value: String(datosGenerales.datos[key] || ''), value: String(datosGenerales[key] || ''),
} }
}) })
}, [datosGenerales]) }, [campos, datosGenerales])
// 2. Manejar el estado inicial si viene de "Datos de Asignatura" (Prefill) // 2. Manejar el estado inicial si viene de "Datos de Materia" (Prefill)
useEffect(() => { useEffect(() => {
const state = routerState.location.state as any const state = routerState.location.state as any
@@ -217,7 +214,7 @@ export function IAAsignaturaTab({
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full"> <ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto max-w-3xl space-y-6 p-6">
{messages?.map((msg) => ( {messages.map((msg) => (
<div <div
key={msg.id} key={msg.id}
className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`} className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`}
@@ -247,7 +244,7 @@ export function IAAsignaturaTab({
{msg.content} {msg.content}
</div> </div>
{/* Renderizado de Sugerencias (Homologado con lógica de Asignatura) */} {/* Renderizado de Sugerencias (Homologado con lógica de Materia) */}
{msg.sugerencia && !msg.sugerencia.aceptada && ( {msg.sugerencia && !msg.sugerencia.aceptada && (
<div className="animate-in fade-in slide-in-from-top-1 mt-3 w-full"> <div className="animate-in fade-in slide-in-from-top-1 mt-3 w-full">
<div className="rounded-xl border border-teal-100 bg-white p-4 shadow-md"> <div className="rounded-xl border border-teal-100 bg-white p-4 shadow-md">
@@ -305,7 +302,7 @@ export function IAAsignaturaTab({
{showSuggestions && ( {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="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"> <div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold tracking-wider text-slate-500 uppercase">
Seleccionar campo de asignatura Seleccionar campo de materia
</div> </div>
<div className="max-h-64 overflow-y-auto p-1"> <div className="max-h-64 overflow-y-auto p-1">
{availableFields.map((field) => ( {availableFields.map((field) => (

View File

@@ -0,0 +1,704 @@
import {
createFileRoute,
Link,
useNavigate,
useParams,
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 { 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 {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useSubject } from '@/data/hooks/useSubjects'
import {
mockMateria,
mockEstructura,
mockDocumentoSep,
} from '@/data/mockMateriaData'
export interface BibliografiaEntry {
id: string
tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string
fuenteBibliotecaId?: string
fuenteBiblioteca?: any
}
export interface BibliografiaTabProps {
id: string
bibliografia: Array<BibliografiaEntry>
onSave: (bibliografia: Array<BibliografiaEntry>) => void
isSaving: boolean
}
export interface AsignaturaDatos {
[key: string]: string
}
export interface AsignaturaResponse {
datos: AsignaturaDatos
}
function EditableHeaderField({
value,
onSave,
className,
}: {
value: string | number
onSave: (val: string) => void
className?: string
}) {
const textValue = String(value)
// Manejador para cuando el usuario termina de editar (pierde el foco)
const handleBlur = (e: React.FocusEvent<HTMLSpanElement>) => {
const newValue = e.currentTarget.innerText
if (newValue !== textValue) {
onSave(newValue)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur() // Forzamos el guardado al presionar Enter
}
}
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<span
contentEditable
suppressContentEditableWarning={true} // Evita el warning de React por tener hijos y contentEditable
spellCheck={false}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={`inline-block cursor-text rounded-sm px-1 transition-all hover:bg-white/10 focus:bg-white/20 focus:ring-2 focus:ring-blue-400/50 focus:outline-none ${className ?? ''} `}
>
{textValue}
</span>
)
}
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId',
)({
component: MateriaDetailPage,
})
export default function MateriaDetailPage() {
const routerState = useRouterState()
const state = routerState.location.state as any
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: asignaturasApi, isLoading: loadingAsig } =
useSubject(asignaturaId)
// 1. Asegúrate de tener estos estados en tu componente principal
const [messages, setMessages] = useState<Array<IAMessage>>([])
const [datosGenerales, setDatosGenerales] = useState({})
const [campos, setCampos] = useState<Array<CampoEstructura>>([])
const [activeTab, setActiveTab] = useState('datos')
// Dentro de MateriaDetailPage
const [headerData, setHeaderData] = useState({
codigo: '',
nombre: '',
creditos: 0,
ciclo: 0,
})
useEffect(() => {
// Si en el state de la ruta viene una pestaña específica, cámbiate a ella
if (state?.activeTab) {
setActiveTab(state.activeTab)
}
}, [state])
// 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 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)
}
}, [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<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: 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)
setTimeout(() => {
setIsRegenerating(false)
}, 2000)
}, [])
return (
<div className="w-full">
{/* ================= HEADER ACTUALIZADO ================= */}
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<div className="mx-auto max-w-7xl px-6 py-10">
<Link
to="/planes/$planId/asignaturas"
params={{ planId }}
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white"
>
<ArrowLeft className="h-4 w-4" /> Volver al plan
</Link>
<div className="flex items-start justify-between gap-6">
<div className="space-y-3">
{/* 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">
<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="h-4 w-4 shrink-0" />
{/* Eliminamos el max-w y dejamos que el flex-wrap haga su trabajo */}
<EditableHeaderField
value={asignaturasApi?.planes_estudio?.datos?.nombre || ''}
onSave={(val) => handleUpdateHeader('plan_nombre', val)}
className="min-w-[10ch] text-blue-100" // min-w para que sea clickeable si está vacío
/>
</span>
<span className="flex items-center gap-1">
<EditableHeaderField
value={
asignaturasApi?.planes_estudio?.carreras?.facultades
?.nombre || ''
}
onSave={(val) => handleUpdateHeader('facultad_nombre', val)}
className="min-w-[10ch] text-blue-100"
/>
</span>
</div>
<p className="text-sm text-blue-300">
Pertenece al plan:{' '}
<span className="cursor-pointer underline">
{asignaturasApi?.planes_estudio?.nombre}
</span>
</p>
</div>
<div className="flex flex-col items-end gap-2 text-right">
{/* CRÉDITOS EDITABLES */}
<Badge variant="secondary" className="gap-1">
<span className="inline-flex max-w-fit">
<EditableHeaderField
value={headerData.creditos}
onSave={(val) =>
handleUpdateHeader('creditos', parseInt(val) || 0)
}
/>
</span>
<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="border-b bg-white">
<div className="mx-auto max-w-7xl px-6">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<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>
<TabsTrigger value="ia">IA de la materia</TabsTrigger>
<TabsTrigger value="sep">Documento SEP</TabsTrigger>
<TabsTrigger value="historial">Historial</TabsTrigger>
</TabsList>
<Separator className="mt-2" />
{/* ================= TAB: DATOS GENERALES ================= */}
<TabsContent value="datos">
<DatosGenerales
data={datosGenerales}
isLoading={loadingAsig}
asignaturaId={asignaturaId}
/>
</TabsContent>
<TabsContent value="contenido">
<ContenidoTematico
data={asignaturasApi}
isLoading={loadingAsig}
></ContenidoTematico>
</TabsContent>
<TabsContent value="bibliografia">
<BibliographyItem
bibliografia={bibliografia}
id={asignaturaId}
onSave={handleSaveBibliografia}
isSaving={isSaving}
/>
</TabsContent>
<TabsContent value="ia">
<IAMateriaTab
campos={campos}
datosGenerales={datosGenerales}
messages={messages}
onSendMessage={handleSendMessage}
onAcceptSuggestion={handleAcceptSuggestion}
onRejectSuggestion={
(id) =>
console.log(
'Rechazada',
) /* toast.error("Sugerencia rechazada")*/
}
/>
</TabsContent>
<TabsContent value="sep">
<DocumentoSEPTab
documento={mockDocumentoSep}
materia={mockMateria}
estructura={mockEstructura}
datosGenerales={datosGenerales}
onRegenerate={handleRegenerateDocument}
isRegenerating={isRegenerating}
/>
</TabsContent>
<TabsContent value="historial">
<HistorialTab asignaturaId={asignaturaId} />
</TabsContent>
</Tabs>
</div>
</section>
</div>
)
}
/* ================= TAB CONTENT ================= */
interface DatosGeneralesProps {
asignaturaId: string
data: AsignaturaDatos
isLoading: boolean
}
function DatosGenerales({
data,
isLoading,
asignaturaId,
}: DatosGeneralesProps) {
const formatTitle = (key: string): string =>
key.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase())
// 1. Extraemos la definición de la estructura (los metadatos)
const structureProps =
data?.estructuras_asignatura?.definicion?.properties || {}
// 2. Extraemos los valores reales (el contenido redactado)
const valoresActuales = data?.datos || {}
return (
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
{/* Encabezado de la Sección */}
<div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
Datos Generales
</h2>
<p className="mt-1 text-slate-500">
Información oficial estructurada bajo los lineamientos de la SEP.
</p>
</div>
</div>
{/* Grid de Información */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* Columna Principal (Más ancha) */}
<div className="space-y-6 md:col-span-2">
{isLoading && <p>Cargando información...</p>}
{!isLoading &&
Object.entries(structureProps).map(
([key, config]: [string, any]) => {
// 1. METADATOS (Vienen de structureProps -> config)
const cardTitle = config.title || key
const description = config.description || ''
// Obtenemos el placeholder del arreglo 'examples' de la estructura
const placeholder =
config.examples && config.examples.length > 0
? config.examples[0]
: ''
// 2. CONTENIDO REAL (Viene de data.datos -> valoresActuales)
// El problema: Si 'description' en 'datos' es igual a la de la 'estructura',
// el usuario aún no ha redactado nada real.
const valActual = valoresActuales[key]
// Lógica para determinar si mostrar el contenido o dejarlo vacío (para que salga el placeholder)
// Si el contenido en 'datos' es idéntico a la instrucción de la 'estructura',
// asumimos que no hay contenido real todavía.
const isContentEmpty =
!valActual?.description ||
valActual.description === config.description
const currentContent = valActual?.description ?? ''
return (
<InfoCard
asignaturaId={asignaturaId}
key={key}
clave={key}
title={cardTitle}
initialContent={currentContent} // Si es igual a la descripción de la SEP, pasamos vacío
placeholder={placeholder} // Aquí irá "Primer semestre", "MAT-101", etc.
description={description} // El texto largo de "Indicar el ciclo..."
onEnhanceAI={(contenido) => console.log(contenido)}
/>
)
},
)}
</div>
{/* Columna Lateral (Información Secundaria) */}
<div className="space-y-6">
<div className="space-y-6">
{/* Tarjeta de Requisitos */}
<InfoCard
title="Requisitos y Seriación"
type="requirements"
initialContent={[
{
type: 'Pre-requisito',
code: 'PA-301',
name: 'Programación Avanzada',
},
{
type: 'Co-requisito',
code: 'MAT-201',
name: 'Matemáticas Discretas',
},
]}
/>
{/* Tarjeta de Evaluación */}
<InfoCard
title="Sistema de Evaluación"
type="evaluation"
initialContent={[
{ label: 'Exámenes parciales', value: '30%' },
{ label: 'Proyecto integrador', value: '35%' },
{ label: 'Prácticas de laboratorio', value: '20%' },
{ label: 'Participación', value: '15%' },
]}
/>
</div>
</div>
</div>
</div>
)
}
interface InfoCardProps {
asignaturaId?: string
clave?: string
title: string
initialContent: any
placeholder?: string
description?: string
required?: boolean // Nueva prop para el asterisco
type?: 'text' | 'requirements' | 'evaluation'
onEnhanceAI?: (content: any) => void
}
function InfoCard({
asignaturaId,
clave,
title,
initialContent,
placeholder,
description,
required,
type = 'text',
}: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false)
const [data, setData] = useState(initialContent)
const [tempText, setTempText] = useState(initialContent)
const navigate = useNavigate()
useEffect(() => {
setData(initialContent)
setTempText(initialContent)
}, [initialContent])
const handleSave = () => {
setData(tempText)
setIsEditing(false)
// Aquí iría tu lógica de guardado a la DB
}
const handleIARequest = (campoClave: string) => {
console.log(placeholder)
navigate({
to: '/planes/$planId/asignaturas/$asignaturaId',
params: { asignaturaId: asignaturaId! },
state: {
activeTab: 'ia',
prefillCampo: campoClave,
prefillContenido: data,
} as any,
})
}
return (
<Card className="overflow-hidden transition-all hover:border-slate-300">
<TooltipProvider>
<CardHeader className="border-b bg-slate-50/50 px-5 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<CardTitle className="cursor-help text-sm font-bold text-slate-700">
{title}
</CardTitle>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs">
{description || 'Información del campo'}
</TooltipContent>
</Tooltip>
{required && (
<span
className="text-sm font-bold text-red-500"
title="Requerido"
>
*
</span>
)}
</div>
{!isEditing && (
<div className="flex gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
onClick={() => handleIARequest(clave)}
>
<Sparkles className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Mejorar con IA</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400"
onClick={() => setIsEditing(true)}
>
<Pencil className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Editar campo</TooltipContent>
</Tooltip>
</div>
)}
</div>
</CardHeader>
</TooltipProvider>
<CardContent className="pt-4">
{isEditing ? (
<div className="space-y-3">
<Textarea
value={tempText}
placeholder={placeholder}
onChange={(e) => setTempText(e.target.value)}
className="min-h-[120px] text-sm leading-relaxed"
/>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => setIsEditing(false)}
>
Cancelar
</Button>
<Button
size="sm"
className="bg-[#00a878] hover:bg-[#008f66]"
onClick={handleSave}
>
Guardar
</Button>
</div>
</div>
) : (
<div className="text-sm leading-relaxed text-slate-600">
{type === 'text' &&
(data ? (
<p className="whitespace-pre-wrap">{data}</p>
) : (
<p className="text-slate-400 italic">Sin información.</p>
))}
{type === 'requirements' && <RequirementsView items={data} />}
{type === 'evaluation' && <EvaluationView items={data} />}
</div>
)}
</CardContent>
</Card>
)
}
// Vista de Requisitos
function RequirementsView({ items }: { items: Array<any> }) {
return (
<div className="space-y-3">
{items.map((req, i) => (
<div
key={i}
className="rounded-lg border border-slate-100 bg-slate-50 p-3"
>
<p className="text-[10px] font-bold tracking-tight text-slate-400 uppercase">
{req.type}
</p>
<p className="text-sm font-medium text-slate-700">
{req.code} {req.name}
</p>
</div>
))}
</div>
)
}
// Vista de Evaluación
function EvaluationView({ items }: { items: Array<any> }) {
return (
<div className="space-y-2">
{items.map((item, i) => (
<div
key={i}
className="flex justify-between border-b border-slate-50 pb-1.5 text-sm italic"
>
<span className="text-slate-500">{item.label}</span>
<span className="font-bold text-blue-600">{item.value}</span>
</div>
))}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,7 +34,7 @@ export function PasoBasicosForm({
const estructurasPlanList = catalogos?.estructurasPlan ?? [] const estructurasPlanList = catalogos?.estructurasPlan ?? []
const filteredCarreras = rawCarreras.filter((c: any) => { const filteredCarreras = rawCarreras.filter((c: any) => {
const facId = wizard.datosBasicos.facultad.id const facId = wizard.datosBasicos.facultadId
if (!facId) return true if (!facId) return true
// soportar ambos shapes: `facultad_id` (BD) o `facultadId` (local) // soportar ambos shapes: `facultad_id` (BD) o `facultadId` (local)
return c.facultad_id ? c.facultad_id === facId : c.facultadId === facId return c.facultad_id ? c.facultad_id === facId : c.facultadId === facId
@@ -50,7 +50,6 @@ export function PasoBasicosForm({
id="nombrePlan" id="nombrePlan"
placeholder="Ej. Ingeniería en Sistemas (2026)" placeholder="Ej. Ingeniería en Sistemas (2026)"
value={wizard.datosBasicos.nombrePlan} value={wizard.datosBasicos.nombrePlan}
maxLength={200}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange( onChange(
(w): NewPlanWizardState => ({ (w): NewPlanWizardState => ({
@@ -69,20 +68,15 @@ export function PasoBasicosForm({
<div className="grid gap-1"> <div className="grid gap-1">
<Label htmlFor="facultad">Facultad</Label> <Label htmlFor="facultad">Facultad</Label>
<Select <Select
value={wizard.datosBasicos.facultad.id} value={wizard.datosBasicos.facultadId}
onValueChange={(value) => onValueChange={(value) =>
onChange( onChange(
(w): NewPlanWizardState => ({ (w): NewPlanWizardState => ({
...w, ...w,
datosBasicos: { datosBasicos: {
...w.datosBasicos, ...w.datosBasicos,
facultad: { facultadId: value,
id: value, carreraId: '',
nombre:
facultadesList.find((f) => f.id === value)?.nombre ||
'',
},
carrera: { id: '', nombre: '' },
}, },
}), }),
) )
@@ -92,7 +86,7 @@ export function PasoBasicosForm({
id="facultad" id="facultad"
className={cn( className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!', 'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.facultad.id !wizard.datosBasicos.facultadId
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder ? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium) : 'font-medium not-italic', // Tiene Valor (Medium)
)} )}
@@ -112,30 +106,22 @@ export function PasoBasicosForm({
<div className="grid gap-1"> <div className="grid gap-1">
<Label htmlFor="carrera">Carrera</Label> <Label htmlFor="carrera">Carrera</Label>
<Select <Select
value={wizard.datosBasicos.carrera.id} value={wizard.datosBasicos.carreraId}
onValueChange={(value) => onValueChange={(value) =>
onChange( onChange(
(w): NewPlanWizardState => ({ (w): NewPlanWizardState => ({
...w, ...w,
datosBasicos: { datosBasicos: { ...w.datosBasicos, carreraId: value },
...w.datosBasicos,
carrera: {
id: value,
nombre:
filteredCarreras.find((c) => c.id === value)?.nombre ||
'',
},
},
}), }),
) )
} }
disabled={!wizard.datosBasicos.facultad.id} disabled={!wizard.datosBasicos.facultadId}
> >
<SelectTrigger <SelectTrigger
id="carrera" id="carrera"
className={cn( className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!', 'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.carrera.id !wizard.datosBasicos.carreraId
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder ? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium) : 'font-medium not-italic', // Tiene Valor (Medium)
)} )}
@@ -229,7 +215,6 @@ export function PasoBasicosForm({
id="numCiclos" id="numCiclos"
type="number" type="number"
min={1} min={1}
max={99}
step={1} step={1}
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
@@ -248,13 +233,12 @@ export function PasoBasicosForm({
// Keep undefined when the input is empty so the field stays optional // Keep undefined when the input is empty so the field stays optional
numCiclos: (() => { numCiclos: (() => {
const raw = e.target.value const raw = e.target.value
if (raw === '') return null if (raw === '') return undefined
const asNumber = Number(raw) const asNumber = Number(raw)
if (Number.isNaN(asNumber)) return null if (Number.isNaN(asNumber)) return undefined
// Coerce to positive integer (natural numbers without zero) // Coerce to positive integer (natural numbers without zero)
const n = Math.floor(Math.abs(asNumber)) const n = Math.floor(Math.abs(asNumber))
const capped = Math.min(n >= 1 ? n : 1, 99) return n >= 1 ? n : 1
return capped
})(), })(),
}, },
}), }),

View File

@@ -48,8 +48,7 @@ export function PasoDetallesPanel({
<textarea <textarea
id="desc" id="desc"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none" className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="Define el perfil de egreso, visión pedagógica y sector profesional. Ej.: Programa semestral orientado a la Industria 4.0, con enfoque en competencias directivas y emprendimiento tecnológico..." placeholder="Describe el enfoque del programa…"
maxLength={7000}
value={wizard.iaConfig?.descripcionEnfoqueAcademico || ''} value={wizard.iaConfig?.descripcionEnfoqueAcademico || ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
onChange((w) => ({ onChange((w) => ({
@@ -73,15 +72,14 @@ export function PasoDetallesPanel({
<textarea <textarea
id="notas" id="notas"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none" className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
placeholder="Opcional: Estándares, estructura y limitaciones. Ej.: Estructura de 9 ciclos, carga pesada en ciencias básicas, sigue normativa CACEI, incluye 15% de materias optativas..." placeholder="Lineamientos institucionales, restricciones, etc."
maxLength={7000}
value={wizard.iaConfig?.instruccionesAdicionalesIA || ''} value={wizard.iaConfig?.instruccionesAdicionalesIA || ''}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
onChange((w) => ({ onChange((w) => ({
...w, ...w,
iaConfig: { iaConfig: {
...(w.iaConfig || ({} as any)), ...(w.iaConfig || ({} as any)),
instruccionesAdicionalesIA: e.target.value, InstruccionesAdicionalesIA: e.target.value,
}, },
})) }))
} }
@@ -92,7 +90,7 @@ export function PasoDetallesPanel({
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []} selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []} uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
onToggleArchivo={(id, checked) => onToggleArchivo={(id, checked) =>
onChange((w): NewPlanWizardState => { onChange((w) => {
const prev = w.iaConfig?.archivosReferencia || [] const prev = w.iaConfig?.archivosReferencia || []
const next = checked const next = checked
? [...prev, id] ? [...prev, id]
@@ -107,7 +105,7 @@ export function PasoDetallesPanel({
}) })
} }
onToggleRepositorio={(id, checked) => onToggleRepositorio={(id, checked) =>
onChange((w): NewPlanWizardState => { onChange((w) => {
const prev = w.iaConfig?.repositoriosReferencia || [] const prev = w.iaConfig?.repositoriosReferencia || []
const next = checked const next = checked
? [...prev, id] ? [...prev, id]

View File

@@ -17,7 +17,6 @@ import {
TabsContents, TabsContents,
} from '@/components/ui/motion-tabs' } from '@/components/ui/motion-tabs'
import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs' import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs'
import { cn } from '@/lib/utils'
const ReferenciasParaIA = ({ const ReferenciasParaIA = ({
selectedArchivoIds = [], selectedArchivoIds = [],
@@ -88,10 +87,7 @@ const ReferenciasParaIA = ({
onCheckedChange={(checked) => onCheckedChange={(checked) =>
onToggleArchivo?.(archivo.id, !!checked) onToggleArchivo?.(archivo.id, !!checked)
} }
className={cn( className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
selectedArchivoIds.includes(archivo.id) ? '' : 'invisible',
)}
/> />
<FileText className="text-muted-foreground h-4 w-4" /> <FileText className="text-muted-foreground h-4 w-4" />
@@ -138,12 +134,7 @@ const ReferenciasParaIA = ({
onCheckedChange={(checked) => onCheckedChange={(checked) =>
onToggleRepositorio?.(repositorio.id, !!checked) onToggleRepositorio?.(repositorio.id, !!checked)
} }
className={cn( className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
selectedRepositorioIds.includes(repositorio.id)
? ''
: 'invisible',
)}
/> />
<FolderOpen className="text-muted-foreground h-4 w-4" /> <FolderOpen className="text-muted-foreground h-4 w-4" />

View File

@@ -45,8 +45,8 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
Facultad/Carrera:{' '} Facultad/Carrera:{' '}
</span> </span>
<span className="font-medium"> <span className="font-medium">
{wizard.datosBasicos.facultad.nombre || '—'} /{' '} {wizard.datosBasicos.facultadId || '—'} /{' '}
{wizard.datosBasicos.carrera.nombre || '—'} {wizard.datosBasicos.carreraId || '—'}
</span> </span>
</div> </div>
<div> <div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,9 @@ import { StepWithTooltip } from '@/components/wizard/StepWithTooltip'
export function WizardResponsiveHeader({ export function WizardResponsiveHeader({
wizard, wizard,
methods, methods,
titleOverrides,
}: { }: {
wizard: any wizard: any
methods: any methods: any
titleOverrides?: Record<string, string>
}) { }) {
const idx = wizard.utils.getIndex(methods.current.id) const idx = wizard.utils.getIndex(methods.current.id)
const totalSteps = wizard.steps.length const totalSteps = wizard.steps.length
@@ -16,8 +14,6 @@ export function WizardResponsiveHeader({
const hasNextStep = idx < totalSteps - 1 const hasNextStep = idx < totalSteps - 1
const nextStep = wizard.steps[currentIndex] const nextStep = wizard.steps[currentIndex]
const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title
return ( return (
<> <>
<div className="block sm:hidden"> <div className="block sm:hidden">
@@ -26,13 +22,13 @@ export function WizardResponsiveHeader({
<div className="flex flex-col justify-center"> <div className="flex flex-col justify-center">
<h2 className="text-lg font-bold text-slate-900"> <h2 className="text-lg font-bold text-slate-900">
<StepWithTooltip <StepWithTooltip
title={resolveTitle(methods.current)} title={methods.current.title}
desc={methods.current.description} desc={methods.current.description}
/> />
</h2> </h2>
{hasNextStep && nextStep ? ( {hasNextStep && nextStep ? (
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
Siguiente: {resolveTitle(nextStep)} Siguiente: {nextStep.title}
</p> </p>
) : ( ) : (
<p className="text-sm font-medium text-green-500"> <p className="text-sm font-medium text-green-500">
@@ -52,10 +48,7 @@ export function WizardResponsiveHeader({
className="whitespace-nowrap" className="whitespace-nowrap"
> >
<wizard.Stepper.Title> <wizard.Stepper.Title>
<StepWithTooltip <StepWithTooltip title={step.title} desc={step.description} />
title={resolveTitle(step)}
desc={step.description}
/>
</wizard.Stepper.Title> </wizard.Stepper.Title>
</wizard.Stepper.Step> </wizard.Stepper.Step>
))} ))}

View File

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

View File

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

View File

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

View File

@@ -79,7 +79,7 @@ export async function plans_list(
`, `,
{ count: 'exact' }, { count: 'exact' },
) )
.order('creado_en', { ascending: false }) .order('actualizado_en', { ascending: false })
// 2. Aplicamos filtros dinámicos // 2. Aplicamos filtros dinámicos
@@ -144,48 +144,6 @@ export async function plans_get(planId: UUID): Promise<PlanEstudio> {
return requireData(data, 'Plan no encontrado.') return requireData(data, 'Plan no encontrado.')
} }
/**
* Variante de `plans_get` que NO lanza si no existe (devuelve null).
* Útil para flujos de polling donde el plan puede tardar en aparecer.
*/
export async function plans_get_maybe(
planId: UUID,
): Promise<PlanEstudio | null> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('planes_estudio')
.select(
`
*,
carreras (*, facultades(*)),
estructuras_plan (*),
estados_plan (*)
`,
)
.eq('id', planId)
.maybeSingle()
throwIfError(error)
return (data ?? null) as unknown as PlanEstudio | null
}
export async function plans_delete(planId: UUID): Promise<{ id: UUID }> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('planes_estudio')
.delete()
.eq('id', planId)
.select('id')
.maybeSingle()
throwIfError(error)
// Si por alguna razón no retorna fila (RLS / triggers), devolvemos el id solicitado.
return { id: ((data as any)?.id ?? planId) as UUID }
}
export async function plan_lineas_list( export async function plan_lineas_list(
planId: UUID, planId: UUID,
): Promise<Array<LineaPlan>> { ): Promise<Array<LineaPlan>> {
@@ -207,7 +165,7 @@ export async function plan_asignaturas_list(
const { data, error } = await supabase const { data, error } = await supabase
.from('asignaturas') .from('asignaturas')
.select( .select(
'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en', 'id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en',
) )
.eq('plan_estudio_id', planId) .eq('plan_estudio_id', planId)
.order('numero_ciclo', { ascending: true, nullsFirst: false }) .order('numero_ciclo', { ascending: true, nullsFirst: false })
@@ -218,31 +176,18 @@ export async function plan_asignaturas_list(
return data ?? [] return data ?? []
} }
export async function plans_history( export async function plans_history(planId: UUID): Promise<Array<CambioPlan>> {
planId: UUID,
page: number = 0,
pageSize: number = 4,
): Promise<{ data: Array<CambioPlan>; count: number }> {
// Cambiamos el retorno
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const from = page * pageSize const { data, error } = await supabase
const to = from + pageSize - 1
const { data, error, count } = await supabase
.from('cambios_plan') .from('cambios_plan')
.select( .select(
'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,response_id', 'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo',
{ count: 'exact' }, // <--- Pedimos el conteo exacto
) )
.eq('plan_estudio_id', planId) .eq('plan_estudio_id', planId)
.order('cambiado_en', { ascending: false }) .order('cambiado_en', { ascending: false })
.range(from, to)
throwIfError(error) throwIfError(error)
return { return data ?? []
data: data ?? [],
count: count ?? 0,
}
} }
/** Wizard: crear plan manual (Edge Function) */ /** Wizard: crear plan manual (Edge Function) */
@@ -323,8 +268,8 @@ export type AIGeneratePlanInput = {
estructuraPlanId: UUID estructuraPlanId: UUID
} }
iaConfig: { iaConfig: {
descripcionEnfoqueAcademico: string descripcionEnfoque: string
instruccionesAdicionalesIA?: string notasAdicionales?: string
archivosReferencia?: Array<UUID> archivosReferencia?: Array<UUID>
repositoriosIds?: Array<UUID> repositoriosIds?: Array<UUID>
archivosAdjuntos: Array<UploadedFile> archivosAdjuntos: Array<UploadedFile>
@@ -346,7 +291,7 @@ export async function ai_generate_plan(
archivosAdjuntos: undefined, // los manejamos aparte archivosAdjuntos: undefined, // los manejamos aparte
}), }),
) )
input.iaConfig.archivosAdjuntos.forEach((file) => { input.iaConfig.archivosAdjuntos.forEach((file, index) => {
edgeFunctionBody.append(`archivosAdjuntos`, file.file) edgeFunctionBody.append(`archivosAdjuntos`, file.file)
}) })
@@ -388,7 +333,7 @@ export async function plans_import_from_files(payload: {
} }
archivoWordPlanId: UUID archivoWordPlanId: UUID
archivoMapaExcelId?: UUID | null archivoMapaExcelId?: UUID | null
archivoAsignaturasExcelId?: UUID | null archivoMateriasExcelId?: UUID | null
}): Promise<PlanEstudio> { }): Promise<PlanEstudio> {
return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload) return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload)
} }

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import {
useQuery, useQuery,
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import { useEffect } from 'react'
import { import {
ai_generate_plan, ai_generate_plan,
@@ -13,7 +12,6 @@ import {
plan_lineas_list, plan_lineas_list,
plans_clone_from_existing, plans_clone_from_existing,
plans_create_manual, plans_create_manual,
plans_delete,
plans_generate_document, plans_generate_document,
plans_get, plans_get,
plans_get_document, plans_get_document,
@@ -25,9 +23,7 @@ import {
plans_update_fields, plans_update_fields,
plans_update_map, plans_update_map,
} from '../api/plans.api' } from '../api/plans.api'
import { lineas_delete } from '../api/subjects.api'
import { qk } from '../query/keys' import { qk } from '../query/keys'
import { supabaseBrowser } from '../supabase/client'
import type { import type {
PlanListFilters, PlanListFilters,
@@ -74,92 +70,20 @@ export function usePlanLineas(planId: UUID | null | undefined) {
} }
export function usePlanAsignaturas(planId: UUID | null | undefined) { export function usePlanAsignaturas(planId: UUID | null | undefined) {
const qc = useQueryClient() return useQuery({
const query = useQuery({
queryKey: planId queryKey: planId
? qk.planAsignaturas(planId) ? qk.planAsignaturas(planId)
: ['planes', 'asignaturas', null], : ['planes', 'asignaturas', null],
queryFn: () => plan_asignaturas_list(planId as UUID), queryFn: () => plan_asignaturas_list(planId as UUID),
enabled: Boolean(planId), enabled: Boolean(planId),
}) })
useEffect(() => {
if (!planId) return
const supabase = supabaseBrowser()
const channel = supabase.channel(`plan-asignaturas-${planId}`)
channel.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'asignaturas',
filter: `plan_estudio_id=eq.${planId}`,
},
(payload: {
eventType?: 'INSERT' | 'UPDATE' | 'DELETE'
new?: any
old?: any
}) => {
const eventType = payload.eventType
if (eventType === 'DELETE') {
const oldRow: any = payload.old
const deletedId = oldRow?.id
if (!deletedId) return
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
if (!Array.isArray(prev)) return prev
return prev.filter((a: any) => String(a?.id) !== String(deletedId))
})
return
}
const newRow: any = payload.new
if (!newRow?.id) return
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
if (!Array.isArray(prev)) return prev
const idx = prev.findIndex(
(a: any) => String(a?.id) === String(newRow.id),
)
if (idx === -1) return [...prev, newRow]
const next = [...prev]
next[idx] = { ...prev[idx], ...newRow }
return next
})
},
)
channel.subscribe()
return () => {
try {
supabase.removeChannel(channel)
} catch {
// noop
}
}
}, [planId, qc])
return query
} }
export function usePlanHistorial( export function usePlanHistorial(planId: UUID | null | undefined) {
planId: UUID | null | undefined,
page: number,
) {
return useQuery({ return useQuery({
queryKey: planId queryKey: planId ? qk.planHistorial(planId) : ['planes', 'historial', null],
? [...qk.planHistorial(planId), page] queryFn: () => plans_history(planId as UUID),
: ['planes', 'historial', null, page],
queryFn: () => plans_history(planId as UUID, page),
enabled: Boolean(planId), enabled: Boolean(planId),
placeholderData: (previousData) => previousData,
}) })
} }
@@ -174,7 +98,7 @@ export function usePlanDocumento(planId: UUID | null | undefined) {
export function useCatalogosPlanes() { export function useCatalogosPlanes() {
return useQuery({ return useQuery({
queryKey: qk.estructurasPlan(), queryKey: ['catalogos_planes'],
queryFn: getCatalogos, queryFn: getCatalogos,
staleTime: 1000 * 60 * 60, // 1 hora de caché (estos datos casi no cambian) staleTime: 1000 * 60 * 60, // 1 hora de caché (estos datos casi no cambian)
}) })
@@ -322,23 +246,6 @@ export function useTransitionPlanEstado() {
}) })
} }
export function useDeletePlanEstudio() {
const qc = useQueryClient()
return useMutation({
mutationFn: (planId: UUID) => plans_delete(planId),
onSuccess: (_ok, planId) => {
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.removeQueries({ queryKey: qk.plan(planId) })
qc.removeQueries({ queryKey: qk.planMaybe(planId) })
qc.removeQueries({ queryKey: qk.planAsignaturas(planId) })
qc.removeQueries({ queryKey: qk.planLineas(planId) })
qc.removeQueries({ queryKey: qk.planHistorial(planId) })
qc.removeQueries({ queryKey: qk.planDocumento(planId) })
},
})
}
export function useGeneratePlanDocumento() { export function useGeneratePlanDocumento() {
const qc = useQueryClient() const qc = useQueryClient()
@@ -350,15 +257,3 @@ export function useGeneratePlanDocumento() {
}, },
}) })
} }
export function useDeleteLinea() {
const qc = useQueryClient()
return useMutation({
mutationFn: lineas_delete,
onSuccess: (_idEliminado) => {
// Invalidamos para que las materias y líneas se refresquen
qc.invalidateQueries({ queryKey: ['plan_lineas'] })
qc.invalidateQueries({ queryKey: ['plan_asignaturas'] })
},
})
}

View File

@@ -1,20 +1,19 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { qk } from '../query/keys'
import type { UUID } from '../types/domain'
import type {
BibliografiaUpsertInput,
SubjectsCreateManualInput,
SubjectsUpdateFieldsPatch,
} from '../api/subjects.api'
import { import {
ai_generate_subject, ai_generate_subject,
asignaturas_update,
bibliografia_delete,
bibliografia_insert,
bibliografia_update,
lineas_insert,
lineas_update,
subjects_bibliografia_list, subjects_bibliografia_list,
subjects_clone_from_existing, subjects_clone_from_existing,
subjects_create_manual, subjects_create_manual,
subjects_generate_document, subjects_generate_document,
subjects_get, subjects_get,
subjects_get_document, subjects_get_document,
subjects_get_structure_catalog,
subjects_history, subjects_history,
subjects_import_from_file, subjects_import_from_file,
subjects_persist_from_ai, subjects_persist_from_ai,
@@ -22,15 +21,6 @@ import {
subjects_update_contenido, subjects_update_contenido,
subjects_update_fields, subjects_update_fields,
} from '../api/subjects.api' } from '../api/subjects.api'
import { qk } from '../query/keys'
import type {
BibliografiaUpsertInput,
ContenidoApi,
SubjectsUpdateFieldsPatch,
} from '../api/subjects.api'
import type { UUID } from '../types/domain'
import type { TablesInsert } from '@/types/supabase'
export function useSubject(subjectId: UUID | null | undefined) { export function useSubject(subjectId: UUID | null | undefined) {
return useQuery({ return useQuery({
@@ -73,20 +63,13 @@ export function useSubjectDocumento(subjectId: UUID | null | undefined) {
}) })
} }
export function useSubjectEstructuras() {
return useQuery({
queryKey: qk.estructurasAsignatura(),
queryFn: () => subjects_get_structure_catalog(),
})
}
/* ------------------ Mutations ------------------ */ /* ------------------ Mutations ------------------ */
export function useCreateSubjectManual() { export function useCreateSubjectManual() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (payload: TablesInsert<'asignaturas'>) => mutationFn: (payload: SubjectsCreateManualInput) =>
subjects_create_manual(payload), subjects_create_manual(payload),
onSuccess: (subject) => { onSuccess: (subject) => {
qc.setQueryData(qk.asignatura(subject.id), subject) qc.setQueryData(qk.asignatura(subject.id), subject)
@@ -101,16 +84,14 @@ export function useCreateSubjectManual() {
} }
export function useGenerateSubjectAI() { export function useGenerateSubjectAI() {
return useMutation({ return useMutation({ mutationFn: ai_generate_subject })
mutationFn: ai_generate_subject,
})
} }
export function usePersistSubjectFromAI() { export function usePersistSubjectFromAI() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (payload: { planId: UUID; jsonAsignatura: any }) => mutationFn: (payload: { planId: UUID; jsonMateria: any }) =>
subjects_persist_from_ai(payload), subjects_persist_from_ai(payload),
onSuccess: (subject) => { onSuccess: (subject) => {
qc.setQueryData(qk.asignatura(subject.id), subject) qc.setQueryData(qk.asignatura(subject.id), subject)
@@ -165,9 +146,7 @@ export function useUpdateSubjectFields() {
mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) => mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) =>
subjects_update_fields(vars.subjectId, vars.patch), subjects_update_fields(vars.subjectId, vars.patch),
onSuccess: (updated) => { onSuccess: (updated) => {
qc.setQueryData(qk.asignatura(updated.id), (prev) => qc.setQueryData(qk.asignatura(updated.id), updated)
prev ? { ...(prev as any), ...(updated as any) } : updated,
)
qc.invalidateQueries({ qc.invalidateQueries({
queryKey: qk.planAsignaturas(updated.plan_estudio_id), queryKey: qk.planAsignaturas(updated.plan_estudio_id),
}) })
@@ -180,19 +159,10 @@ export function useUpdateSubjectContenido() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (vars: { subjectId: UUID; unidades: Array<ContenidoApi> }) => mutationFn: (vars: { subjectId: UUID; unidades: any[] }) =>
subjects_update_contenido(vars.subjectId, vars.unidades), subjects_update_contenido(vars.subjectId, vars.unidades),
onSuccess: (updated) => { onSuccess: (updated) => {
qc.setQueryData(qk.asignatura(updated.id), (prev) => qc.setQueryData(qk.asignatura(updated.id), updated)
prev ? { ...(prev as any), ...(updated as any) } : updated,
)
qc.invalidateQueries({
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(updated.plan_estudio_id),
})
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) }) qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
}, },
}) })
@@ -224,96 +194,3 @@ export function useGenerateSubjectDocumento() {
}, },
}) })
} }
export function useUpdateAsignatura() {
const qc = useQueryClient()
return useMutation({
mutationFn: (vars: {
asignaturaId: UUID
patch: Partial<SubjectsUpdateFieldsPatch>
}) => asignaturas_update(vars.asignaturaId, vars.patch),
onSuccess: (updated) => {
// ✅ Mantener consistencia con las query keys centralizadas (qk)
// 1) Actualiza el detalle (esto evita volver a entrar con caché vieja)
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
prev ? { ...(prev as any), ...(updated as any) } : updated,
)
// 2) Refresca vistas derivadas del plan
qc.invalidateQueries({
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(updated.plan_estudio_id),
})
// 3) Refresca historial de la asignatura si existe
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
},
})
}
export function useCreateLinea() {
const qc = useQueryClient()
return useMutation({
mutationFn: lineas_insert,
onSuccess: (nuevaLinea) => {
qc.invalidateQueries({
queryKey: ['plan_lineas', nuevaLinea.plan_estudio_id],
})
},
})
}
export function useUpdateLinea() {
const qc = useQueryClient()
return useMutation({
mutationFn: (vars: { lineaId: string; patch: any }) =>
lineas_update(vars.lineaId, vars.patch),
onSuccess: (updated) => {
qc.invalidateQueries({
queryKey: ['plan_lineas', updated.plan_estudio_id],
})
},
})
}
export function useCreateBibliografia() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: bibliografia_insert,
onSuccess: (data) => {
// USAR LA MISMA LLAVE QUE EL HOOK DE LECTURA
queryClient.invalidateQueries({
queryKey: qk.asignaturaBibliografia(data.asignatura_id),
})
},
})
}
export function useUpdateBibliografia(asignaturaId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, updates }: { id: string; updates: any }) =>
bibliografia_update(id, updates),
onSuccess: () => {
qc.invalidateQueries({
queryKey: qk.asignaturaBibliografia(asignaturaId),
})
},
})
}
export function useDeleteBibliografia(asignaturaId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => bibliografia_delete(id),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: qk.asignaturaBibliografia(asignaturaId),
})
},
})
}

View File

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

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

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

View File

@@ -2,7 +2,6 @@ export const qk = {
auth: ['auth'] as const, auth: ['auth'] as const,
session: () => ['auth', 'session'] as const, session: () => ['auth', 'session'] as const,
meProfile: () => ['auth', 'meProfile'] as const, meProfile: () => ['auth', 'meProfile'] as const,
meAccess: () => ['auth', 'meAccess'] as const,
facultades: () => ['meta', 'facultades'] as const, facultades: () => ['meta', 'facultades'] as const,
carreras: (facultadId?: string | null) => carreras: (facultadId?: string | null) =>
@@ -14,18 +13,14 @@ export const qk = {
planesList: (filters: unknown) => ['planes', 'list', filters] as const, planesList: (filters: unknown) => ['planes', 'list', filters] as const,
plan: (planId: string) => ['planes', 'detail', planId] as const, plan: (planId: string) => ['planes', 'detail', planId] as const,
planMaybe: (planId: string) => ['planes', 'detail-maybe', planId] as const,
planLineas: (planId: string) => ['planes', planId, 'lineas'] as const, planLineas: (planId: string) => ['planes', planId, 'lineas'] as const,
planAsignaturas: (planId: string) => planAsignaturas: (planId: string) =>
['planes', planId, 'asignaturas'] as const, ['planes', planId, 'asignaturas'] as const,
planHistorial: (planId: string) => ['planes', planId, 'historial'] as const, planHistorial: (planId: string) => ['planes', planId, 'historial'] as const,
planDocumento: (planId: string) => ['planes', planId, 'documento'] as const, planDocumento: (planId: string) => ['planes', planId, 'documento'] as const,
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
asignatura: (asignaturaId: string) => asignatura: (asignaturaId: string) =>
['asignaturas', 'detail', asignaturaId] as const, ['asignaturas', 'detail', asignaturaId] as const,
asignaturaMaybe: (asignaturaId: string) =>
['asignaturas', 'detail-maybe', asignaturaId] as const,
asignaturaBibliografia: (asignaturaId: string) => asignaturaBibliografia: (asignaturaId: string) =>
['asignaturas', asignaturaId, 'bibliografia'] as const, ['asignaturas', asignaturaId, 'bibliografia'] as const,
asignaturaHistorial: (asignaturaId: string) => asignaturaHistorial: (asignaturaId: string) =>

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,8 @@ import * as Icons from 'lucide-react'
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard' import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm' import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm'
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel' import { PasoConfiguracionPanel } from '@/components/asignaturas/wizard/PasoConfiguracionPanel'
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup' import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard' import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls' import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
@@ -54,17 +54,11 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
setWizard, setWizard,
canContinueDesdeMetodo, canContinueDesdeMetodo,
canContinueDesdeBasicos, canContinueDesdeBasicos,
canContinueDesdeDetalles, canContinueDesdeConfig,
simularGeneracionIA,
crearAsignatura,
} = useNuevaAsignaturaWizard(planId) } = useNuevaAsignaturaWizard(planId)
const titleOverrides =
wizard.tipoOrigen === 'IA_MULTIPLE'
? {
basicos: 'Sugerencias',
detalles: 'Estructura',
}
: undefined
const handleClose = () => { const handleClose = () => {
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false }) navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
} }
@@ -105,29 +99,18 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
title="Nueva Asignatura" title="Nueva Asignatura"
onClose={handleClose} onClose={handleClose}
headerSlot={ headerSlot={
<WizardResponsiveHeader <WizardResponsiveHeader wizard={Wizard} methods={methods} />
wizard={Wizard}
methods={methods}
titleOverrides={titleOverrides}
/>
} }
footerSlot={ footerSlot={
<Wizard.Stepper.Controls> <Wizard.Stepper.Controls>
<WizardControls <WizardControls
errorMessage={wizard.errorMessage} Wizard={Wizard}
onPrev={() => methods.prev()} methods={methods}
onNext={() => methods.next()}
disablePrev={idx === 0 || wizard.isLoading}
disableNext={
wizard.isLoading ||
(idx === 0 && !canContinueDesdeMetodo) ||
(idx === 1 && !canContinueDesdeBasicos) ||
(idx === 2 && !canContinueDesdeDetalles)
}
disableCreate={wizard.isLoading}
isLastStep={idx >= Wizard.steps.length - 1}
wizard={wizard} wizard={wizard}
setWizard={setWizard} canContinueDesdeMetodo={canContinueDesdeMetodo}
canContinueDesdeBasicos={canContinueDesdeBasicos}
canContinueDesdeConfig={canContinueDesdeConfig}
onCreate={() => crearAsignatura(handleClose)}
/> />
</Wizard.Stepper.Controls> </Wizard.Stepper.Controls>
} }
@@ -147,7 +130,11 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
{idx === 2 && ( {idx === 2 && (
<Wizard.Stepper.Panel> <Wizard.Stepper.Panel>
<PasoDetallesPanel wizard={wizard} onChange={setWizard} /> <PasoConfiguracionPanel
wizard={wizard}
onChange={setWizard}
onGenerarIA={simularGeneracionIA}
/>
</Wizard.Stepper.Panel> </Wizard.Stepper.Panel>
)} )}

View File

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

View File

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

View File

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

View File

@@ -8,11 +8,11 @@ export function useNuevoPlanWizard() {
tipoOrigen: null, tipoOrigen: null,
datosBasicos: { datosBasicos: {
nombrePlan: '', nombrePlan: '',
facultad: { id: '', nombre: '' }, carreraId: '',
carrera: { id: '', nombre: '' }, facultadId: '',
nivel: '', nivel: '',
tipoCiclo: '', tipoCiclo: '',
numCiclos: null, numCiclos: undefined,
estructuraPlanId: null, estructuraPlanId: null,
}, },
// datosBasicos: { // datosBasicos: {
@@ -53,10 +53,10 @@ export function useNuevoPlanWizard() {
const canContinueDesdeBasicos = const canContinueDesdeBasicos =
!!wizard.datosBasicos.nombrePlan && !!wizard.datosBasicos.nombrePlan &&
!!wizard.datosBasicos.carrera.id && !!wizard.datosBasicos.carreraId &&
!!wizard.datosBasicos.facultad.id && !!wizard.datosBasicos.facultadId &&
!!wizard.datosBasicos.nivel && !!wizard.datosBasicos.nivel &&
wizard.datosBasicos.numCiclos !== null && wizard.datosBasicos.numCiclos !== undefined &&
wizard.datosBasicos.numCiclos > 0 && wizard.datosBasicos.numCiclos > 0 &&
// Requerir ambas plantillas (plan y mapa) con versión // Requerir ambas plantillas (plan y mapa) con versión
!!wizard.datosBasicos.estructuraPlanId !!wizard.datosBasicos.estructuraPlanId

View File

@@ -19,17 +19,11 @@ export type NewPlanWizardState = {
tipoOrigen: TipoOrigen | null tipoOrigen: TipoOrigen | null
datosBasicos: { datosBasicos: {
nombrePlan: string nombrePlan: string
facultad: { carreraId: string
id: string facultadId: string
nombre: string
}
carrera: {
id: string
nombre: string
}
nivel: NivelPlanEstudio | '' nivel: NivelPlanEstudio | ''
tipoCiclo: TipoCiclo | '' tipoCiclo: TipoCiclo | ''
numCiclos: number | null numCiclos: number | undefined
// Selección de plantillas (obligatorias) // Selección de plantillas (obligatorias)
estructuraPlanId: string | null estructuraPlanId: string | null
} }

View File

@@ -6,7 +6,6 @@ import reportWebVitals from './reportWebVitals.ts'
import { routeTree } from './routeTree.gen' import { routeTree } from './routeTree.gen'
import * as TanStackQueryProvider from '@/data/query/queryClient.tsx' import * as TanStackQueryProvider from '@/data/query/queryClient.tsx'
import { supabaseBrowser } from '@/data/supabase/client'
import './styles.css' import './styles.css'
@@ -17,7 +16,6 @@ const router = createRouter({
routeTree, routeTree,
context: { context: {
...TanStackQueryProviderContext, ...TanStackQueryProviderContext,
supabase: supabaseBrowser(),
}, },
defaultPreload: 'intent', defaultPreload: 'intent',
scrollRestoration: true, scrollRestoration: true,

View File

@@ -17,19 +17,13 @@ import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-qu
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo' import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
import { Route as PlanesPlanIdDetalleRouteImport } from './routes/planes/$planId/_detalle' import { Route as PlanesPlanIdDetalleRouteImport } from './routes/planes/$planId/_detalle'
import { Route as PlanesPlanIdDetalleIndexRouteImport } from './routes/planes/$planId/_detalle/index' import { Route as PlanesPlanIdDetalleIndexRouteImport } from './routes/planes/$planId/_detalle/index'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId'
import { Route as PlanesPlanIdDetalleMapaRouteImport } from './routes/planes/$planId/_detalle/mapa' import { Route as PlanesPlanIdDetalleMapaRouteImport } from './routes/planes/$planId/_detalle/mapa'
import { Route as PlanesPlanIdDetalleIaplanRouteImport } from './routes/planes/$planId/_detalle/iaplan' import { Route as PlanesPlanIdDetalleIaplanRouteImport } from './routes/planes/$planId/_detalle/iaplan'
import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial' import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial'
import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo' import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo'
import { Route as PlanesPlanIdDetalleDocumentoRouteImport } from './routes/planes/$planId/_detalle/documento' import { Route as PlanesPlanIdDetalleDocumentoRouteImport } from './routes/planes/$planId/_detalle/documento'
import { Route as PlanesPlanIdDetalleAsignaturasRouteImport } from './routes/planes/$planId/_detalle/asignaturas' import { Route as PlanesPlanIdDetalleAsignaturasIndexRouteImport } from './routes/planes/$planId/_detalle/asignaturas/index'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/route'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/index'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/historial'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/documento'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/contenido'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/bibliografia'
import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva' import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva'
const LoginRoute = LoginRouteImport.update({ const LoginRoute = LoginRouteImport.update({
@@ -73,6 +67,12 @@ const PlanesPlanIdDetalleIndexRoute =
path: '/', path: '/',
getParentRoute: () => PlanesPlanIdDetalleRoute, getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any) } as any)
const PlanesPlanIdAsignaturasAsignaturaIdRoute =
PlanesPlanIdAsignaturasAsignaturaIdRouteImport.update({
id: '/planes/$planId/asignaturas/$asignaturaId',
path: '/planes/$planId/asignaturas/$asignaturaId',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdDetalleMapaRoute = PlanesPlanIdDetalleMapaRouteImport.update({ const PlanesPlanIdDetalleMapaRoute = PlanesPlanIdDetalleMapaRouteImport.update({
id: '/mapa', id: '/mapa',
path: '/mapa', path: '/mapa',
@@ -102,59 +102,17 @@ const PlanesPlanIdDetalleDocumentoRoute =
path: '/documento', path: '/documento',
getParentRoute: () => PlanesPlanIdDetalleRoute, getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any) } as any)
const PlanesPlanIdDetalleAsignaturasRoute = const PlanesPlanIdDetalleAsignaturasIndexRoute =
PlanesPlanIdDetalleAsignaturasRouteImport.update({ PlanesPlanIdDetalleAsignaturasIndexRouteImport.update({
id: '/asignaturas', id: '/asignaturas/',
path: '/asignaturas', path: '/asignaturas/',
getParentRoute: () => PlanesPlanIdDetalleRoute, getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any) } as any)
const PlanesPlanIdAsignaturasAsignaturaIdRouteRoute =
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport.update({
id: '/planes/$planId/asignaturas/$asignaturaId',
path: '/planes/$planId/asignaturas/$asignaturaId',
getParentRoute: () => rootRouteImport,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdIndexRoute =
PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute =
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport.update({
id: '/iaasignatura',
path: '/iaasignatura',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute =
PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport.update({
id: '/historial',
path: '/historial',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute =
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport.update({
id: '/documento',
path: '/documento',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute =
PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport.update({
id: '/contenido',
path: '/contenido',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute =
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport.update({
id: '/bibliografia',
path: '/bibliografia',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
} as any)
const PlanesPlanIdDetalleAsignaturasNuevaRoute = const PlanesPlanIdDetalleAsignaturasNuevaRoute =
PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({ PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({
id: '/nueva', id: '/asignaturas/nueva',
path: '/nueva', path: '/asignaturas/nueva',
getParentRoute: () => PlanesPlanIdDetalleAsignaturasRoute, getParentRoute: () => PlanesPlanIdDetalleRoute,
} as any) } as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
@@ -165,21 +123,15 @@ export interface FileRoutesByFullPath {
'/planes': typeof PlanesListaRouteWithChildren '/planes': typeof PlanesListaRouteWithChildren
'/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren '/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren
'/planes/nuevo': typeof PlanesListaNuevoRoute '/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute '/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute '/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute '/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute '/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute '/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute '/planes/$planId/asignaturas/': typeof PlanesPlanIdDetalleAsignaturasIndexRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -188,20 +140,15 @@ export interface FileRoutesByTo {
'/demo/tanstack-query': typeof DemoTanstackQueryRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute
'/planes': typeof PlanesListaRouteWithChildren '/planes': typeof PlanesListaRouteWithChildren
'/planes/nuevo': typeof PlanesListaNuevoRoute '/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute '/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute '/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute '/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute '/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute '/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute '/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasIndexRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -212,21 +159,15 @@ export interface FileRoutesById {
'/planes/_lista': typeof PlanesListaRouteWithChildren '/planes/_lista': typeof PlanesListaRouteWithChildren
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren '/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute '/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
'/planes/$planId/_detalle/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
'/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute '/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute
'/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute '/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute
'/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute '/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute
'/planes/$planId/_detalle/iaplan': typeof PlanesPlanIdDetalleIaplanRoute '/planes/$planId/_detalle/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
'/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute '/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute '/planes/$planId/_detalle/asignaturas/': typeof PlanesPlanIdDetalleAsignaturasIndexRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -238,21 +179,15 @@ export interface FileRouteTypes {
| '/planes' | '/planes'
| '/planes/$planId' | '/planes/$planId'
| '/planes/nuevo' | '/planes/nuevo'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/asignaturas'
| '/planes/$planId/documento' | '/planes/$planId/documento'
| '/planes/$planId/flujo' | '/planes/$planId/flujo'
| '/planes/$planId/historial' | '/planes/$planId/historial'
| '/planes/$planId/iaplan' | '/planes/$planId/iaplan'
| '/planes/$planId/mapa' | '/planes/$planId/mapa'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/' | '/planes/$planId/'
| '/planes/$planId/asignaturas/nueva' | '/planes/$planId/asignaturas/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia' | '/planes/$planId/asignaturas/'
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
| '/planes/$planId/asignaturas/$asignaturaId/documento'
| '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@@ -261,20 +196,15 @@ export interface FileRouteTypes {
| '/demo/tanstack-query' | '/demo/tanstack-query'
| '/planes' | '/planes'
| '/planes/nuevo' | '/planes/nuevo'
| '/planes/$planId/asignaturas'
| '/planes/$planId/documento' | '/planes/$planId/documento'
| '/planes/$planId/flujo' | '/planes/$planId/flujo'
| '/planes/$planId/historial' | '/planes/$planId/historial'
| '/planes/$planId/iaplan' | '/planes/$planId/iaplan'
| '/planes/$planId/mapa' | '/planes/$planId/mapa'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId' | '/planes/$planId'
| '/planes/$planId/asignaturas/nueva' | '/planes/$planId/asignaturas/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia' | '/planes/$planId/asignaturas'
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
| '/planes/$planId/asignaturas/$asignaturaId/documento'
| '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@@ -284,21 +214,15 @@ export interface FileRouteTypes {
| '/planes/_lista' | '/planes/_lista'
| '/planes/$planId/_detalle' | '/planes/$planId/_detalle'
| '/planes/_lista/nuevo' | '/planes/_lista/nuevo'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/_detalle/asignaturas'
| '/planes/$planId/_detalle/documento' | '/planes/$planId/_detalle/documento'
| '/planes/$planId/_detalle/flujo' | '/planes/$planId/_detalle/flujo'
| '/planes/$planId/_detalle/historial' | '/planes/$planId/_detalle/historial'
| '/planes/$planId/_detalle/iaplan' | '/planes/$planId/_detalle/iaplan'
| '/planes/$planId/_detalle/mapa' | '/planes/$planId/_detalle/mapa'
| '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/_detalle/' | '/planes/$planId/_detalle/'
| '/planes/$planId/_detalle/asignaturas/nueva' | '/planes/$planId/_detalle/asignaturas/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia' | '/planes/$planId/_detalle/asignaturas/'
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
| '/planes/$planId/asignaturas/$asignaturaId/documento'
| '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -308,7 +232,7 @@ export interface RootRouteChildren {
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
PlanesListaRoute: typeof PlanesListaRouteWithChildren PlanesListaRoute: typeof PlanesListaRouteWithChildren
PlanesPlanIdDetalleRoute: typeof PlanesPlanIdDetalleRouteWithChildren PlanesPlanIdDetalleRoute: typeof PlanesPlanIdDetalleRouteWithChildren
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren PlanesPlanIdAsignaturasAsignaturaIdRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -369,6 +293,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlanesPlanIdDetalleIndexRouteImport preLoaderRoute: typeof PlanesPlanIdDetalleIndexRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute parentRoute: typeof PlanesPlanIdDetalleRoute
} }
'/planes/$planId/asignaturas/$asignaturaId': {
id: '/planes/$planId/asignaturas/$asignaturaId'
path: '/planes/$planId/asignaturas/$asignaturaId'
fullPath: '/planes/$planId/asignaturas/$asignaturaId'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/$planId/_detalle/mapa': { '/planes/$planId/_detalle/mapa': {
id: '/planes/$planId/_detalle/mapa' id: '/planes/$planId/_detalle/mapa'
path: '/mapa' path: '/mapa'
@@ -404,68 +335,19 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlanesPlanIdDetalleDocumentoRouteImport preLoaderRoute: typeof PlanesPlanIdDetalleDocumentoRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute parentRoute: typeof PlanesPlanIdDetalleRoute
} }
'/planes/$planId/_detalle/asignaturas': { '/planes/$planId/_detalle/asignaturas/': {
id: '/planes/$planId/_detalle/asignaturas' id: '/planes/$planId/_detalle/asignaturas/'
path: '/asignaturas' path: '/asignaturas'
fullPath: '/planes/$planId/asignaturas' fullPath: '/planes/$planId/asignaturas/'
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasRouteImport preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasIndexRouteImport
parentRoute: typeof PlanesPlanIdDetalleRoute parentRoute: typeof PlanesPlanIdDetalleRoute
} }
'/planes/$planId/asignaturas/$asignaturaId': {
id: '/planes/$planId/asignaturas/$asignaturaId'
path: '/planes/$planId/asignaturas/$asignaturaId'
fullPath: '/planes/$planId/asignaturas/$asignaturaId'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/planes/$planId/asignaturas/$asignaturaId/': {
id: '/planes/$planId/asignaturas/$asignaturaId/'
path: '/'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': {
id: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
path: '/iaasignatura'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/historial': {
id: '/planes/$planId/asignaturas/$asignaturaId/historial'
path: '/historial'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/historial'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/documento': {
id: '/planes/$planId/asignaturas/$asignaturaId/documento'
path: '/documento'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/documento'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/contenido': {
id: '/planes/$planId/asignaturas/$asignaturaId/contenido'
path: '/contenido'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/contenido'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': {
id: '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
path: '/bibliografia'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
}
'/planes/$planId/_detalle/asignaturas/nueva': { '/planes/$planId/_detalle/asignaturas/nueva': {
id: '/planes/$planId/_detalle/asignaturas/nueva' id: '/planes/$planId/_detalle/asignaturas/nueva'
path: '/nueva' path: '/asignaturas/nueva'
fullPath: '/planes/$planId/asignaturas/nueva' fullPath: '/planes/$planId/asignaturas/nueva'
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRouteImport preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRouteImport
parentRoute: typeof PlanesPlanIdDetalleAsignaturasRoute parentRoute: typeof PlanesPlanIdDetalleRoute
} }
} }
} }
@@ -482,75 +364,33 @@ const PlanesListaRouteWithChildren = PlanesListaRoute._addFileChildren(
PlanesListaRouteChildren, PlanesListaRouteChildren,
) )
interface PlanesPlanIdDetalleAsignaturasRouteChildren {
PlanesPlanIdDetalleAsignaturasNuevaRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
}
const PlanesPlanIdDetalleAsignaturasRouteChildren: PlanesPlanIdDetalleAsignaturasRouteChildren =
{
PlanesPlanIdDetalleAsignaturasNuevaRoute:
PlanesPlanIdDetalleAsignaturasNuevaRoute,
}
const PlanesPlanIdDetalleAsignaturasRouteWithChildren =
PlanesPlanIdDetalleAsignaturasRoute._addFileChildren(
PlanesPlanIdDetalleAsignaturasRouteChildren,
)
interface PlanesPlanIdDetalleRouteChildren { interface PlanesPlanIdDetalleRouteChildren {
PlanesPlanIdDetalleAsignaturasRoute: typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
PlanesPlanIdDetalleDocumentoRoute: typeof PlanesPlanIdDetalleDocumentoRoute PlanesPlanIdDetalleDocumentoRoute: typeof PlanesPlanIdDetalleDocumentoRoute
PlanesPlanIdDetalleFlujoRoute: typeof PlanesPlanIdDetalleFlujoRoute PlanesPlanIdDetalleFlujoRoute: typeof PlanesPlanIdDetalleFlujoRoute
PlanesPlanIdDetalleHistorialRoute: typeof PlanesPlanIdDetalleHistorialRoute PlanesPlanIdDetalleHistorialRoute: typeof PlanesPlanIdDetalleHistorialRoute
PlanesPlanIdDetalleIaplanRoute: typeof PlanesPlanIdDetalleIaplanRoute PlanesPlanIdDetalleIaplanRoute: typeof PlanesPlanIdDetalleIaplanRoute
PlanesPlanIdDetalleMapaRoute: typeof PlanesPlanIdDetalleMapaRoute PlanesPlanIdDetalleMapaRoute: typeof PlanesPlanIdDetalleMapaRoute
PlanesPlanIdDetalleIndexRoute: typeof PlanesPlanIdDetalleIndexRoute PlanesPlanIdDetalleIndexRoute: typeof PlanesPlanIdDetalleIndexRoute
PlanesPlanIdDetalleAsignaturasNuevaRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
PlanesPlanIdDetalleAsignaturasIndexRoute: typeof PlanesPlanIdDetalleAsignaturasIndexRoute
} }
const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = { const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = {
PlanesPlanIdDetalleAsignaturasRoute:
PlanesPlanIdDetalleAsignaturasRouteWithChildren,
PlanesPlanIdDetalleDocumentoRoute: PlanesPlanIdDetalleDocumentoRoute, PlanesPlanIdDetalleDocumentoRoute: PlanesPlanIdDetalleDocumentoRoute,
PlanesPlanIdDetalleFlujoRoute: PlanesPlanIdDetalleFlujoRoute, PlanesPlanIdDetalleFlujoRoute: PlanesPlanIdDetalleFlujoRoute,
PlanesPlanIdDetalleHistorialRoute: PlanesPlanIdDetalleHistorialRoute, PlanesPlanIdDetalleHistorialRoute: PlanesPlanIdDetalleHistorialRoute,
PlanesPlanIdDetalleIaplanRoute: PlanesPlanIdDetalleIaplanRoute, PlanesPlanIdDetalleIaplanRoute: PlanesPlanIdDetalleIaplanRoute,
PlanesPlanIdDetalleMapaRoute: PlanesPlanIdDetalleMapaRoute, PlanesPlanIdDetalleMapaRoute: PlanesPlanIdDetalleMapaRoute,
PlanesPlanIdDetalleIndexRoute: PlanesPlanIdDetalleIndexRoute, PlanesPlanIdDetalleIndexRoute: PlanesPlanIdDetalleIndexRoute,
PlanesPlanIdDetalleAsignaturasNuevaRoute:
PlanesPlanIdDetalleAsignaturasNuevaRoute,
PlanesPlanIdDetalleAsignaturasIndexRoute:
PlanesPlanIdDetalleAsignaturasIndexRoute,
} }
const PlanesPlanIdDetalleRouteWithChildren = const PlanesPlanIdDetalleRouteWithChildren =
PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren) PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren)
interface PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren {
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
}
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren =
{
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute:
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute,
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute:
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute,
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute:
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute,
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute:
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute,
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute:
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute,
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute:
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute,
}
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren =
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute._addFileChildren(
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren,
)
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
DashboardRoute: DashboardRoute, DashboardRoute: DashboardRoute,
@@ -558,8 +398,8 @@ const rootRouteChildren: RootRouteChildren = {
DemoTanstackQueryRoute: DemoTanstackQueryRoute, DemoTanstackQueryRoute: DemoTanstackQueryRoute,
PlanesListaRoute: PlanesListaRouteWithChildren, PlanesListaRoute: PlanesListaRouteWithChildren,
PlanesPlanIdDetalleRoute: PlanesPlanIdDetalleRouteWithChildren, PlanesPlanIdDetalleRoute: PlanesPlanIdDetalleRouteWithChildren,
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute: PlanesPlanIdAsignaturasAsignaturaIdRoute:
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren, PlanesPlanIdAsignaturasAsignaturaIdRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -1,59 +1,22 @@
import { TanStackDevtools } from '@tanstack/react-devtools' import { TanStackDevtools } from '@tanstack/react-devtools'
import { import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
Outlet,
createRootRouteWithContext,
redirect,
useNavigate,
useRouterState,
} from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { useEffect } from 'react'
import Header from '../components/Header' import Header from '../components/Header'
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools' import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
import type { Database } from '@/types/supabase'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { QueryClient } from '@tanstack/react-query' import type { QueryClient } from '@tanstack/react-query'
import { NotFoundPage } from '@/components/ui/NotFoundPage' import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { throwIfError } from '@/data/api/_helpers'
import { useSession } from '@/data/hooks/useAuth'
import { qk } from '@/data/query/keys'
interface MyRouterContext { interface MyRouterContext {
queryClient: QueryClient queryClient: QueryClient
supabase: SupabaseClient<Database>
} }
export const Route = createRootRouteWithContext<MyRouterContext>()({ export const Route = createRootRouteWithContext<MyRouterContext>()({
beforeLoad: async ({ context, location }) => {
const pathname = location.pathname
const isLogin = pathname === '/login'
const isIndex = pathname === '/'
const session = await context.queryClient.ensureQueryData({
queryKey: qk.session(),
queryFn: async () => {
const { data, error } = await context.supabase.auth.getSession()
throwIfError(error)
return data.session ?? null
},
staleTime: Infinity,
})
if (!session && !isLogin) {
throw redirect({ to: '/login' })
}
if (session && (isLogin || isIndex)) {
throw redirect({ to: '/dashboard' })
}
},
component: () => ( component: () => (
<> <>
<AuthSync /> <Header />
<MaybeHeader />
<Outlet /> <Outlet />
<TanStackDevtools <TanStackDevtools
config={{ config={{
@@ -97,40 +60,3 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
) )
}, },
}) })
function MaybeHeader() {
const pathname = useRouterState({
select: (s) => s.location.pathname,
})
if (pathname === '/login') return null
return <Header />
}
function AuthSync() {
const { data: session, isLoading } = useSession()
// Mantiene roles/permisos sincronizados con la BD (database-first)
// useMeAccess()
const navigate = useNavigate()
const pathname = useRouterState({
select: (s) => s.location.pathname,
})
// Reaccionar a cambios de sesión (login/logout) sin depender solo de beforeLoad.
// Nota: beforeLoad sigue siendo la línea de defensa en navegación/refresh.
useEffect(() => {
if (isLoading) return
if (!session && pathname !== '/login') {
void navigate({ to: '/login', replace: true })
return
}
if (session && pathname === '/login') {
void navigate({ to: '/dashboard', replace: true })
}
}, [isLoading, session, pathname, navigate])
return null
}

View File

@@ -5,10 +5,12 @@ import {
Clock, Clock,
Hash, Hash,
CalendarDays, CalendarDays,
Save,
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, forwardRef } from 'react' import { useState, useEffect, forwardRef } from 'react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -18,12 +20,14 @@ import {
import { NotFoundPage } from '@/components/ui/NotFoundPage' import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { plans_get } from '@/data/api/plans.api' import { plans_get } from '@/data/api/plans.api'
import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans' import { usePlan } from '@/data/hooks/usePlans'
import { qk } from '@/data/query/keys' import { qk } from '@/data/query/keys'
export const Route = createFileRoute('/planes/$planId/_detalle')({ export const Route = createFileRoute('/planes/$planId/_detalle')({
loader: async ({ context: { queryClient }, params: { planId } }) => { loader: async ({ context: { queryClient }, params: { planId } }) => {
try { try {
console.log('loader')
await queryClient.ensureQueryData({ await queryClient.ensureQueryData({
queryKey: qk.plan(planId), queryKey: qk.plan(planId),
queryFn: () => plans_get(planId), queryFn: () => plans_get(planId),
@@ -31,6 +35,8 @@ export const Route = createFileRoute('/planes/$planId/_detalle')({
} catch (e: any) { } catch (e: any) {
// PGRST116: The result contains 0 rows // PGRST116: The result contains 0 rows
if (e?.code === 'PGRST116') { if (e?.code === 'PGRST116') {
console.log('not found on', Route.path)
throw notFound() throw notFound()
} }
throw e throw e
@@ -50,7 +56,6 @@ export const Route = createFileRoute('/planes/$planId/_detalle')({
function RouteComponent() { function RouteComponent() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const { data, isLoading } = usePlan(planId) const { data, isLoading } = usePlan(planId)
const { mutate } = useUpdatePlanFields()
// Estados locales para manejar la edición "en vivo" antes de persistir // Estados locales para manejar la edición "en vivo" antes de persistir
const [nombrePlan, setNombrePlan] = useState('') const [nombrePlan, setNombrePlan] = useState('')
@@ -72,49 +77,32 @@ function RouteComponent() {
'Especialidad', 'Especialidad',
] ]
const persistChange = (patch: any) => { const handleKeyDown = (e: React.KeyboardEvent) => {
mutate({ planId, patch })
}
const MAX_CHARACTERS = 200
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
// 1. Permitir teclas de control (Borrar, flechas, etc.) siempre
const isControlKey =
e.key === 'Backspace' ||
e.key === 'Delete' ||
e.key.includes('Arrow') ||
e.metaKey ||
e.ctrlKey
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault() e.preventDefault() // Evita el salto de línea
e.currentTarget.blur() e.currentTarget.blur() // Quita el foco, lo que dispara el onBlur y "guarda" en el estado
return
}
// 2. Bloquear si excede los 200 caracteres y no es una tecla de control
const currentText = e.currentTarget.textContent || ''
if (currentText.length >= MAX_CHARACTERS && !isControlKey) {
e.preventDefault()
} }
} }
const handlePaste = (e: React.ClipboardEvent<HTMLSpanElement>) => { const handleSave = () => {
e.preventDefault() console.log('Guardando en DB...', { nombrePlan, nivelPlan })
const text = e.clipboardData.getData('text/plain') // Aquí iría tu mutation
const currentText = e.currentTarget.textContent || '' setIsDirty(false)
// Calcular cuánto espacio queda
const remainingSpace = MAX_CHARACTERS - currentText.length
if (remainingSpace > 0) {
const slicedText = text.slice(0, remainingSpace)
document.execCommand('insertText', false, slicedText)
}
} }
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
{/* Botón Flotante de Guardar */}
{isDirty && (
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-8 bottom-8 z-50 duration-300">
<Button
onClick={handleSave}
className="gap-2 rounded-full bg-teal-600 px-6 shadow-xl hover:bg-teal-700"
>
<Save size={16} /> Guardar cambios del Plan
</Button>
</div>
)}
{/* 1. Header Superior */} {/* 1. Header Superior */}
<div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm"> <div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm">
<div className="px-6 py-2"> <div className="px-6 py-2">
@@ -128,39 +116,37 @@ function RouteComponent() {
</div> </div>
<div className="mx-auto max-w-400 space-y-8 p-8"> <div className="mx-auto max-w-400 space-y-8 p-8">
{/* 2. Header del Plan */} {/* Header del Plan */}
{isLoading ? ( {isLoading ? (
/* ===== SKELETON ===== */ /* ===== SKELETON ===== */
<div className="mx-auto max-w-400 p-8">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<DatosGeneralesSkeleton key={i} /> <DatosGeneralesSkeleton key={i} />
))} ))}
</div> </div>
</div>
) : ( ) : (
<>
<div className="flex flex-col items-start justify-between gap-4 md:flex-row"> <div className="flex flex-col items-start justify-between gap-4 md:flex-row">
<div> <div>
<h1 className="flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight text-slate-900"> <h1 className="flex items-baseline gap-2 text-3xl font-bold tracking-tight text-slate-900">
{/* El prefijo "Nivel en" lo mantenemos simple */} <span>{nivelPlan} en</span>
<span className="shrink-0">{nivelPlan} en</span>
<span <span
role="textbox" role="textbox"
tabIndex={0} tabIndex={0}
contentEditable contentEditable
suppressContentEditableWarning suppressContentEditableWarning
spellCheck={false} spellCheck={false} // Quita el subrayado rojo de error ortográfico
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} // Añadido para controlar lo que pegan onBlur={(e) =>
onBlur={(e) => { setNombrePlan(e.currentTarget.textContent || '')
const nuevoNombre =
e.currentTarget.textContent?.trim() || ''
setNombrePlan(nuevoNombre)
if (nuevoNombre !== data?.nombre) {
mutate({ planId, patch: { nombre: nuevoNombre } })
} }
}} className="cursor-text border-b border-transparent decoration-transparent transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500"
// Clases añadidas: break-words y whitespace-pre-wrap para el wrap style={{
className="block w-full cursor-text border-b border-transparent break-words whitespace-pre-wrap transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500 sm:inline-block sm:w-auto" WebkitTextDecoration: 'none',
style={{ textDecoration: 'none' }} textDecoration: 'none',
}} // Doble seguridad contra subrayados
> >
{nombrePlan} {nombrePlan}
</span> </span>
@@ -172,14 +158,20 @@ function RouteComponent() {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 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} /> {data?.estados_plan?.etiqueta}
</Badge> */}
<Badge
className={`gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100`}
>
{data?.estados_plan?.etiqueta} {data?.estados_plan?.etiqueta}
</Badge> </Badge>
</div> </div>
</div> </div>
</>
)} )}
{/* 3. Cards de Información */} {/* 3. Cards de Información con Context Menu */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -197,9 +189,7 @@ function RouteComponent() {
key={n} key={n}
onClick={() => { onClick={() => {
setNivelPlan(n) setNivelPlan(n)
if (n !== data?.nivel) { setIsDirty(true)
mutate({ planId, patch: { nivel: n } })
}
}} }}
> >
{n} {n}
@@ -221,7 +211,7 @@ function RouteComponent() {
<InfoCard <InfoCard
icon={<CalendarDays className="text-slate-400" />} icon={<CalendarDays className="text-slate-400" />}
label="Creación" label="Creación"
value={data?.creado_en?.split('T')[0]} value={data?.creado_en?.split('T')[0]} // Cortamos la fecha para que no sea tan larga
/> />
</div> </div>
@@ -235,7 +225,7 @@ function RouteComponent() {
Mapa Curricular Mapa Curricular
</Tab> </Tab>
<Tab to="/planes/$planId/asignaturas" params={{ planId }}> <Tab to="/planes/$planId/asignaturas" params={{ planId }}>
Asignaturas Materias
</Tab> </Tab>
<Tab to="/planes/$planId/flujo" params={{ planId }}> <Tab to="/planes/$planId/flujo" params={{ planId }}>
Flujo y Estados Flujo y Estados
@@ -304,7 +294,6 @@ function Tab({
}: { }: {
to: string to: string
params?: any params?: any
search?: any
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return (

View File

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

View File

@@ -1,16 +1,16 @@
import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { import {
Plus, Plus,
Copy,
Search, Search,
Filter, Filter,
ChevronRight, ChevronRight,
BookOpen, BookOpen,
Loader2, Loader2,
} from 'lucide-react' } from 'lucide-react'
import { useMemo, useState } from 'react' import { useState, useMemo } from 'react'
import type { Asignatura, AsignaturaStatus, TipoAsignatura } from '@/types/plan' import type { Materia } from '@/types/plan'
import type { Tables } from '@/types/supabase'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -33,60 +33,45 @@ import {
import { usePlanAsignaturas, usePlanLineas } from '@/data' import { usePlanAsignaturas, usePlanLineas } from '@/data'
// --- Configuración de Estilos --- // --- Configuración de Estilos ---
const statusConfig: Record< const statusConfig: Record<string, { label: string; className: string }> = {
AsignaturaStatus,
{ label: string; className: string }
> = {
generando: {
label: 'Generando',
className:
'bg-slate-100 text-slate-600 animate-pulse [animation-duration:2s]',
},
borrador: { label: 'Borrador', className: 'bg-slate-100 text-slate-600' }, borrador: { label: 'Borrador', className: 'bg-slate-100 text-slate-600' },
revisada: { label: 'Revisada', className: 'bg-amber-100 text-amber-700' }, revisada: { label: 'Revisada', className: 'bg-amber-100 text-amber-700' },
aprobada: { label: 'Aprobada', className: 'bg-emerald-100 text-emerald-700' }, aprobada: { label: 'Aprobada', className: 'bg-emerald-100 text-emerald-700' },
} }
const tipoConfig: Record<TipoAsignatura, { label: string; className: string }> = const tipoConfig: Record<string, { label: string; className: string }> = {
{ obligatoria: { label: 'Obligatoria', className: 'bg-blue-100 text-blue-700' },
OBLIGATORIA: { optativa: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' },
label: 'Obligatoria', troncal: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' },
className: 'bg-blue-100 text-blue-700', }
},
OPTATIVA: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' },
TRONCAL: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' },
OTRA: { label: 'Otra', className: 'bg-slate-100 text-slate-700' },
}
// --- Mapeadores de API --- // --- Mapeadores de API ---
const mapAsignaturas = ( const mapAsignaturas = (asigApi: Array<any> = []): Array<Materia> => {
asigApi: Array<Tables<'asignaturas'>> = [],
): Array<Asignatura> => {
return asigApi.map((asig) => ({ return asigApi.map((asig) => ({
id: asig.id, id: asig.id,
clave: asig.codigo ?? '', clave: asig.codigo,
nombre: asig.nombre, nombre: asig.nombre,
creditos: asig.creditos, creditos: asig.creditos ?? 0,
ciclo: asig.numero_ciclo ?? null, ciclo: asig.numero_ciclo ?? null,
lineaCurricularId: asig.linea_plan_id ?? null, lineaCurricularId: asig.linea_plan_id ?? null,
tipo: asig.tipo, tipo:
estado: asig.estado, asig.tipo?.toLowerCase() === 'obligatoria' ? 'obligatoria' : 'optativa',
hd: asig.horas_academicas ?? 0, estado: 'borrador', // O el campo que venga de tu API
hi: asig.horas_independientes ?? 0, hd: Math.floor((asig.horas_semana ?? 0) / 2),
prerrequisitos: [], hi: Math.ceil((asig.horas_semana ?? 0) / 2),
})) }))
} }
export const Route = createFileRoute('/planes/$planId/_detalle/asignaturas')({ export const Route = createFileRoute('/planes/$planId/_detalle/asignaturas/')({
component: AsignaturasPage, component: MateriasPage,
}) })
function AsignaturasPage() { function MateriasPage() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const navigate = useNavigate() const navigate = useNavigate()
// 1. Fetch de datos reales // 1. Fetch de datos reales
const { data: asignaturaApi, isLoading: loadingAsig } = const { data: asignaturasApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId) usePlanAsignaturas(planId)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId) const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
@@ -97,13 +82,13 @@ function AsignaturasPage() {
const [filterLinea, setFilterLinea] = useState<string>('all') const [filterLinea, setFilterLinea] = useState<string>('all')
// 3. Procesamiento de datos // 3. Procesamiento de datos
const asignaturas = useMemo( const materias = useMemo(
() => mapAsignaturas(asignaturaApi), () => mapAsignaturas(asignaturasApi),
[asignaturaApi], [asignaturasApi],
) )
const lineas = useMemo(() => lineasApi || [], [lineasApi]) const lineas = useMemo(() => lineasApi || [], [lineasApi])
const filteredAsignaturas = asignaturas.filter((m) => { const filteredMaterias = materias.filter((m) => {
const matchesSearch = const matchesSearch =
m.nombre.toLowerCase().includes(searchTerm.toLowerCase()) || m.nombre.toLowerCase().includes(searchTerm.toLowerCase()) ||
m.clave.toLowerCase().includes(searchTerm.toLowerCase()) m.clave.toLowerCase().includes(searchTerm.toLowerCase())
@@ -134,15 +119,18 @@ function AsignaturasPage() {
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h2 className="text-foreground text-xl font-bold"> <h2 className="text-foreground text-xl font-bold">
Asignaturas del Plan Materias del Plan
</h2> </h2>
<p className="text-muted-foreground mt-1 text-sm"> <p className="text-muted-foreground mt-1 text-sm">
{asignaturas.length} asignaturas en total {' '} {materias.length} materias en total {filteredMaterias.length}{' '}
{filteredAsignaturas.length} filtradas filtradas
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm">
<Copy className="mr-2 h-4 w-4" /> Clonar
</Button>
<Button <Button
onClick={() => { onClick={() => {
console.log('planId desde asignaturas', planId) console.log('planId desde asignaturas', planId)
@@ -154,7 +142,7 @@ function AsignaturasPage() {
}} }}
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors" className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
> >
<Plus className="mr-2 h-4 w-4" /> Nueva Asignatura <Plus className="mr-2 h-4 w-4" /> Nueva Materia
</Button> </Button>
</div> </div>
</div> </div>
@@ -229,12 +217,12 @@ function AsignaturasPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredAsignaturas.length === 0 ? ( {filteredMaterias.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={8} className="h-40 text-center"> <TableCell colSpan={8} className="h-40 text-center">
<div className="text-muted-foreground flex flex-col items-center justify-center"> <div className="text-muted-foreground flex flex-col items-center justify-center">
<BookOpen className="mb-2 h-10 w-10 opacity-20" /> <BookOpen className="mb-2 h-10 w-10 opacity-20" />
<p className="font-medium">No se encontraron asignaturas</p> <p className="font-medium">No se encontraron materias</p>
<p className="text-xs"> <p className="text-xs">
Intenta cambiar los filtros de búsqueda Intenta cambiar los filtros de búsqueda
</p> </p>
@@ -242,59 +230,59 @@ function AsignaturasPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredAsignaturas.map((asignatura) => ( filteredMaterias.map((materia) => (
<TableRow <TableRow
key={asignatura.id} key={materia.id}
className="group cursor-pointer transition-colors hover:bg-slate-50/80" className="group cursor-pointer transition-colors hover:bg-slate-50/80"
onClick={() => onClick={() =>
navigate({ navigate({
to: '/planes/$planId/asignaturas/$asignaturaId', to: '/planes/$planId/asignaturas/$asignaturaId',
params: { params: {
planId, planId,
asignaturaId: asignatura.id, // 👈 puede ser índice, consecutivo o slug asignaturaId: materia.id, // 👈 puede ser índice, consecutivo o slug
}, },
state: { state: {
realId: asignatura.id, // 👈 ID largo oculto realId: materia.id, // 👈 ID largo oculto
asignaturaId: asignatura.id, asignaturaId: materia.id,
} as any, } as any,
}) })
} }
> >
<TableCell className="font-mono text-xs font-bold text-slate-400"> <TableCell className="font-mono text-xs font-bold text-slate-400">
{asignatura.clave} {materia.clave}
</TableCell> </TableCell>
<TableCell className="font-semibold text-slate-700"> <TableCell className="font-semibold text-slate-700">
{asignatura.nombre} {materia.nombre}
</TableCell> </TableCell>
<TableCell className="text-center font-medium"> <TableCell className="text-center font-medium">
{asignatura.creditos} {materia.creditos}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
{asignatura.ciclo ? ( {materia.ciclo ? (
<Badge variant="outline" className="font-normal"> <Badge variant="outline" className="font-normal">
Ciclo {asignatura.ciclo} Ciclo {materia.ciclo}
</Badge> </Badge>
) : ( ) : (
<span className="text-slate-300"></span> <span className="text-slate-300"></span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-sm text-slate-600"> <TableCell className="text-sm text-slate-600">
{getLineaNombre(asignatura.lineaCurricularId)} {getLineaNombre(materia.lineaCurricularId)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <Badge
variant="outline" variant="outline"
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`} className={`capitalize shadow-sm ${tipoConfig[materia.tipo]?.className}`}
> >
{tipoConfig[asignatura.tipo].label} {tipoConfig[materia.tipo]?.label}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge <Badge
variant="outline" variant="outline"
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`} className={`capitalize shadow-sm ${statusConfig[materia.estado]?.className}`}
> >
{statusConfig[asignatura.estado].label} {statusConfig[materia.estado]?.label}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -308,7 +296,6 @@ function AsignaturasPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<Outlet />
</div> </div>
) )
} }

View File

@@ -12,13 +12,10 @@ import {
Eye, Eye,
History, History,
Calendar, Calendar,
ChevronLeft,
ChevronRight,
} from 'lucide-react' } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { import {
Dialog, Dialog,
@@ -26,7 +23,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { usePlan, usePlanHistorial } from '@/data/hooks/usePlans' import { usePlanHistorial } from '@/data/hooks/usePlans'
export const Route = createFileRoute('/planes/$planId/_detalle/historial')({ export const Route = createFileRoute('/planes/$planId/_detalle/historial')({
component: RouteComponent, component: RouteComponent,
@@ -60,23 +57,12 @@ const getEventConfig = (tipo: string, campo: string) => {
function RouteComponent() { function RouteComponent() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const [page, setPage] = useState(0) const { data: rawData, isLoading } = usePlanHistorial(planId)
const pageSize = 4
const { data: response, isLoading } = usePlanHistorial(planId, page) // ESTADOS PARA EL MODAL
const rawData = response?.data ?? []
const totalRecords = response?.count ?? 0
const totalPages = Math.ceil(totalRecords / pageSize)
const [structure, setStructure] = useState<any>(null)
const { data } = usePlan(planId)
const [selectedEvent, setSelectedEvent] = useState<any>(null) const [selectedEvent, setSelectedEvent] = useState<any>(null)
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
useEffect(() => {
if (data?.estructuras_plan?.definicion?.properties) {
setStructure(data.estructuras_plan.definicion.properties)
}
}, [data])
const historyEvents = useMemo(() => { const historyEvents = useMemo(() => {
if (!rawData) return [] if (!rawData) return []
return rawData.map((item: any) => { return rawData.map((item: any) => {
@@ -91,13 +77,10 @@ function RouteComponent() {
description: description:
item.campo === 'datos' item.campo === 'datos'
? `Actualización general de: ${item.valor_nuevo?.nombre || 'información del plan'}` ? `Actualización general de: ${item.valor_nuevo?.nombre || 'información del plan'}`
: `Se modificó el campo ${ : `Se modificó el campo ${item.campo}`,
structure?.[item.campo]?.title ?? item.campo
}`,
date: parseISO(item.cambiado_en), date: parseISO(item.cambiado_en),
icon: config.icon, icon: config.icon,
campo: campo: item.campo,
data?.estructuras_plan?.definicion?.properties?.[item.campo]?.title,
details: { details: {
from: item.valor_anterior, from: item.valor_anterior,
to: item.valor_nuevo, to: item.valor_nuevo,
@@ -232,46 +215,6 @@ function RouteComponent() {
</div> </div>
)) ))
)} )}
{historyEvents.length > 0 && (
<div className="mt-10 ml-20 flex items-center justify-between border-t pt-4">
<p className="text-xs text-slate-500">
Mostrando {rawData.length} de {totalRecords} cambios
</p>
<div className="flex items-center gap-4">
<Button
variant="outline"
size="sm"
onClick={() => {
setPage((p) => Math.max(0, p - 1))
window.scrollTo(0, 0) // Opcional: volver arriba
}}
disabled={page === 0 || isLoading}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Anterior
</Button>
<span className="text-sm font-medium text-slate-700">
Página {page + 1} de {totalPages || 1}
</span>
<Button
variant="outline"
size="sm"
onClick={() => {
setPage((p) => p + 1)
window.scrollTo(0, 0)
}}
// Ahora se deshabilita si llegamos a la última página real
disabled={page + 1 >= totalPages || isLoading}
>
Siguiente
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)}
</div> </div>
{/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */} {/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */}
@@ -299,8 +242,6 @@ function RouteComponent() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="grid h-full grid-cols-2 gap-6"> <div className="grid h-full grid-cols-2 gap-6">
{/* Lado Antes */} {/* Lado Antes */}
{/* Lado Antes: Solo se renderiza si existe valor_anterior */}
{selectedEvent?.details.from && (
<div className="flex flex-col space-y-2"> <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="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" /> <div className="h-2 w-2 rounded-full bg-red-400" />
@@ -309,10 +250,9 @@ function RouteComponent() {
</span> </span>
</div> </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"> <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)} {renderValue(selectedEvent?.details.from)}
</div> </div>
</div> </div>
)}
{/* Lado Después */} {/* Lado Después */}
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
@@ -332,11 +272,6 @@ function RouteComponent() {
<div className="flex justify-center border-t bg-slate-50 p-4"> <div className="flex justify-center border-t bg-slate-50 p-4">
<Badge variant="outline" className="font-mono text-[10px]"> <Badge variant="outline" className="font-mono text-[10px]">
Campo: {selectedEvent?.campo} Campo: {selectedEvent?.campo}
{console.log(
data?.estructuras_plan?.definicion?.properties?.[
selectedEvent?.campo
]?.title,
)}
</Badge> </Badge>
</div> </div>
</DialogContent> </DialogContent>

View File

@@ -1,37 +1,22 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
import { useQueryClient } from '@tanstack/react-query'
import { createFileRoute, useRouterState } from '@tanstack/react-router' import { createFileRoute, useRouterState } from '@tanstack/react-router'
import { import {
Sparkles,
Send, Send,
Target, Target,
UserCheck,
Lightbulb, Lightbulb,
FileText, FileText,
GraduationCap, GraduationCap,
BookOpen, BookOpen,
Check, Check,
X, X,
MessageSquarePlus,
Archive,
RotateCcw,
Loader2,
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone' import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import {
useAIPlanChat,
useConversationByPlan,
useUpdateConversationStatus,
useUpdateConversationTitle,
} from '@/data'
import { usePlan } from '@/data/hooks/usePlans' import { usePlan } from '@/data/hooks/usePlans'
const PRESETS = [ const PRESETS = [
@@ -67,284 +52,72 @@ interface SelectedField {
label: string label: string
value: string value: string
} }
interface EstructuraDefinicion {
properties?: { const formatLabel = (key: string) => {
[key: string]: { const result = key.replace(/_/g, ' ')
title: string return result.charAt(0).toUpperCase() + result.slice(1)
description?: string
}
}
}
interface ChatMessageJSON {
user: 'user' | 'assistant'
message?: string
prompt?: string
refusal?: boolean
recommendations?: Array<{
campo_afectado: string
texto_mejora: string
aplicada: boolean
}>
} }
export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({ export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({
component: RouteComponent, component: RouteComponent,
}) })
function RouteComponent() { function RouteComponent() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const { data } = usePlan(planId) // 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() const routerState = useRouterState()
const [openIA, setOpenIA] = useState(false)
const { mutateAsync: sendChat, isPending: isLoading } = useAIPlanChat()
const { mutate: updateStatusMutation } = useUpdateConversationStatus()
const [activeChatId, setActiveChatId] = useState<string | undefined>( // ESTADOS PRINCIPALES
undefined, const [messages, setMessages] = useState<Array<any>>([
) {
const { data: lastConversation, isLoading: isLoadingConv } = id: '1',
useConversationByPlan(planId) role: 'assistant',
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>( content:
[], '¡Hola! Soy tu asistente de IA. ¿Qué campos deseas mejorar? Puedes escribir ":" para seleccionar uno.',
) },
const [selectedRepositorioIds, setSelectedRepositorioIds] = useState< ])
Array<string>
>([])
const [uploadedFiles, setUploadedFiles] = useState<Array<UploadedFile>>([])
const [messages, setMessages] = useState<Array<any>>([])
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([]) const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
const [showSuggestions, setShowSuggestions] = useState(false) const [showSuggestions, setShowSuggestions] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null) const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
const queryClient = useQueryClient()
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const [showArchived, setShowArchived] = useState(false)
const [editingChatId, setEditingChatId] = useState<string | null>(null) // 1. Transformar datos de la API para el menú de selección
const editableRef = useRef<HTMLSpanElement>(null)
const { mutate: updateTitleMutation } = useUpdateConversationTitle()
const [isSending, setIsSending] = useState(false)
const [optimisticMessage, setOptimisticMessage] = useState<string | null>(
null,
)
const [filterQuery, setFilterQuery] = useState('')
const availableFields = useMemo(() => { const availableFields = useMemo(() => {
const definicion = data?.estructuras_plan if (!data?.datos) return []
?.definicion as EstructuraDefinicion return Object.entries(data.datos).map(([key, value]) => ({
// Encadenamiento opcional para evitar errores si data es null
if (!definicion.properties) return []
return Object.entries(definicion.properties).map(([key, value]) => ({
key, key,
label: value.title, label: formatLabel(key),
value: String(value.description || ''), value: String(value || ''),
})) }))
}, [data]) }, [data])
const filteredFields = useMemo(() => { // 2. Manejar el estado inicial si viene de "Datos Generales"
return availableFields.filter(
(field) =>
field.label.toLowerCase().includes(filterQuery.toLowerCase()) &&
!selectedFields.some((s) => s.key === field.key), // No mostrar ya seleccionados
)
}, [availableFields, filterQuery, selectedFields])
const activeChatData = useMemo(() => {
return lastConversation?.find((chat: any) => chat.id === activeChatId)
}, [lastConversation, activeChatId])
const chatMessages = useMemo(() => {
// 1. Si no hay ID o no hay data del chat, retornamos vacío
if (!activeChatId || !activeChatData) return []
const json = (activeChatData.conversacion_json ||
[]) as unknown as Array<ChatMessageJSON>
// 2. Verificamos que 'json' sea realmente un array antes de mapear
if (!Array.isArray(json)) return []
return json.map((msg, index: number) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!msg?.user) {
return {
id: `err-${index}`,
role: 'assistant',
content: '',
suggestions: [],
}
}
const isAssistant = msg.user === 'assistant'
return {
id: `${activeChatId}-${index}`,
role: isAssistant ? 'assistant' : 'user',
content: isAssistant ? msg.message || '' : msg.prompt || '', // Agregamos fallback a string vacío
isRefusal: isAssistant && msg.refusal === true,
suggestions:
isAssistant && msg.recommendations
? msg.recommendations.map((rec) => {
const fieldConfig = availableFields.find(
(f) => f.key === rec.campo_afectado,
)
return {
key: rec.campo_afectado,
label: fieldConfig
? fieldConfig.label
: rec.campo_afectado.replace(/_/g, ' '),
newValue: rec.texto_mejora,
applied: rec.aplicada,
}
})
: [],
}
})
}, [activeChatData, activeChatId, availableFields])
const scrollToBottom = () => {
if (scrollRef.current) {
// Buscamos el viewport interno del ScrollArea de Radix
const scrollContainer = scrollRef.current.querySelector(
'[data-radix-scroll-area-viewport]',
)
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'smooth',
})
}
}
}
const { activeChats, archivedChats } = useMemo(() => {
const allChats = lastConversation || []
return {
activeChats: allChats.filter((chat: any) => chat.estado === 'ACTIVA'),
archivedChats: allChats.filter(
(chat: any) => chat.estado === 'ARCHIVADA',
),
}
}, [lastConversation])
useEffect(() => {
scrollToBottom()
}, [chatMessages, isLoading])
useEffect(() => {
// Verificamos cuáles campos de la lista "selectedFields" ya no están presentes en el texto del input
const camposActualizados = selectedFields.filter((field) =>
input.includes(field.label),
)
// Solo actualizamos el estado si hubo un cambio real (para evitar bucles infinitos)
if (camposActualizados.length !== selectedFields.length) {
setSelectedFields(camposActualizados)
}
}, [input, selectedFields])
useEffect(() => {
if (isLoadingConv || !lastConversation) return
const isChatStillActive = activeChats.some(
(chat) => chat.id === activeChatId,
)
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
// Caso A: El chat actual ya no es válido (fue archivado o borrado)
if (activeChatId && !isChatStillActive && !isCreationMode) {
setActiveChatId(undefined)
setMessages([])
return // Salimos para evitar ejecuciones extra en este render
}
// Caso B: No hay chat seleccionado y hay chats disponibles (Auto-selección al cargar)
if (!activeChatId && activeChats.length > 0 && !isCreationMode) {
setActiveChatId(activeChats[0].id)
}
// Caso C: Si la lista de chats está vacía y no estamos creando uno, limpiar por si acaso
if (activeChats.length === 0 && activeChatId && !isCreationMode) {
setActiveChatId(undefined)
}
}, [activeChats, activeChatId, isLoadingConv, messages.length])
useEffect(() => { useEffect(() => {
const state = routerState.location.state as any const state = routerState.location.state as any
if (!state?.campo_edit || availableFields.length === 0) return if (state?.prefill && availableFields.length > 0) {
// Intentamos encontrar qué campo es por su valor o si mandaste el fieldKey
const field = availableFields.find( const field = availableFields.find(
(f) => (f) => f.value === state.prefill || f.key === state.fieldKey,
f.value === state.campo_edit.label || f.key === state.campo_edit.clave,
) )
if (!field) return if (field && !selectedFields.find((sf) => sf.key === field.key)) {
setSelectedFields([field]) setSelectedFields([field])
setInput((prev) => }
injectFieldsIntoInput(prev || 'Mejora este campo:', [field]), setInput(`Mejora este campo: ${field?.label} `)
) }
}, [availableFields]) }, [availableFields])
const createNewChat = () => { // 3. Lógica para el disparador ":"
setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo
setMessages([
{
id: 'welcome',
role: 'assistant',
content: 'Iniciando una nueva conversación. ¿En qué puedo ayudarte?',
},
])
setInput('')
setSelectedFields([])
}
const archiveChat = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
updateStatusMutation(
{ id, estado: 'ARCHIVADA' },
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId],
})
if (activeChatId === id) {
setActiveChatId(undefined)
setMessages([])
setOptimisticMessage(null)
setInput('')
setSelectedFields([])
}
},
},
)
}
const unarchiveChat = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
updateStatusMutation(
{ id, estado: 'ACTIVA' },
{
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId],
})
},
},
)
}
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value const val = e.target.value
const cursorPosition = e.target.selectionStart // Dónde está escribiendo el usuario
setInput(val) setInput(val)
if (val.endsWith(':')) {
// Busca un ":" seguido de letras justo antes del cursor
const textBeforeCursor = val.slice(0, cursorPosition)
const match = textBeforeCursor.match(/:(\w*)$/)
if (match) {
setShowSuggestions(true) setShowSuggestions(true)
setFilterQuery(match[1]) // Esto es lo que se usa para el filtrado
} else { } else {
setShowSuggestions(false) setShowSuggestions(false)
setFilterQuery('')
} }
} }
@@ -352,259 +125,91 @@ function RouteComponent() {
input: string, input: string,
fields: Array<SelectedField>, fields: Array<SelectedField>,
) => { ) => {
const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim() const baseText = input.replace(/\[[^\]]+]/g, '').trim()
if (fields.length === 0) return cleaned const tags = fields.map((f) => `[${f.label}]`).join(' ')
const fieldLabels = fields.map((f) => f.label).join(', ') return `${baseText} ${tags}`.trim()
return `${cleaned}\n[Campos: ${fieldLabels}]`
} }
const toggleField = (field: SelectedField) => { const toggleField = (field: SelectedField) => {
// 1. Lo agregamos a la lista de "SelectedFields" (para que la IA sepa qué procesar)
setSelectedFields((prev) => { setSelectedFields((prev) => {
const isSelected = prev.find((f) => f.key === field.key) let nextFields
return isSelected ? prev : [...prev, field]
})
// 2. Insertamos el nombre del campo en el texto exactamente donde estaba el ":" if (prev.find((f) => f.key === field.key)) {
setInput((prev) => { nextFields = prev.filter((f) => f.key !== field.key)
// Reemplaza el último ":" y cualquier texto de filtro por el label del campo } else {
const nuevoTexto = prev.replace(/:(\w*)$/, field.label) nextFields = [...prev, field]
return nuevoTexto + ' ' // Añadimos un espacio para que el usuario siga escribiendo
})
// 3. Limpiamos estados de búsqueda
setShowSuggestions(false)
setFilterQuery('')
} }
const buildPrompt = (userInput: string, fields: Array<SelectedField>) => { setInput((prevInput) =>
if (fields.length === 0) return userInput injectFieldsIntoInput(prevInput || 'Mejora este campo:', nextFields),
)
return ` ${userInput}` return nextFields
})
setShowSuggestions(false)
}
const buildPrompt = (userInput: string) => {
if (selectedFields.length === 0) return userInput
const fieldsText = selectedFields
.map((f) => `- ${f.label}: ${f.value || '(sin contenido)'}`)
.join('\n')
return `
${userInput || 'Mejora los siguientes campos:'}
Campos a analizar:
${fieldsText}
`.trim()
} }
const handleSend = async (promptOverride?: string) => { const handleSend = async (promptOverride?: string) => {
const rawText = promptOverride || input const rawText = promptOverride || input
if (!rawText.trim() && selectedFields.length === 0) return if (!rawText.trim() && selectedFields.length === 0) return
if (isSending || (!rawText.trim() && selectedFields.length === 0)) return
const currentFields = [...selectedFields] const finalPrompt = buildPrompt(rawText)
const finalPrompt = buildPrompt(rawText, currentFields)
setIsSending(true) const userMsg = {
setOptimisticMessage(rawText) id: Date.now().toString(),
setInput('') role: 'user',
setSelectedArchivoIds([])
setSelectedRepositorioIds([])
setUploadedFiles([])
try {
const payload: any = {
planId: planId,
content: finalPrompt, content: finalPrompt,
conversacionId: activeChatId || undefined,
} }
if (currentFields.length > 0) { setMessages((prev) => [...prev, userMsg])
payload.campos = currentFields.map((f) => f.key) setInput('')
setIsLoading(true)
setTimeout(() => {
const mockText =
'Sugerencia generada basada en los campos seleccionados...'
setMessages((prev) => [
...prev,
{
id: Date.now().toString(),
role: 'assistant',
content: `He analizado ${selectedFields
.map((f) => f.label)
.join(', ')}. Aquí tienes una propuesta:\n\n${mockText}`,
},
])
setPendingSuggestion({ text: mockText })
setIsLoading(false)
}, 1200)
} }
const response = await sendChat(payload)
if (response.conversacionId && response.conversacionId !== activeChatId) {
setActiveChatId(response.conversacionId)
}
await queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId],
})
setOptimisticMessage(null)
} catch (error) {
console.error('Error en el chat:', error)
// Aquí sí podrías usar un toast o un mensaje de error temporal
} finally {
// 5. CRÍTICO: Detener el estado de carga SIEMPRE
setIsSending(false)
setOptimisticMessage(null)
}
}
const totalReferencias = useMemo(() => {
return (
selectedArchivoIds.length +
selectedRepositorioIds.length +
uploadedFiles.length
)
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
const removeSelectedField = (fieldKey: string) => {
setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
}
return ( return (
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4"> <div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
{/* --- PANEL IZQUIERDO: HISTORIAL --- */}
<div className="flex w-64 flex-col border-r pr-4">
<div className="mb-4">
<div className="mb-4 flex items-center justify-between px-2">
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Chats
</h2>
{/* Botón de toggle archivados movido aquí arriba */}
<button
onClick={() => setShowArchived(!showArchived)}
className={`rounded-md p-1.5 transition-colors ${
showArchived
? 'bg-teal-50 text-teal-600'
: 'text-slate-400 hover:bg-slate-100'
}`}
title={showArchived ? 'Ver chats activos' : 'Ver archivados'}
>
<Archive size={16} />
</button>
</div>
<Button
onClick={createNewChat}
variant="outline"
className="mb-4 w-full justify-start gap-2 border-slate-200 hover:bg-teal-50 hover:text-teal-700"
>
<MessageSquarePlus size={18} /> Nuevo chat
</Button>
</div>
<ScrollArea className="flex-1">
<div className="space-y-1">
{!showArchived ? (
activeChats.map((chat) => (
<div
key={chat.id}
onClick={() => setActiveChatId(chat.id)}
className={`group relative flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-sm transition-colors ${
activeChatId === chat.id
? 'bg-slate-100 font-medium text-slate-900'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
<FileText size={16} className="shrink-0 opacity-40" />
<span
ref={editingChatId === chat.id ? editableRef : null}
contentEditable={editingChatId === chat.id}
suppressContentEditableWarning={true}
className={`truncate pr-14 transition-all outline-none ${
editingChatId === chat.id
? 'min-w-[50px] cursor-text rounded bg-white px-1 ring-1 ring-teal-500'
: 'cursor-pointer'
}`}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingChatId(chat.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
const newTitle = e.currentTarget.textContent || ''
updateTitleMutation(
{ id: chat.id, nombre: newTitle },
{
onSuccess: () => setEditingChatId(null),
},
)
}
if (e.key === 'Escape') {
setEditingChatId(null)
e.currentTarget.textContent = chat.nombre || ''
}
}}
onBlur={(e) => {
if (editingChatId === chat.id) {
const newTitle = e.currentTarget.textContent || ''
if (newTitle !== chat.nombre) {
updateTitleMutation({ id: chat.id, nombre: newTitle })
}
setEditingChatId(null)
}
}}
onClick={(e) => {
if (editingChatId === chat.id) e.stopPropagation()
}}
>
{chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`}
</span>
{/* ACCIONES */}
<div className="absolute right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation()
setEditingChatId(chat.id)
// Pequeño timeout para asegurar que el DOM se actualice antes de enfocar
setTimeout(() => editableRef.current?.focus(), 50)
}}
className="p-1 text-slate-400 hover:text-teal-600"
>
<Send size={12} className="rotate-45" />
</button>
<button
onClick={(e) => archiveChat(e, chat.id)}
className="p-1 text-slate-400 hover:text-amber-600"
>
<Archive size={14} />
</button>
</div>
</div>
))
) : (
/* ... Resto del código de archivados (sin cambios) ... */
<div className="animate-in fade-in slide-in-from-left-2">
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
Archivados
</p>
{archivedChats.map((chat) => (
<div
key={chat.id}
className="group relative mb-1 flex w-full items-center gap-3 rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400"
>
<Archive size={14} className="shrink-0 opacity-30" />
<span className="truncate pr-8">
{chat.nombre ||
`Archivado ${chat.creado_en.split('T')[0]}`}
</span>
<button
onClick={(e) => unarchiveChat(e, chat.id)}
className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600"
>
<RotateCcw size={14} />
</button>
</div>
))}
</div>
)}
</div>
</ScrollArea>
</div>
{/* PANEL DE CHAT PRINCIPAL */} {/* 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"> <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 */} {/* NUEVO: Barra superior de campos seleccionados */}
<div className="shrink-0 border-b bg-white p-3"> <div className="shrink-0 border-b bg-white p-3">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] font-bold text-slate-400 uppercase"> <span className="text-[10px] font-bold text-slate-400 uppercase">
Mejorar con IA Mejorar con IA
</span> </span>
<button
onClick={() => setOpenIA(true)}
className="flex items-center gap-2 rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium transition hover:bg-slate-200"
>
<Archive size={14} className="text-slate-500" />
Referencias
{totalReferencias > 0 && (
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
{totalReferencias}
</span>
)}
</button>
</div> </div>
</div> </div>
@@ -612,98 +217,43 @@ function RouteComponent() {
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full"> <ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto max-w-3xl space-y-6 p-6">
{!activeChatId && {messages.map((msg) => (
chatMessages.length === 0 &&
!optimisticMessage ? (
<div className="flex h-[400px] flex-col items-center justify-center text-center opacity-40">
<MessageSquarePlus
size={48}
className="mb-4 text-slate-300"
/>
<h3 className="text-lg font-medium text-slate-900">
No hay un chat seleccionado
</h3>
<p className="text-sm text-slate-500">
Selecciona un chat del historial o crea uno nuevo para
empezar.
</p>
</div>
) : (
<>
{chatMessages.map((msg: any) => (
<div <div
key={msg.id} key={msg.id}
className={`flex max-w-[85%] flex-col ${ className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`}
msg.role === 'user' >
? 'ml-auto items-end' <Avatar
: 'items-start' 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 <div
className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${ className={`rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm ${
msg.role === 'user' msg.role === 'user'
? 'rounded-tr-none bg-teal-600 text-white' ? 'rounded-tr-none bg-teal-600 text-white'
: `rounded-tl-none border bg-white text-slate-700 ${ : 'rounded-tl-none border bg-white text-slate-700'
// --- LÓGICA DE REFUSAL ---
msg.isRefusal
? 'border-red-200 bg-red-50/50 ring-1 ring-red-100'
: 'border-slate-100'
}`
}`} }`}
> >
{/* Icono opcional de advertencia si es refusal */}
{msg.isRefusal && (
<div className="mb-1 flex items-center gap-1 text-[10px] font-bold text-red-500 uppercase">
<span>Aviso del Asistente</span>
</div>
)}
{msg.content} {msg.content}
{!msg.isRefusal &&
msg.suggestions &&
msg.suggestions.length > 0 && (
<div className="mt-4">
<ImprovementCard
suggestions={msg.suggestions}
planId={planId}
currentDatos={data?.datos}
activeChatId={activeChatId}
onApplySuccess={(key) =>
removeSelectedField(key)
}
/>
</div> </div>
)}
</div> </div>
</div> </div>
))} ))}
{isLoading && (
{optimisticMessage && ( <div className="flex gap-2 p-4">
<div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end"> <div className="h-2 w-2 animate-bounce rounded-full bg-teal-400" />
<div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm"> <div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.2s]" />
{optimisticMessage} <div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.4s]" />
</div> </div>
</div>
)}
{isSending && (
<div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300">
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-2">
<div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" />
</div>
<span className="text-[10px] font-medium tracking-tight text-slate-400 uppercase">
Esperando respuesta...
</span>
</div>
</div>
</div>
)}
</>
)} )}
</div> </div>
</ScrollArea> </ScrollArea>
@@ -729,47 +279,38 @@ function RouteComponent() {
)} )}
</div> </div>
{/* INPUT FIJO AL FONDO CON SUGERENCIAS : */}
{/* INPUT FIJO AL FONDO CON SUGERENCIAS : */} {/* INPUT FIJO AL FONDO CON SUGERENCIAS : */}
<div className="shrink-0 border-t bg-white p-4"> <div className="shrink-0 border-t bg-white p-4">
<div className="relative mx-auto max-w-4xl"> <div className="relative mx-auto max-w-4xl">
{/* MENÚ DE SUGERENCIAS FLOTANTE */} {/* MENÚ DE SUGERENCIAS FLOTANTE (Se mantiene igual) */}
{showSuggestions && ( {showSuggestions && (
<div className="animate-in slide-in-from-bottom-2 absolute bottom-full mb-2 w-full rounded-xl border bg-white shadow-2xl"> <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 text-slate-500 uppercase"> <div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold tracking-wider text-slate-500 uppercase">
Resultados para "{filterQuery}" Seleccionar campo para IA
</div> </div>
<div className="max-h-64 overflow-y-auto p-1"> <div className="max-h-64 overflow-y-auto p-1">
{filteredFields.length > 0 ? ( {availableFields.map((field) => (
filteredFields.map((field, index) => (
<button <button
key={field.key} key={field.key}
onClick={() => toggleField(field)} onClick={() => toggleField(field)}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${ 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"
index === 0
? 'bg-teal-50 text-teal-700 ring-1 ring-teal-200 ring-inset'
: 'hover:bg-slate-50'
}`}
> >
<span>{field.label}</span> <span className="text-slate-700 group-hover:text-teal-700">
{index === 0 && ( {field.label}
<span className="font-mono text-[10px] opacity-50">
TAB
</span> </span>
{selectedFields.find((f) => f.key === field.key) && (
<Check size={14} className="text-teal-600" />
)} )}
</button> </button>
)) ))}
) : (
<div className="p-3 text-center text-xs text-slate-400">
No hay coincidencias
</div>
)}
</div> </div>
</div> </div>
)} )}
{/* CONTENEDOR DEL INPUT TRANSFORMADO */} {/* 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"> <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 ) */} {/* 1. Visualización de campos dentro del input (Tags) */}
{selectedFields.length > 0 && ( {selectedFields.length > 0 && (
<div className="flex flex-wrap gap-2 px-2 pt-1"> <div className="flex flex-wrap gap-2 px-2 pt-1">
{selectedFields.map((field) => ( {selectedFields.map((field) => (
@@ -795,32 +336,9 @@ function RouteComponent() {
value={input} value={input}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={(e) => { onKeyDown={(e) => {
if (showSuggestions) { if (e.key === 'Enter' && !e.shiftKey) {
if (e.key === 'Tab' || e.key === 'Enter') {
if (filteredFields.length > 0) {
e.preventDefault() e.preventDefault()
toggleField(filteredFields[0]) handleSend()
}
}
if (e.key === 'Escape') {
e.preventDefault()
setShowSuggestions(false)
setFilterQuery('')
}
} else {
// Si el usuario borra y el input está vacío, eliminar el último campo
if (
e.key === 'Backspace' &&
input === '' &&
selectedFields.length > 0
) {
setSelectedFields((prev) => prev.slice(0, -1))
}
}
if (e.key === 'Enter' && !e.shiftKey && !showSuggestions) {
e.preventDefault()
if (!isSending) handleSend()
} }
}} }}
placeholder={ placeholder={
@@ -828,28 +346,25 @@ function RouteComponent() {
? 'Escribe instrucciones adicionales...' ? 'Escribe instrucciones adicionales...'
: 'Escribe tu solicitud o ":" para campos...' : '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 <Button
onClick={() => handleSend()} onClick={() => handleSend()}
disabled={ disabled={
isSending || (!input.trim() && selectedFields.length === 0) (!input.trim() && selectedFields.length === 0) || isLoading
} }
size="icon" size="icon"
className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700" className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700"
> >
{isSending ? ( <Send size={16} className="text-white" />
<Loader2 className="animate-spin" size={16} />
) : (
<Send size={16} />
)}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* PANEL LATERAL */}
{/* PANEL LATERAL (PRESETS) - SE MANTIENE COMO LO TENÍAS */}
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2"> <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"> <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 <Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
@@ -871,44 +386,6 @@ function RouteComponent() {
))} ))}
</div> </div>
</div> </div>
<Drawer open={openIA} onOpenChange={setOpenIA}>
<DrawerContent className="fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl">
{/* Cabecera más compacta */}
<div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3">
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Referencias para la IA
</h2>
<button
onClick={() => setOpenIA(false)}
className="text-slate-400 transition-colors hover:text-slate-600"
>
<X size={18} />
</button>
</div>
{/* Contenido con scroll interno */}
<div className="flex-1 overflow-y-auto p-4">
<ReferenciasParaIA
selectedArchivoIds={selectedArchivoIds}
selectedRepositorioIds={selectedRepositorioIds}
uploadedFiles={uploadedFiles}
onToggleArchivo={(id, checked) => {
setSelectedArchivoIds((prev) =>
checked ? [...prev, id] : prev.filter((a) => a !== id),
)
}}
onToggleRepositorio={(id, checked) => {
setSelectedRepositorioIds((prev) =>
checked ? [...prev, id] : prev.filter((r) => r !== id),
)
}}
onFilesChange={(files) => {
setUploadedFiles(files)
}}
/>
</div>
</DrawerContent>
</Drawer>
</div> </div>
) )
} }

View File

@@ -18,7 +18,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { usePlan, useUpdatePlanFields } from '@/data' import { usePlan } from '@/data'
// import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea // import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea
export const Route = createFileRoute('/planes/$planId/_detalle/')({ export const Route = createFileRoute('/planes/$planId/_detalle/')({
@@ -39,7 +39,7 @@ function DatosGeneralesPage() {
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('') const [editValue, setEditValue] = useState('')
const location = useLocation() const location = useLocation()
const updatePlan = useUpdatePlanFields()
// Confetti al llegar desde creación // Confetti al llegar desde creación
useEffect(() => { useEffect(() => {
if (location.state.showConfetti) { if (location.state.showConfetti) {
@@ -81,7 +81,6 @@ function DatosGeneralesPage() {
const rawValue = valores[key] const rawValue = valores[key]
return { return {
clave: key,
id: (index + 1).toString(), id: (index + 1).toString(),
label: schema?.title || formatLabel(key), label: schema?.title || formatLabel(key),
helperText: schema?.description || '', helperText: schema?.description || '',
@@ -93,6 +92,7 @@ function DatosGeneralesPage() {
requerido: true, requerido: true,
// 👇 TIPO DE CAMPO
tipo: Array.isArray(schema?.enum) tipo: Array.isArray(schema?.enum)
? 'select' ? 'select'
: schema?.type === 'number' : schema?.type === 'number'
@@ -106,125 +106,39 @@ function DatosGeneralesPage() {
setCampos(datosTransformados) setCampos(datosTransformados)
} }
console.log(properties)
}, [data]) }, [data])
// 3. Manejadores de acciones (Ahora como funciones locales) // 3. Manejadores de acciones (Ahora como funciones locales)
const handleEdit = (nuevoCampo: DatosGeneralesField) => { const handleEdit = (campo: DatosGeneralesField) => {
// 1. SI YA ESTÁBAMOS EDITANDO OTRO CAMPO, GUARDAMOS EL ANTERIOR PRIMERO setEditingId(campo.id)
if (editingId && editingId !== nuevoCampo.id) { setEditValue(campo.value)
const campoAnterior = campos.find((c) => c.id === editingId)
if (campoAnterior && editValue !== campoAnterior.value) {
// Solo guardamos si el valor realmente cambió
ejecutarGuardadoSilencioso(campoAnterior, editValue)
}
}
// 2. ABRIMOS EL NUEVO CAMPO
setEditingId(nuevoCampo.id)
setEditValue(nuevoCampo.value)
} }
const handleCancel = () => { const handleCancel = () => {
setEditingId(null) setEditingId(null)
setEditValue('') setEditValue('')
} }
// Función auxiliar para procesar los datos (fuera o dentro del componente)
const prepararDatosActualizados = (
data: any,
campo: DatosGeneralesField,
valor: string,
) => {
const currentValue = data.datos[campo.clave]
let newValue: any
if ( const handleSave = (id: string) => {
typeof currentValue === 'object' && // Actualizamos el estado local de la lista
currentValue !== null &&
'description' in currentValue
) {
newValue = { ...currentValue, description: valor }
} else {
newValue = valor
}
return {
...data.datos,
[campo.clave]: newValue,
}
}
const ejecutarGuardadoSilencioso = (
campo: DatosGeneralesField,
valor: string,
) => {
if (!data?.datos) return
const datosActualizados = prepararDatosActualizados(data, campo, valor)
console.log(datosActualizados)
updatePlan.mutate({
planId,
patch: { datos: datosActualizados },
})
// Actualizar UI localmente
setCampos((prev) => setCampos((prev) =>
prev.map((c) => (c.id === campo.id ? { ...c, value: valor } : c)), prev.map((c) => (c.id === id ? { ...c, value: editValue } : c)),
) )
}
const handleSave = (campo: DatosGeneralesField) => {
if (!data?.datos) return
const currentValue = (data.datos as any)[campo.clave]
let newValue: any
if (
typeof currentValue === 'object' &&
currentValue !== null &&
'description' in currentValue
) {
// Caso 1: objeto con description
newValue = {
...currentValue,
description: editValue,
}
} else {
// Caso 2: valor plano (string, number, etc)
newValue = editValue
}
const datosActualizados = {
...data.datos,
[campo.clave]: newValue,
}
updatePlan.mutate({
planId,
patch: {
datos: datosActualizados,
},
})
// UI optimista
setCampos((prev) =>
prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)),
)
ejecutarGuardadoSilencioso(campo, editValue)
setEditingId(null) setEditingId(null)
setEditValue('') setEditValue('')
// toast.success('Cambios guardados localmente')
} }
const handleIARequest = (clave: string) => { const handleIARequest = (descripcion: string) => {
navigate({ navigate({
to: '/planes/$planId/iaplan', to: '/planes/$planId/iaplan',
params: { params: {
planId: planId, // o dinámico planId: planId, // o dinámico
}, },
state: { state: {
campo_edit: clave, prefill: descripcion,
} as any, } as any,
}) })
} }
@@ -280,7 +194,7 @@ function DatosGeneralesPage() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-teal-600" className="h-8 w-8 text-teal-600"
onClick={() => handleIARequest(campo)} onClick={() => handleIARequest(campo.value)}
> >
<Sparkles size={14} /> <Sparkles size={14} />
</Button> </Button>
@@ -327,7 +241,7 @@ function DatosGeneralesPage() {
<Button <Button
size="sm" size="sm"
className="bg-teal-600 hover:bg-teal-700" className="bg-teal-600 hover:bg-teal-700"
onClick={() => handleSave(campo)} onClick={() => handleSave(campo.id)}
> >
<Check size={14} className="mr-1" /> Guardar <Check size={14} className="mr-1" /> Guardar
</Button> </Button>

View File

@@ -1,4 +1,3 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/label-has-associated-control */
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { import {
@@ -7,13 +6,10 @@ import {
AlertTriangle, AlertTriangle,
GripVertical, GripVertical,
Trash2, Trash2,
Pencil,
} from 'lucide-react' } from 'lucide-react'
import { useMemo, useState, useEffect, Fragment } from 'react' import { useMemo, useState, useEffect } from 'react'
import type { TipoAsignatura } from '@/data' import type { Materia, LineaCurricular } from '@/types/plan'
import type { Asignatura, LineaCurricular } from '@/types/plan'
import type { TablesUpdate } from '@/types/supabase'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -37,15 +33,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { import { usePlanAsignaturas, usePlanLineas } from '@/data'
useCreateLinea,
useDeleteLinea,
usePlan,
usePlanAsignaturas,
usePlanLineas,
useUpdateAsignatura,
useUpdateLinea,
} from '@/data'
// --- Mapeadores (Fuera del componente para mayor limpieza) --- // --- Mapeadores (Fuera del componente para mayor limpieza) ---
const mapLineasToLineaCurricular = ( const mapLineasToLineaCurricular = (
@@ -59,26 +47,21 @@ const mapLineasToLineaCurricular = (
})) }))
} }
const mapAsignaturasToAsignaturas = ( const mapAsignaturasToMaterias = (asigApi: Array<any> = []): Array<Materia> => {
asigApi: Array<any> = [], return asigApi.map((asig) => ({
): Array<Asignatura> => {
return asigApi.map((asig) => {
return {
id: asig.id, id: asig.id,
clave: asig.codigo, clave: asig.codigo,
nombre: asig.nombre, nombre: asig.nombre,
creditos: asig.creditos ?? 0, creditos: asig.creditos ?? 0,
ciclo: asig.numero_ciclo ?? null, ciclo: asig.numero_ciclo ?? null,
lineaCurricularId: asig.linea_plan_id ?? null, lineaCurricularId: asig.linea_plan_id ?? null,
tipo: asig.tipo, tipo: asig.tipo === 'OBLIGATORIA' ? 'obligatoria' : 'optativa',
estado: 'borrador', estado: 'borrador',
orden: asig.orden_celda ?? 0, orden: asig.orden_celda ?? 0,
// Mapeo directo de los nuevos campos de la API hd: Math.floor((asig.horas_semana ?? 0) / 2),
hd: asig.horas_academicas ?? 0, hi: Math.ceil((asig.horas_semana ?? 0) / 2),
hi: asig.horas_independientes ?? 0,
prerrequisitos: [], prerrequisitos: [],
} }))
})
} }
const lineColors = [ const lineColors = [
@@ -121,13 +104,13 @@ function StatItem({
) )
} }
function AsignaturaCardItem({ function MateriaCardItem({
asignatura, materia,
onDragStart, onDragStart,
isDragging, isDragging,
onClick, onClick,
}: { }: {
asignatura: Asignatura materia: Materia
onDragStart: (e: React.DragEvent, id: string) => void onDragStart: (e: React.DragEvent, id: string) => void
isDragging: boolean isDragging: boolean
onClick: () => void onClick: () => void
@@ -135,7 +118,7 @@ function AsignaturaCardItem({
return ( return (
<button <button
draggable draggable
onDragStart={(e) => onDragStart(e, asignatura.id)} onDragStart={(e) => onDragStart(e, materia.id)}
onClick={onClick} onClick={onClick}
className={`group cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all active:cursor-grabbing ${ className={`group cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all active:cursor-grabbing ${
isDragging isDragging
@@ -145,21 +128,21 @@ function AsignaturaCardItem({
> >
<div className="mb-1 flex items-start justify-between"> <div className="mb-1 flex items-start justify-between">
<span className="font-mono text-[10px] font-bold text-slate-400"> <span className="font-mono text-[10px] font-bold text-slate-400">
{asignatura.clave} {materia.clave}
</span> </span>
<Badge <Badge
variant="outline" variant="outline"
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[asignatura.estado] || ''}`} className={`px-1 py-0 text-[9px] uppercase ${statusBadge[materia.estado] || ''}`}
> >
{asignatura.estado} {materia.estado}
</Badge> </Badge>
</div> </div>
<p className="mb-1 text-xs leading-tight font-bold text-slate-700"> <p className="mb-1 text-xs leading-tight font-bold text-slate-700">
{asignatura.nombre} {materia.nombre}
</p> </p>
<div className="mt-2 flex items-center justify-between"> <div className="mt-2 flex items-center justify-between">
<span className="text-[10px] text-slate-500"> <span className="text-[10px] text-slate-500">
{asignatura.creditos} CR HD:{asignatura.hd} HI:{asignatura.hi} {materia.creditos} CR HD:{materia.hd} HI:{materia.hi}
</span> </span>
<GripVertical <GripVertical
size={12} size={12}
@@ -176,107 +159,73 @@ export const Route = createFileRoute('/planes/$planId/_detalle/mapa')({
function MapaCurricularPage() { function MapaCurricularPage() {
const { planId } = Route.useParams() // Idealmente usa el ID de la ruta const { planId } = Route.useParams() // Idealmente usa el ID de la ruta
const { data } = usePlan(planId)
const [ciclo, setCiclo] = useState(0) // 1. Fetch de Datos
const [editingLineaId, setEditingLineaId] = useState<string | null>(null) const { data: asignaturasApi, isLoading: loadingAsig } =
const [tempNombreLinea, setTempNombreLinea] = useState('')
const { mutate: createLinea } = useCreateLinea()
const { mutate: updateLineaApi } = useUpdateLinea()
const { mutate: deleteLineaApi } = useDeleteLinea()
const { data: asignaturaApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId) usePlanAsignaturas(planId)
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId) const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
const [asignaturas, setAsignaturas] = useState<Array<Asignatura>>([])
const [lineas, setLineas] = useState<Array<LineaCurricular>>([])
const [draggedAsignatura, setDraggedAsignatura] = useState<string | null>(
null,
)
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado
const { mutate: updateAsignatura } = useUpdateAsignatura()
const [seriacionValue, setSeriacionValue] = useState<string>('')
useEffect(() => { // 2. Estado Local (Para interactividad)
if (data?.numero_ciclos) { const [materias, setMaterias] = useState<Array<Materia>>([])
setCiclo(data.numero_ciclos) const [lineas, setLineas] = useState<Array<LineaCurricular>>([])
} const [draggedMateria, setDraggedMateria] = useState<string | null>(null)
}, [data]) 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 manejarAgregarLinea = (nombre: string) => {
const nombreNormalizado = nombre.trim() const nombreNormalizado = nombre.trim()
// 1. Validar que no esté vacío
if (!nombreNormalizado) return if (!nombreNormalizado) return
const nombreBusqueda = nombreNormalizado
// 2. Validar duplicados (Insensible a mayúsculas/minúsculas y acentos)
const nombreParaComparar = nombreNormalizado
.toLowerCase() .toLowerCase()
.normalize('NFD') .normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') .replace(/[\u0300-\u036f]/g, '')
const yaExiste = lineas.some((l) => { const yaExiste = lineas.some((l) => {
const lineaExistente = l.nombre const lineaNombreBase = l.nombre
.toLowerCase() .toLowerCase()
.normalize('NFD') .normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') .replace(/[\u0300-\u036f]/g, '')
return lineaExistente === nombreBusqueda return lineaNombreBase === nombreParaComparar
}) })
if (yaExiste) { if (yaExiste) {
alert(`La línea "${nombreNormalizado}" ya existe en este plan.`) alert(`La línea "${nombreNormalizado}" ya existe.`)
return return
} }
const maxOrden = lineas.reduce((max, l) => Math.max(max, l.orden || 0), 0)
createLinea( // 3. Validar Área Común (usando tu lógica previa)
{ const esAreaComun =
nombreNormalizado.toLowerCase() === 'área común' ||
nombreNormalizado.toLowerCase() === 'area comun'
if (esAreaComun && hasAreaComun) {
alert('El Área Común ya ha sido agregada.')
return
}
// 4. Agregar la línea si todo está bien
const nueva = {
id: crypto.randomUUID(),
nombre: nombreNormalizado, nombre: nombreNormalizado,
plan_estudio_id: planId, orden: lineas.length + 1,
orden: maxOrden + 1,
area: 'sin asignar',
},
{
onSuccess: (nueva) => {
const mapeada = {
id: nueva.id,
nombre: nueva.nombre,
orden: nueva.orden,
color: '#1976d2', color: '#1976d2',
} }
setLineas((prev) => [...prev, mapeada])
setNombreNuevaLinea('')
},
},
)
}
const guardarEdicionLinea = (id: string, nuevoNombre?: string) => {
// Usamos el nombre que viene por parámetro o el del estado como fallback
const nombreAFijar = (
nuevoNombre !== undefined ? nuevoNombre : tempNombreLinea
).trim()
if (!nombreAFijar) { setLineas([...lineas, nueva])
setEditingLineaId(null)
return if (esAreaComun) {
setHasAreaComun(true)
} }
updateLineaApi( setNombreNuevaLinea('') // Limpiar input
{
lineaId: id,
patch: { nombre: nombreAFijar },
},
{
onSuccess: (lineaActualizada) => {
setLineas((prev) =>
prev.map((l) =>
l.id === id ? { ...l, nombre: lineaActualizada.nombre } : l,
),
)
setEditingLineaId(null)
setTempNombreLinea('')
},
onError: (err) => {
console.error('Error al actualizar linea:', err)
// Opcional: revertir cambios o avisar al usuario
},
},
)
} }
const tieneAreaComun = useMemo(() => { const tieneAreaComun = useMemo(() => {
return lineas.some( return lineas.some(
(l) => (l) =>
@@ -285,118 +234,57 @@ function MapaCurricularPage() {
) )
}, [lineas]) }, [lineas])
// 3. Sincronizar API -> Estado Local
useEffect(() => { useEffect(() => {
if (asignaturaApi) if (asignaturasApi) setMaterias(mapAsignaturasToMaterias(asignaturasApi))
setAsignaturas(mapAsignaturasToAsignaturas(asignaturaApi)) }, [asignaturasApi])
}, [asignaturaApi])
useEffect(() => { useEffect(() => {
if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi)) if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi))
}, [lineasApi]) }, [lineasApi])
const ciclosTotales = Number(ciclo) const ciclosTotales = 9
const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1) const ciclosArray = Array.from({ length: ciclosTotales }, (_, i) => i + 1)
const [editingData, setEditingData] = useState<Asignatura | null>(null)
const handleIntegerChange = (value: string) => {
if (value === '') return value
// Solo números, máximo 3 cifras // Nuevo estado para controlar los datos temporales del modal de edición
const regex = /^\d{1,3}$/ const [editingData, setEditingData] = useState<Materia | null>(null)
if (!regex.test(value)) return null // 1. FUNCION DE GUARDAR MODAL
return value
}
const handleDecimalChange = (value: string, max?: number): string | null => {
if (value === '') return ''
const val = value.replace(',', '.')
const regex = /^\d*\.?\d{0,2}$/
if (!regex.test(val)) return null
if (max !== undefined) {
const num = Number(val)
if (!isNaN(num) && num > max) {
return max.toFixed(2)
}
}
return val
}
const handleSaveChanges = () => { const handleSaveChanges = () => {
if (!editingData) return if (!editingData) return
setAsignaturas((prev) => console.log(materias)
setMaterias((prev) =>
prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)), prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)),
) )
type AsignaturaPatch = { setIsEditModalOpen(false)
codigo?: TablesUpdate<'asignaturas'>['codigo']
nombre?: TablesUpdate<'asignaturas'>['nombre']
tipo?: TablesUpdate<'asignaturas'>['tipo']
creditos?: TablesUpdate<'asignaturas'>['creditos']
horas_academicas?: TablesUpdate<'asignaturas'>['horas_academicas']
horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes']
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo']
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id']
}
const patch: Partial<AsignaturaPatch> = {
nombre: editingData.nombre,
codigo: editingData.clave,
creditos: editingData.creditos,
horas_academicas: editingData.hd,
horas_independientes: editingData.hi,
numero_ciclo: editingData.ciclo,
linea_plan_id: editingData.lineaCurricularId,
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
} }
updateAsignatura( // 2. MODIFICACIÓN: Zona de soltado siempre visible
{ asignaturaId: editingData.id, patch: patch as any }, // Cambiamos la condición: Mostramos la sección si hay materias sin asignar
{ // O si simplemente queremos tener el "depósito" disponible.
onSuccess: () => { const unassignedMaterias = materias.filter((m) => m.ciclo === null)
setIsEditModalOpen(false)
// Opcional: Mostrar un toast de éxito // --- Lógica de Gestión ---
}, const agregarLinea = (nombre: string) => {
onError: (error) => { const nueva = { id: crypto.randomUUID(), nombre, orden: lineas.length + 1 }
console.error('Error al guardar:', error) setLineas([...lineas, nueva])
alert('Hubo un error al guardar los cambios.')
},
},
)
} }
const unassignedAsignaturas = asignaturas.filter(
(m) => m.ciclo === null || m.lineaCurricularId === null,
)
const borrarLinea = (id: string) => { const borrarLinea = (id: string) => {
if ( setMaterias((prev) =>
!confirm( prev.map((m) =>
'¿Estás seguro de eliminar esta línea? Las materias asignadas volverán a la bandeja de entrada.', m.lineaCurricularId === id
) ? { ...m, ciclo: null, lineaCurricularId: null }
) { : m,
return
}
deleteLineaApi(id, {
onSuccess: () => {
// Primero: Las materias que estaban en esa línea pasan a ser "huérfanas"
setAsignaturas((prev) =>
prev.map((asig) =>
asig.lineaCurricularId === id
? { ...asig, ciclo: null, lineaCurricularId: null }
: asig,
), ),
) )
setLineas((prev) => prev.filter((l) => l.id !== id)) setLineas((prev) => prev.filter((l) => l.id !== id))
},
onError: (error) => {
console.error(error)
alert('No se pudo eliminar la línea. Verifica si tiene dependencias.')
},
})
} }
// --- Selectores/Cálculos --- // --- Selectores/Cálculos ---
const getTotalesCiclo = (ciclo: number) => { const getTotalesCiclo = (ciclo: number) => {
return asignaturas return materias
.filter((m) => m.ciclo === ciclo) .filter((m) => m.ciclo === ciclo)
.reduce( .reduce(
(acc, m) => ({ (acc, m) => ({
@@ -409,8 +297,8 @@ function MapaCurricularPage() {
} }
const getSubtotalLinea = (lineaId: string) => { const getSubtotalLinea = (lineaId: string) => {
return asignaturas return materias
.filter((m) => m.lineaCurricularId === lineaId && m.ciclo !== null) // Aseguramos que pertenezca a la línea Y tenga ciclo .filter((m) => m.lineaCurricularId === lineaId && m.ciclo !== null)
.reduce( .reduce(
(acc, m) => ({ (acc, m) => ({
cr: acc.cr + (m.creditos || 0), cr: acc.cr + (m.creditos || 0),
@@ -422,7 +310,7 @@ function MapaCurricularPage() {
} }
const handleDragStart = (e: React.DragEvent, id: string) => { const handleDragStart = (e: React.DragEvent, id: string) => {
setDraggedAsignatura(id) setDraggedMateria(id)
e.dataTransfer.effectAllowed = 'move' e.dataTransfer.effectAllowed = 'move'
} }
const handleDragOver = (e: React.DragEvent) => e.preventDefault() const handleDragOver = (e: React.DragEvent) => e.preventDefault()
@@ -432,37 +320,21 @@ function MapaCurricularPage() {
lineaId: string | null, lineaId: string | null,
) => { ) => {
e.preventDefault() e.preventDefault()
if (draggedAsignatura) { if (draggedMateria) {
// 1. Actualización optimista del UI setMaterias((prev) =>
setAsignaturas((prev) =>
prev.map((m) => prev.map((m) =>
m.id === draggedAsignatura m.id === draggedMateria
? { ...m, ciclo, lineaCurricularId: lineaId } ? { ...m, ciclo, lineaCurricularId: lineaId }
: m, : m,
), ),
) )
const patch = { setDraggedMateria(null)
numero_ciclo: ciclo,
linea_plan_id: lineaId,
}
updateAsignatura(
{ asignaturaId: draggedAsignatura, patch },
{
onError: (error) => {
console.error('Error al mover:', error)
// Opcional: Revertir el estado local si falla
},
},
)
setDraggedAsignatura(null)
} }
} }
const stats = useMemo( const stats = useMemo(
() => () =>
asignaturas.reduce( materias.reduce(
(acc, m) => { (acc, m) => {
if (m.ciclo !== null) { if (m.ciclo !== null) {
acc.cr += m.creditos || 0 acc.cr += m.creditos || 0
@@ -473,36 +345,9 @@ function MapaCurricularPage() {
}, },
{ cr: 0, hd: 0, hi: 0 }, { cr: 0, hd: 0, hi: 0 },
), ),
[asignaturas], [materias],
) )
const handleKeyDownLinea = (
e: React.KeyboardEvent<HTMLSpanElement>,
id: string,
) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
}
}
const handleBlurLinea = (
e: React.FocusEvent<HTMLSpanElement>,
id: string,
) => {
const nuevoNombre = e.currentTarget.textContent?.trim() || ''
// Buscamos la línea original para comparar
const lineaOriginal = lineas.find((l) => l.id === id)
if (nuevoNombre !== lineaOriginal?.nombre) {
// IMPORTANTE: Pasamos nuevoNombre directamente
guardarEdicionLinea(id, nuevoNombre)
} else {
setEditingLineaId(null)
}
}
if (loadingAsig || loadingLineas) if (loadingAsig || loadingLineas)
return <div className="p-10 text-center">Cargando mapa curricular...</div> return <div className="p-10 text-center">Cargando mapa curricular...</div>
@@ -513,22 +358,14 @@ function MapaCurricularPage() {
<div> <div>
<h2 className="text-xl font-bold">Mapa Curricular</h2> <h2 className="text-xl font-bold">Mapa Curricular</h2>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
Organiza las asignaturas de la petición por línea y ciclo Organiza las materias de la petición por línea y ciclo
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button className="bg-teal-700 text-white hover:bg-teal-800"> {materias.filter((m) => !m.ciclo).length > 0 && (
<Plus size={16} className="mr-2" /> Exportar{' '}
</Button>
{asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId).length >
0 && (
<Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50"> <Badge className="border-amber-100 bg-amber-50 text-amber-600 hover:bg-amber-50">
<AlertTriangle size={14} className="mr-1" />{' '} <AlertTriangle size={14} className="mr-1" />{' '}
{ {materias.filter((m) => !m.ciclo).length} sin asignar
asignaturas.filter((m) => !m.ciclo || !m.lineaCurricularId)
.length
}{' '}
sin asignar
</Badge> </Badge>
)} )}
<DropdownMenu> <DropdownMenu>
@@ -588,94 +425,65 @@ function MapaCurricularPage() {
<div className="overflow-x-auto pb-6"> <div className="overflow-x-auto pb-6">
<div className="min-w-[1500px]"> <div className="min-w-[1500px]">
<div <div
className="grid gap-3" className="mb-4 grid gap-3"
style={{ style={{
gridTemplateColumns: `220px repeat(${ciclosTotales}, minmax(auto, 1fr)) 120px`, gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
}} }}
> >
<div className="self-end px-2 text-xs font-bold text-slate-400"> <div className="self-end px-2 text-xs font-bold text-slate-400">
LÍNEA CURRICULAR LÍNEA CURRICULAR
</div> </div>
{ciclosArray.map((n) => ( {ciclosArray.map((n) => (
<div <div
key={`header-${n}`} key={n}
className="rounded-lg bg-slate-100 p-2 text-center text-sm font-bold text-slate-600" className="rounded-lg bg-slate-100 p-2 text-center text-sm font-bold text-slate-600"
> >
Ciclo {n} Ciclo {n}
</div> </div>
))} ))}
<div className="self-end text-center text-xs font-bold text-slate-400"> <div className="self-end text-center text-xs font-bold text-slate-400">
SUBTOTAL SUBTOTAL
</div> </div>
</div>
{lineas.map((linea, idx) => { {lineas.map((linea, idx) => {
const sub = getSubtotalLinea(linea.id) const sub = getSubtotalLinea(linea.id)
return ( return (
<Fragment key={linea.id}>
<div <div
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${ key={linea.id}
lineColors[idx % lineColors.length] className="mb-3 grid gap-3"
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`} style={{
> gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
<div className="flex-1 overflow-hidden">
<span
contentEditable={editingLineaId === linea.id}
suppressContentEditableWarning
spellCheck={false}
onKeyDown={(e) => handleKeyDownLinea(e, linea.id)}
onBlur={(e) => handleBlurLinea(e, linea.id)}
onClick={() => {
if (editingLineaId !== linea.id) {
setEditingLineaId(linea.id)
setTempNombreLinea(linea.nombre)
}
}} }}
className={`block w-full text-xs font-bold break-words outline-none ${
editingLineaId === linea.id
? 'cursor-text border-b border-teal-500/50 pb-1'
: 'cursor-pointer'
}`}
> >
{linea.nombre} <div
</span> className={`flex items-center justify-between rounded-xl border-l-4 p-4 ${lineColors[idx % lineColors.length]}`}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setEditingLineaId(linea.id)}
className="..."
> >
{' '} <span className="text-xs font-bold">{linea.nombre}</span>
<Pencil size={12} />{' '}
</button>
<Trash2 <Trash2
onClick={() => borrarLinea(linea.id)}
className="..."
size={14} size={14}
className="cursor-pointer text-slate-400 hover:text-red-500"
onClick={() => borrarLinea(linea.id)}
/> />
</div> </div>
</div>
{ciclosArray.map((ciclo) => ( {ciclosArray.map((ciclo) => (
<div <div
key={`${linea.id}-${ciclo}`} key={ciclo}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, ciclo, linea.id)} onDrop={(e) => handleDrop(e, ciclo, linea.id)}
className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2" className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2"
> >
{asignaturas {materias
.filter( .filter(
(m) => (m) =>
m.ciclo === ciclo && m.ciclo === ciclo && m.lineaCurricularId === linea.id,
m.lineaCurricularId === linea.id,
) )
.map((m) => ( .map((m) => (
<AsignaturaCardItem <MateriaCardItem
key={m.id} key={m.id}
asignatura={m} materia={m}
isDragging={draggedAsignatura === m.id} isDragging={draggedMateria === m.id}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onClick={() => { onClick={() => {
setEditingData(m) setEditingData(m)
@@ -691,21 +499,24 @@ function MapaCurricularPage() {
<div>HD: {sub.hd}</div> <div>HD: {sub.hd}</div>
<div>HI: {sub.hi}</div> <div>HI: {sub.hi}</div>
</div> </div>
</Fragment> </div>
) )
})} })}
<div className="col-span-full my-2 border-t border-slate-200"></div> <div
className="mt-6 grid gap-3 border-t pt-4"
<div className="self-center p-2 font-bold text-slate-600"> style={{
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
}}
>
<div className="p-2 font-bold text-slate-600">
Totales por Ciclo Totales por Ciclo
</div> </div>
{ciclosArray.map((ciclo) => { {ciclosArray.map((ciclo) => {
const t = getTotalesCiclo(ciclo) const t = getTotalesCiclo(ciclo)
return ( return (
<div <div
key={`footer-${ciclo}`} key={ciclo}
className="rounded-lg bg-slate-50 p-2 text-center text-[10px]" className="rounded-lg bg-slate-50 p-2 text-center text-[10px]"
> >
<div className="font-bold text-slate-700">Cr: {t.cr}</div> <div className="font-bold text-slate-700">Cr: {t.cr}</div>
@@ -715,7 +526,6 @@ function MapaCurricularPage() {
</div> </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 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.cr} Cr</div>
<div>{stats.hd + stats.hi} Hrs</div> <div>{stats.hd + stats.hi} Hrs</div>
@@ -724,34 +534,35 @@ function MapaCurricularPage() {
</div> </div>
</div> </div>
{/* Asignaturas Sin Asignar */} {/* 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="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-6">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-600"> <div className="flex items-center gap-2 text-slate-600">
<h3 className="text-sm font-bold tracking-wider uppercase"> <h3 className="text-sm font-bold tracking-wider uppercase">
Bandeja de Entrada / Asignaturas sin asignar Bandeja de Entrada / Materias sin asignar
</h3> </h3>
<Badge variant="secondary">{unassignedAsignaturas.length}</Badge> <Badge variant="secondary">{unassignedMaterias.length}</Badge>
</div> </div>
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
Arrastra una asignatura aquí para quitarla del mapa Arrastra una materia aquí para quitarla del mapa
</p> </p>
</div> </div>
<div <div
className={`flex min-h-[120px] flex-wrap gap-4 rounded-xl border-2 border-dashed p-4 transition-colors ${ className={`flex min-h-[120px] flex-wrap gap-4 rounded-xl border-2 border-dashed p-4 transition-colors ${
draggedAsignatura draggedMateria
? 'border-teal-300 bg-teal-50/50' ? 'border-teal-300 bg-teal-50/50'
: 'border-slate-200 bg-white/50' : 'border-slate-200 bg-white/50'
}`} }`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, null, null)} // Limpia ciclo y línea onDrop={(e) => handleDrop(e, null, null)} // Limpia ciclo y línea
> >
{unassignedAsignaturas.map((m) => ( {unassignedMaterias.map((m) => (
<div key={m.id} className="w-[200px]"> <div key={m.id} className="w-[200px]">
<AsignaturaCardItem <MateriaCardItem
asignatura={m} materia={m}
isDragging={draggedAsignatura === m.id} isDragging={draggedMateria === m.id}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onClick={() => { onClick={() => {
setEditingData(m) // Cargamos los datos en el estado de edición setEditingData(m) // Cargamos los datos en el estado de edición
@@ -760,9 +571,9 @@ function MapaCurricularPage() {
/> />
</div> </div>
))} ))}
{unassignedAsignaturas.length === 0 && ( {unassignedMaterias.length === 0 && (
<div className="flex w-full items-center justify-center text-sm text-slate-400"> <div className="flex w-full items-center justify-center text-sm text-slate-400">
No hay asignaturas pendientes. Arrastra una asignatura aquí para No hay materias pendientes. Arrastra una materia aquí para
desasignarla. desasignarla.
</div> </div>
)} )}
@@ -771,13 +582,10 @@ function MapaCurricularPage() {
{/* Modal de Edición */} {/* Modal de Edición */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}> <Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent <DialogContent className="sm:max-w-[550px]">
className="sm:max-w-[550px]"
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader> <DialogHeader>
<DialogTitle className="font-bold text-slate-700"> <DialogTitle className="font-bold text-slate-700">
Editar Asignatura Editar Materia
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
@@ -791,7 +599,6 @@ function MapaCurricularPage() {
Clave Clave
</label> </label>
<Input <Input
maxLength={100}
value={editingData.clave} value={editingData.clave}
onChange={(e) => onChange={(e) =>
setEditingData({ ...editingData, clave: e.target.value }) setEditingData({ ...editingData, clave: e.target.value })
@@ -803,7 +610,6 @@ function MapaCurricularPage() {
Nombre Nombre
</label> </label>
<Input <Input
maxLength={200}
value={editingData.nombre} value={editingData.nombre}
onChange={(e) => onChange={(e) =>
setEditingData({ ...editingData, nombre: e.target.value }) setEditingData({ ...editingData, nombre: e.target.value })
@@ -820,17 +626,13 @@ function MapaCurricularPage() {
</label> </label>
<Input <Input
type="number" type="number"
min={0}
value={editingData.creditos} value={editingData.creditos}
onChange={(e) => { onChange={(e) =>
const val = handleDecimalChange(e.target.value, 10)
if (val !== null) {
setEditingData({ setEditingData({
...editingData, ...editingData,
creditos: val === '' ? 0 : Number(val), creditos: Number(e.target.value),
}) })
} }
}}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -840,15 +642,12 @@ function MapaCurricularPage() {
<Input <Input
type="number" type="number"
value={editingData.hd} value={editingData.hd}
onChange={(e) => { onChange={(e) =>
const val = handleIntegerChange(e.target.value)
if (val !== null) {
setEditingData({ setEditingData({
...editingData, ...editingData,
hd: Number(e.target.value), hd: Number(e.target.value),
}) })
} }
}}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -858,15 +657,12 @@ function MapaCurricularPage() {
<Input <Input
type="number" type="number"
value={editingData.hi} value={editingData.hi}
onChange={(e) => { onChange={(e) =>
const val = handleIntegerChange(e.target.value)
if (val !== null) {
setEditingData({ setEditingData({
...editingData, ...editingData,
hi: Number(e.target.value), hi: Number(e.target.value),
}) })
} }
}}
/> />
</div> </div>
</div> </div>
@@ -932,65 +728,32 @@ function MapaCurricularPage() {
</div> </div>
</div> </div>
{/* Fila 4: Seriación (Prerrequisitos) */} {/* Fila 4: Seriación (Igual a tu imagen) */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase"> <label className="text-xs font-bold text-slate-500 uppercase">
Seriación (Prerrequisitos) Seriación (Prerrequisitos)
</label> </label>
<Select <Select>
value={seriacionValue}
onValueChange={(val) => {
if (val === 'none') {
setSeriacionValue('')
return
}
if (!editingData.prerrequisitos.includes(val)) {
setEditingData({
...editingData,
prerrequisitos: [...editingData.prerrequisitos, val],
})
}
setSeriacionValue('')
}}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Seleccionar asignatura..." /> <SelectValue placeholder="Seleccionar materia..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">-- Sin Seriación --</SelectItem> {materias.map((m) => (
<SelectItem key={m.id} value={m.clave}>
{asignaturas {m.nombre}
.filter((m) => m.id !== editingData.id)
.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.nombre} ({m.clave})
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<div className="mt-2 flex gap-2">
{/* Visualización de los prerrequisitos seleccionados */} {/* Aquí usamos el array vacío que inicializamos en el mapeador */}
<div className="mt-2 flex flex-wrap gap-2">
{editingData.prerrequisitos.map((pre) => ( {editingData.prerrequisitos.map((pre) => (
<Badge <Badge
key={pre} key={pre}
variant="secondary" variant="secondary"
className="bg-slate-100 text-slate-600" className="bg-slate-100 text-slate-600"
> >
{pre} {pre} <span className="ml-1 cursor-pointer">×</span>
<button
className="ml-1 hover:text-red-500"
onClick={() => {
setEditingData({
...editingData,
prerrequisitos: editingData.prerrequisitos.filter(
(p) => p !== pre,
),
})
}}
>
×
</button>
</Badge> </Badge>
))} ))}
</div> </div>
@@ -1003,7 +766,7 @@ function MapaCurricularPage() {
</label> </label>
<Select <Select
value={editingData.tipo} value={editingData.tipo}
onValueChange={(val: 'OBLIGATORIA' | 'OPTATIVA') => onValueChange={(val: 'obligatoria' | 'optativa') =>
setEditingData({ ...editingData, tipo: val }) setEditingData({ ...editingData, tipo: val })
} }
> >
@@ -1011,8 +774,8 @@ function MapaCurricularPage() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="OBLIGATORIA">Obligatoria</SelectItem> <SelectItem value="obligatoria">Obligatoria</SelectItem>
<SelectItem value="OPTATIVA">Optativa</SelectItem> <SelectItem value="optativa">Optativa</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -0,0 +1,44 @@
import { createFileRoute, notFound } from '@tanstack/react-router'
import MateriaDetailPage from '@/components/asignaturas/detalle/MateriaDetailPage'
import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { subjects_get } from '@/data/api/subjects.api'
import { qk } from '@/data/query/keys'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId',
)({
loader: async ({ context: { queryClient }, params: { asignaturaId } }) => {
try {
await queryClient.ensureQueryData({
queryKey: qk.asignatura(asignaturaId),
queryFn: () => subjects_get(asignaturaId),
})
} catch (e: any) {
// PGRST116: The result contains 0 rows (Supabase Single response error)
if (e?.code === 'PGRST116') {
throw notFound()
}
throw e
}
},
notFoundComponent: () => {
return (
<NotFoundPage
title="Materia no encontrada"
message="La asignatura que buscas no existe o fue eliminada."
/>
)
},
component: RouteComponent,
})
function RouteComponent() {
// const { planId, asignaturaId } = Route.useParams()
return (
<div>
<MateriaDetailPage></MateriaDetailPage>
</div>
)
}

View File

@@ -1,13 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { BibliographyItem } from '@/components/asignaturas/detalle/BibliographyItem'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/bibliografia',
)({
component: RouteComponent,
})
function RouteComponent() {
return <BibliographyItem />
}

View File

@@ -1,13 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { ContenidoTematico } from '@/components/asignaturas/detalle/ContenidoTematico'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/contenido',
)({
component: RouteComponent,
})
function RouteComponent() {
return <ContenidoTematico />
}

View File

@@ -1,85 +0,0 @@
import { createFileRoute, useParams } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react'
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
import { fetchPlanPdf } from '@/data/api/document.api'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/documento',
)({
component: RouteComponent,
})
function RouteComponent() {
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId/documento',
})
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isRegenerating, setIsRegenerating] = useState(false)
const loadPdfPreview = useCallback(async () => {
try {
setIsLoading(true)
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
})
const url = window.URL.createObjectURL(pdfBlob)
setPdfUrl((prev) => {
if (prev) window.URL.revokeObjectURL(prev)
return url
})
} catch (error) {
console.error('Error cargando PDF:', error)
} finally {
setIsLoading(false)
}
}, [planId])
useEffect(() => {
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
}
}, [loadPdfPreview])
const handleDownload = async () => {
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
})
const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
link.href = url
link.download = 'documento_sep.pdf'
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
}
const handleRegenerate = async () => {
try {
setIsRegenerating(true)
await loadPdfPreview()
} finally {
setIsRegenerating(false)
}
}
return (
<DocumentoSEPTab
pdfUrl={pdfUrl}
isLoading={isLoading}
onDownload={handleDownload}
onRegenerate={handleRegenerate}
isRegenerating={isRegenerating}
/>
)
}

View File

@@ -1,13 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { HistorialTab } from '@/components/asignaturas/detalle/HistorialTab'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/historial',
)({
component: RouteComponent,
})
function RouteComponent() {
return <HistorialTab />
}

View File

@@ -1,13 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { IAAsignaturaTab } from '@/components/asignaturas/detalle/IAAsignaturaTab'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
)({
component: RouteComponent,
})
function RouteComponent() {
return <IAAsignaturaTab />
}

View File

@@ -1,13 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import AsignaturaDetailPage from '@/components/asignaturas/detalle/AsignaturaDetailPage'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/',
)({
component: DatosGeneralesPage,
})
function DatosGeneralesPage() {
return <AsignaturaDetailPage />
}

View File

@@ -1,273 +0,0 @@
import {
createFileRoute,
Outlet,
Link,
useLocation,
useParams,
useRouterState,
} from '@tanstack/react-router'
import { ArrowLeft, GraduationCap } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { lateralConfetti } from '@/components/ui/lateral-confetti'
import { useSubject, useUpdateAsignatura } from '@/data'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId',
)({
component: AsignaturaLayout,
})
function EditableHeaderField({
value,
onSave,
className,
}: {
value: string | number
onSave: (val: string) => void
className?: string
}) {
const textValue = String(value)
// Manejador para cuando el usuario termina de editar (pierde el foco)
const handleBlur = (e: React.FocusEvent<HTMLSpanElement>) => {
const newValue = e.currentTarget.innerText
if (newValue !== textValue) {
onSave(newValue)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur() // Forzamos el guardado al presionar Enter
}
}
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<span
contentEditable
suppressContentEditableWarning={true} // Evita el warning de React por tener hijos y contentEditable
spellCheck={false}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={`inline-block cursor-text rounded-sm px-1 transition-all hover:bg-white/10 focus:bg-white/20 focus:ring-2 focus:ring-blue-400/50 focus:outline-none ${className ?? ''} `}
>
{textValue}
</span>
)
}
interface DatosPlan {
nombre?: string
}
function AsignaturaLayout() {
const location = useLocation()
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: asignaturaApi, isLoading: loadingAsig } =
useSubject(asignaturaId)
// 1. Asegúrate de tener estos estados en tu componente principal
const updateAsignatura = useUpdateAsignatura()
// Dentro de AsignaturaDetailPage
const [headerData, setHeaderData] = useState({
codigo: '',
nombre: '',
creditos: 0,
ciclo: 0,
})
// Sincronizar cuando llegue la API
useEffect(() => {
if (asignaturaApi) {
setHeaderData({
codigo: asignaturaApi.codigo ?? '',
nombre: asignaturaApi.nombre,
creditos: asignaturaApi.creditos,
ciclo: asignaturaApi.numero_ciclo ?? 0,
})
}
}, [asignaturaApi])
const handleUpdateHeader = (key: string, value: string | number) => {
const newData = { ...headerData, [key]: value }
setHeaderData(newData)
const patch: Record<string, any> =
key === 'ciclo'
? { numero_ciclo: value }
: {
[key]: value,
}
updateAsignatura.mutate({
asignaturaId,
patch,
})
}
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
// Confetti al llegar desde creación IA
useEffect(() => {
if ((location.state as any)?.showConfetti) {
lateralConfetti()
window.history.replaceState({}, document.title)
}
}, [location.state])
if (loadingAsig) {
return (
<div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white">
Cargando asignatura...
</div>
)
}
// Si no hay datos y no está cargando, algo falló
if (!asignaturaApi) return null
return (
<div>
<section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<div className="mx-auto max-w-7xl px-6 py-10">
<Link
to="/planes/$planId/asignaturas"
params={{ planId }}
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white"
>
<ArrowLeft className="h-4 w-4" /> Volver al plan
</Link>
<div className="flex items-start justify-between gap-6">
<div className="space-y-3">
{/* 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">
<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="h-4 w-4 shrink-0" />
<span className="text-blue-100">
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
.nombre || ''}
</span>
</span>
<span className="flex items-center gap-1">
<span className="text-blue-100">
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
.nombre ?? ''}
</span>
</span>
</div>
<p className="text-sm text-blue-300">
Pertenece al plan:{' '}
<span className="cursor-pointer underline">
{asignaturaApi.planes_estudio?.nombre}
</span>
</p>
</div>
<div className="flex flex-col items-end gap-2 text-right">
{/* CRÉDITOS EDITABLES */}
<Badge variant="secondary" className="gap-1">
<span className="inline-flex max-w-fit">
<EditableHeaderField
value={headerData.creditos}
onSave={(val) =>
handleUpdateHeader('creditos', parseInt(val) || 0)
}
/>
</span>
<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">{asignaturaApi.tipo}</Badge>
</div>
</div>
</div>
</section>
{/* TABS */}
<nav className="border-b bg-white">
<div className="mx-auto max-w-7xl px-6">
<div className="flex justify-center gap-8">
{[
{ label: 'Datos', to: '' },
{ label: 'Contenido', to: 'contenido' },
{ label: 'Bibliografía', to: 'bibliografia' },
{ label: 'IA', to: 'asignaturaIa' },
{ label: 'Documento SEP', to: 'documento' },
{ label: 'Historial', to: 'historial' },
].map((tab) => {
const isActive =
tab.to === ''
? pathname === `/planes/${planId}/asignaturas/${asignaturaId}`
: pathname.includes(tab.to)
return (
<Link
key={tab.label}
to={
(tab.to === ''
? '/planes/$planId/asignaturas/$asignaturaId'
: `/planes/$planId/asignaturas/$asignaturaId/${tab.to}`) as any
}
from="/planes/$planId/asignaturas/$asignaturaId"
params={{ planId, asignaturaId }}
className={`border-b-2 py-3 text-sm font-medium ${
isActive
? 'border-blue-600 text-blue-600'
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
}`}
>
{tab.label}
</Link>
)
})}
</div>
</div>
</nav>
<div className="mx-auto max-w-7xl px-6 py-8">
<Outlet />
</div>
</div>
)
}

View File

@@ -1,119 +0,0 @@
export type AsignaturaTab =
| 'datos-generales'
| 'contenido-tematico'
| 'bibliografia'
| 'ia-asignatura'
| 'documento-sep'
| 'historial'
export interface Asignatura {
id: string
nombre: string
clave: string
creditos?: number
lineaCurricular?: string
ciclo?: string
planId: string
planNombre: string
carrera: string
facultad: string
estructuraId: string
}
export interface CampoEstructura {
id: string
nombre: string
tipo: 'texto' | 'texto_largo' | 'lista' | 'numero'
obligatorio: boolean
descripcion?: string
placeholder?: string
}
export interface AsignaturaStructure {
id: string
nombre: string
campos: Array<CampoEstructura>
}
export interface Tema {
id: string
nombre: string
descripcion?: string
horasEstimadas?: number
}
export interface UnidadTematica {
id: string
nombre: string
numero: number
temas: Array<Tema>
}
export interface BibliografiaEntry {
id: string
tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string
fuenteBibliotecaId?: string
fuenteBiblioteca?: LibraryResource
}
export interface LibraryResource {
id: string
titulo: string
autor: string
editorial?: string
anio?: number
isbn?: string
tipo: 'libro' | 'articulo' | 'revista' | 'recurso_digital'
disponible: boolean
}
export interface IAMessage {
id: string
role: 'user' | 'assistant'
content: string
timestamp: Date
campoAfectado?: string
sugerencia?: IASugerencia
}
export interface IASugerencia {
campoId: string
campoNombre: string
valorActual: string
valorSugerido: string
aceptada?: boolean
}
export interface CambioAsignatura {
id: string
tipo: 'datos' | 'contenido' | 'bibliografia' | 'ia' | 'documento'
descripcion: string
usuario: string
fecha: Date
detalles?: Record<string, any>
}
export interface DocumentoAsignatura {
id: string
asignaturaId: string
version: number
fechaGeneracion: Date
url?: string
estado: 'generando' | 'listo' | 'error'
}
export interface AsignaturaDetailState {
asignatura: Asignatura | null
estructura: AsignaturaStructure | null
datosGenerales: Record<string, any>
contenidoTematico: Array<UnidadTematica>
bibliografia: Array<BibliografiaEntry>
iaMessages: Array<IAMessage>
documentoSep: DocumentoAsignatura | null
historial: Array<CambioAsignatura>
activeTab: AsignaturaTab
isSaving: boolean
isLoading: boolean
errorMessage: string | null
}

119
src/types/materia.ts Normal file
View File

@@ -0,0 +1,119 @@
export type MateriaTab =
| 'datos-generales'
| 'contenido-tematico'
| 'bibliografia'
| 'ia-materia'
| 'documento-sep'
| 'historial';
export interface Materia {
id: string;
nombre: string;
clave: string;
creditos?: number;
lineaCurricular?: string;
ciclo?: string;
planId: string;
planNombre: string;
carrera: string;
facultad: string;
estructuraId: string;
}
export interface CampoEstructura {
id: string;
nombre: string;
tipo: 'texto' | 'texto_largo' | 'lista' | 'numero';
obligatorio: boolean;
descripcion?: string;
placeholder?: string;
}
export interface MateriaStructure {
id: string;
nombre: string;
campos: CampoEstructura[];
}
export interface Tema {
id: string;
nombre: string;
descripcion?: string;
horasEstimadas?: number;
}
export interface UnidadTematica {
id: string;
nombre: string;
numero: number;
temas: Tema[];
}
export interface BibliografiaEntry {
id: string;
tipo: 'BASICA' | 'COMPLEMENTARIA';
cita: string;
fuenteBibliotecaId?: string;
fuenteBiblioteca?: LibraryResource;
}
export interface LibraryResource {
id: string;
titulo: string;
autor: string;
editorial?: string;
anio?: number;
isbn?: string;
tipo: 'libro' | 'articulo' | 'revista' | 'recurso_digital';
disponible: boolean;
}
export interface IAMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
campoAfectado?: string;
sugerencia?: IASugerencia;
}
export interface IASugerencia {
campoId: string;
campoNombre: string;
valorActual: string;
valorSugerido: string;
aceptada?: boolean;
}
export interface CambioMateria {
id: string;
tipo: 'datos' | 'contenido' | 'bibliografia' | 'ia' | 'documento';
descripcion: string;
usuario: string;
fecha: Date;
detalles?: Record<string, any>;
}
export interface DocumentoMateria {
id: string;
materiaId: string;
version: number;
fechaGeneracion: Date;
url?: string;
estado: 'generando' | 'listo' | 'error';
}
export interface MateriaDetailState {
materia: Materia | null;
estructura: MateriaStructure | null;
datosGenerales: Record<string, any>;
contenidoTematico: UnidadTematica[];
bibliografia: BibliografiaEntry[];
iaMessages: IAMessage[];
documentoSep: DocumentoMateria | null;
historial: CambioMateria[];
activeTab: MateriaTab;
isSaving: boolean;
isLoading: boolean;
errorMessage: string | null;
}

View File

@@ -1,5 +1,3 @@
import type { Tables } from './supabase'
export type PlanStatus = export type PlanStatus =
| 'borrador' | 'borrador'
| 'revision' | 'revision'
@@ -14,9 +12,9 @@ export type TipoPlan =
| 'Doctorado' | 'Doctorado'
| 'Especialidad' | 'Especialidad'
export type TipoAsignatura = Tables<'asignaturas'>['tipo'] export type TipoMateria = 'obligatoria' | 'optativa' | 'troncal'
export type AsignaturaStatus = Tables<'asignaturas'>['estado'] export type MateriaStatus = 'borrador' | 'revisada' | 'aprobada'
export interface Facultad { export interface Facultad {
id: string id: string
@@ -38,15 +36,15 @@ export interface LineaCurricular {
color?: string color?: string
} }
export interface Asignatura { export interface Materia {
id: string id: string
clave: string clave: string
nombre: string nombre: string
creditos: number creditos: number
ciclo: number | null ciclo: number | null
lineaCurricularId: string | null lineaCurricularId: string | null
tipo: TipoAsignatura tipo: TipoMateria
estado: AsignaturaStatus estado: MateriaStatus
orden?: number orden?: number
hd: number // <--- Añadir hd: number // <--- Añadir
hi: number // <--- Añadir hi: number // <--- Añadir
@@ -105,7 +103,7 @@ export interface DocumentoPlan {
export type PlanTab = export type PlanTab =
| 'datos-generales' | 'datos-generales'
| 'mapa-curricular' | 'mapa-curricular'
| 'asignaturas' | 'materias'
| 'flujo' | 'flujo'
| 'ia' | 'ia'
| 'documento' | 'documento'

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
v2.75.0 v2.67.1