Compare commits
130 Commits
dc85e2c946
...
issue/147-
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e1045358d | |||
| 314a96f2c5 | |||
| 7693f86951 | |||
| 8ad6c8096e | |||
| 28742615d8 | |||
| 0cb467cb78 | |||
| ff5ba3952d | |||
| f6b25ad86a | |||
| d7d4eff523 | |||
| 6773247b03 | |||
| ef614be2f1 | |||
| 2f0005baa7 | |||
| 3c37b5f313 | |||
| 4d1f102acb | |||
| f706456ff6 | |||
| b888bb22cd | |||
| 543e83609e | |||
| 4a8a9e1857 | |||
| d28b32d34e | |||
| 6db7c1c023 | |||
| cfc2153fa2 | |||
| f28804bb5b | |||
| f1d09a37ed | |||
| 3c63fdef69 | |||
| 3dc01c3fba | |||
| a51fa6b1fc | |||
| 1fddb75bf8 | |||
| ec994c9586 | |||
| 1acc37403d | |||
| f7ab1d61f0 | |||
| 6ed5d3541f | |||
| 3188a61431 | |||
| 5912a7c1fb | |||
| 1c45330da6 | |||
| 88ad7d74e3 | |||
| 08afd27f80 | |||
| b0a89ac57f | |||
| 440147edec | |||
| d415344ee7 | |||
| 5a0f7acac3 | |||
| f882d8c89d | |||
| 517b9497f1 | |||
| eb95dec097 | |||
| cf4caa2857 | |||
| 2de1e4237c | |||
| 7472e2cf97 | |||
| 15f60b7751 | |||
| 50c00293cd | |||
| 99ed75b2eb | |||
| cd16b3cb4f | |||
| 8444f2a87e | |||
| 02c415a91d | |||
| 7d45eb4dfa | |||
| 54b22b7adf | |||
| d4a034c2fc | |||
| 56d23f1aa5 | |||
| 13d9f1fe4a | |||
| 2624b0694d | |||
| 04909513bb | |||
| 5f8d758f67 | |||
| 41aecc4a45 | |||
| 1e4a58e4da | |||
| 2157ffe3bc | |||
| c280faef22 | |||
| d6c567195a | |||
| 9c588cfd8f | |||
| 46d8d6142e | |||
| 07d08e1b57 | |||
| ded54c18dd | |||
| 89f264bf5d | |||
| 675c76db74 | |||
| d74807c84e | |||
| 4d0f5815eb | |||
| 2f9e779bce | |||
| 0c57bdfc38 | |||
| 2250a1afd1 | |||
| 9102e756cb | |||
| e788eb788f | |||
| 2ec222694d | |||
| 58d4ee8b6e | |||
| d9a6852f43 | |||
| ba188329dc | |||
| 777be81d2a | |||
| 3afce0de77 | |||
| 4b8ec2c5ab | |||
| 0788002c9b | |||
| c7c631a701 | |||
| 9ba94f2c2c | |||
| 846e3abf74 | |||
| e646125116 | |||
| 417dec8c9b | |||
| f5cab5139a | |||
| 1caa5bef06 | |||
| 581dc566bc | |||
| 31a47934e5 | |||
| 958b558111 | |||
| 1f78284fb6 | |||
| b45aa4b59c | |||
| 09d8392a28 | |||
| 016f076e5e | |||
| 43aed3fb47 | |||
| a6a94fa42b | |||
| b1a233fa8c | |||
| f00fabeac5 | |||
| c82fac52f7 | |||
| db5465032e | |||
| fafe90e5e8 | |||
| 0e9648d61a | |||
| bd8bef142a | |||
| 261dec7fa9 | |||
| 1acb18711f | |||
| f046bdcc04 | |||
| 12c572a442 | |||
| 64d9aa336f | |||
| c27f05c5f6 | |||
| efab8eb2e4 | |||
| 867ecc53e0 | |||
| 4d8f7d7b41 | |||
| 36a369a207 | |||
| 2185901c7a | |||
| d0b05256b0 | |||
| 2c702d7d67 | |||
| a67fd72cb7 | |||
| 071f819341 | |||
| 8786aaae25 | |||
| 9065899616 | |||
| 9cad2a0f62 | |||
| 4e00262ab0 | |||
| 35ea4caa39 | |||
| ddb3a5023c |
1
.github/copilot-instructions.md
vendored
Normal file
1
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Al funcionar como agente, ignora los problemas de eslint del orden de imports
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ count.txt
|
|||||||
.nitro
|
.nitro
|
||||||
.tanstack
|
.tanstack
|
||||||
.wrangler
|
.wrangler
|
||||||
|
diff.txt
|
||||||
69
bun.lock
69
bun.lock
@@ -8,6 +8,7 @@
|
|||||||
"@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",
|
||||||
@@ -19,7 +20,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.90.1",
|
"@supabase/supabase-js": "^2.98.0",
|
||||||
"@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",
|
||||||
@@ -35,12 +36,14 @@
|
|||||||
"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",
|
||||||
@@ -250,10 +253,16 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -266,6 +275,8 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -278,12 +289,24 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -294,6 +317,10 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -302,10 +329,22 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -402,17 +441,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.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-pC0Ek4xk4z6q7A/3+UuZ/eYgfFUUQTg3DhapzrAgJnFGDJDFDyGCj6v9nIz8+3jfLqSZ3QKGe6AoEodYjShghg=="],
|
"@supabase/auth-js": ["@supabase/auth-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg=="],
|
||||||
|
|
||||||
"@supabase/functions-js": ["@supabase/functions-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Ott2IcIXHGupaC0nX9WNEiJAX4OdlGRu9upkkURaQHbaLdz9JuCcHxlwTERgtgjMpikbIWHfMM1M9QTQFYABiA=="],
|
"@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/postgrest-js": ["@supabase/postgrest-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uRKKQJBDnfi6XFNFPNMh9+u3HT2PCgp065PcMPmG7e0xGuqvLtN89QxO2/SZcGbw2y1+mNBz0yUs5KmyNqF2fA=="],
|
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg=="],
|
||||||
|
|
||||||
"@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/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/storage-js": ["@supabase/storage-js@2.93.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-3KVwd4S1i1BVPL6KIywe5rnruNQXSkLyvrdiJmwnqwbCcDujQumARdGWBPesqCjOPKEU2M9ORWKAsn+2iLzquA=="],
|
"@supabase/storage-js": ["@supabase/storage-js@2.98.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ=="],
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
@@ -1158,6 +1197,8 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1348,6 +1389,8 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
@@ -1416,6 +1459,8 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -1428,6 +1473,8 @@
|
|||||||
|
|
||||||
"@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=="],
|
||||||
@@ -1484,6 +1531,14 @@
|
|||||||
|
|
||||||
"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=="],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
@@ -17,11 +18,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.90.1",
|
"@supabase/supabase-js": "^2.98.0",
|
||||||
"@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,12 +49,14 @@
|
|||||||
"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",
|
||||||
|
|||||||
118
public/lasalle-logo.svg
Normal file
118
public/lasalle-logo.svg
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 10 KiB |
@@ -1,9 +1,20 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
import { Home, Menu, Network, X } from 'lucide-react'
|
import { Home, LogOut, 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 (
|
||||||
<>
|
<>
|
||||||
@@ -18,13 +29,19 @@ 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
|
<img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" />
|
||||||
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
|
||||||
|
|||||||
483
src/components/asignaturas/detalle/AsignaturaDetailPage.tsx
Normal file
483
src/components/asignaturas/detalle/AsignaturaDetailPage.tsx
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
import {
|
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||||
Plus,
|
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||||
Search,
|
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||||
BookOpen,
|
import { useParams } from '@tanstack/react-router'
|
||||||
Trash2,
|
import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
|
||||||
Library,
|
import { useState } from 'react'
|
||||||
Edit3,
|
|
||||||
Save,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -38,40 +34,13 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { useSubjectBibliografia } from '@/data/hooks/useSubjects'
|
import {
|
||||||
|
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 {
|
||||||
@@ -84,24 +53,21 @@ export interface BibliografiaEntry {
|
|||||||
fuenteBiblioteca?: any
|
fuenteBiblioteca?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BibliografiaTabProps {
|
export function BibliographyItem() {
|
||||||
id: string
|
const { asignaturaId } = useParams({
|
||||||
bibliografia: Array<BibliografiaEntry>
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
onSave: (bibliografia: Array<BibliografiaEntry>) => void
|
})
|
||||||
isSaving: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BibliographyItem({
|
// --- 1. Única fuente de verdad: La Query ---
|
||||||
bibliografia,
|
const { data: bibliografia = [], isLoading } =
|
||||||
id,
|
useSubjectBibliografia(asignaturaId)
|
||||||
onSave,
|
|
||||||
isSaving,
|
|
||||||
}: BibliografiaTabProps) {
|
|
||||||
console.log(id)
|
|
||||||
|
|
||||||
const { data: bibliografia2, isLoading: loadinmateria } =
|
// --- 2. Mutaciones ---
|
||||||
useSubjectBibliografia(id)
|
const { mutate: crearBibliografia } = useCreateBibliografia()
|
||||||
const [entries, setEntries] = useState<Array<BibliografiaEntry>>(bibliografia)
|
const { mutate: actualizarBibliografia } = useUpdateBibliografia(asignaturaId)
|
||||||
|
const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId)
|
||||||
|
|
||||||
|
// --- 3. Estados de UI (Solo para diálogos y edición) ---
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
const [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)
|
||||||
@@ -110,30 +76,27 @@ export function BibliographyItem({
|
|||||||
'BASICA',
|
'BASICA',
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
console.log('Datos actuales en el front:', bibliografia)
|
||||||
if (bibliografia2 && Array.isArray(bibliografia2)) {
|
// --- 4. Derivación de datos (Se calculan en cada render) ---
|
||||||
setEntries(bibliografia2)
|
const basicaEntries = bibliografia.filter((e) => e.tipo === 'BASICA')
|
||||||
} else if (bibliografia) {
|
const complementariaEntries = bibliografia.filter(
|
||||||
// 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) => {
|
||||||
const newEntry: BibliografiaEntry = {
|
crearBibliografia(
|
||||||
id: `manual-${Date.now()}`,
|
{
|
||||||
|
asignatura_id: asignaturaId,
|
||||||
tipo: newEntryType,
|
tipo: newEntryType,
|
||||||
cita,
|
cita,
|
||||||
}
|
tipo_fuente: 'MANUAL',
|
||||||
setEntries([...entries, newEntry])
|
},
|
||||||
setIsAddDialogOpen(false)
|
{
|
||||||
// toast.success('Referencia manual añadida');
|
onSuccess: () => setIsAddDialogOpen(false),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddFromLibrary = (
|
const handleAddFromLibrary = (
|
||||||
@@ -141,22 +104,43 @@ 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}.`
|
||||||
const newEntry: BibliografiaEntry = {
|
crearBibliografia(
|
||||||
id: `lib-ref-${Date.now()}`,
|
{
|
||||||
|
asignatura_id: asignaturaId,
|
||||||
tipo,
|
tipo,
|
||||||
cita,
|
cita,
|
||||||
fuenteBibliotecaId: resource.id,
|
tipo_fuente: 'BIBLIOTECA',
|
||||||
fuenteBiblioteca: resource,
|
biblioteca_item_id: resource.id,
|
||||||
}
|
},
|
||||||
setEntries([...entries, newEntry])
|
{
|
||||||
setIsLibraryDialogOpen(false)
|
onSuccess: () => setIsLibraryDialogOpen(false),
|
||||||
// toast.success('Añadido desde biblioteca');
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateCita = (id: string, cita: string) => {
|
const handleUpdateCita = (id: string, nuevaCita: string) => {
|
||||||
setEntries(entries.map((e) => (e.id === id ? { ...e, cita } : e)))
|
actualizarBibliografia(
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
updates: { cita: nuevaCita },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => setEditingId(null),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onConfirmDelete = () => {
|
||||||
|
if (deleteId) {
|
||||||
|
eliminarBibliografia(deleteId, {
|
||||||
|
onSuccess: () => setDeleteId(null),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return <div className="p-10 text-center">Cargando bibliografía...</div>
|
||||||
|
|
||||||
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">
|
||||||
@@ -184,8 +168,13 @@ 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}
|
||||||
existingIds={entries.map((e) => e.fuenteBibliotecaId || '')}
|
// CORRECCIÓN: Usamos 'bibliografia' en lugar de 'entries'
|
||||||
|
existingIds={bibliografia.map(
|
||||||
|
(e) => e.biblioteca_item_id || '',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -204,15 +193,6 @@ 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>
|
||||||
|
|
||||||
@@ -274,13 +254,7 @@ export function BibliographyItem({
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction onClick={onConfirmDelete} className="bg-red-600">
|
||||||
onClick={() => {
|
|
||||||
setEntries(entries.filter((e) => e.id !== deleteId))
|
|
||||||
setDeleteId(null)
|
|
||||||
}}
|
|
||||||
className="bg-red-600"
|
|
||||||
>
|
|
||||||
Eliminar
|
Eliminar
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
@@ -433,14 +407,16 @@ function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LibrarySearchDialog({ onSelect, existingIds }: any) {
|
function LibrarySearchDialog({ resources, 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 = mockLibraryResources.filter(
|
const filtered = (resources || []).filter(
|
||||||
(r) =>
|
(r: any) =>
|
||||||
!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">
|
||||||
@@ -468,7 +444,7 @@ function LibrarySearchDialog({ 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) => (
|
{filtered.map((res: any) => (
|
||||||
<div
|
<div
|
||||||
key={res.id}
|
key={res.id}
|
||||||
onClick={() => onSelect(res, tipo)}
|
onClick={() => onSelect(res, tipo)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useParams } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
@@ -7,17 +7,12 @@ import {
|
|||||||
Edit3,
|
Edit3,
|
||||||
Trash2,
|
Trash2,
|
||||||
Clock,
|
Clock,
|
||||||
Save,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import type { FocusEvent, KeyboardEvent } from 'react'
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible'
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -28,6 +23,16 @@ 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';
|
||||||
|
|
||||||
@@ -42,78 +47,306 @@ export interface UnidadTematica {
|
|||||||
id: string
|
id: string
|
||||||
nombre: string
|
nombre: string
|
||||||
numero: number
|
numero: number
|
||||||
temas: Tema[]
|
temas: Array<Tema>
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialData: UnidadTematica[] = [
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
{
|
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 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// Estructura que viene de tu JSON/API
|
function coerceNumber(value: unknown): number | undefined {
|
||||||
interface ContenidoApi {
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||||
unidad: number
|
if (typeof value === 'string') {
|
||||||
titulo: string
|
const trimmed = value.trim()
|
||||||
temas: string[] | any[] // Acepta strings o objetos
|
if (!trimmed) return undefined
|
||||||
[key: string]: any // Esta línea permite que haya más claves desconocidas
|
const parsed = Number(trimmed)
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceString(value: unknown): string | undefined {
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTemaValue(value: unknown): ContenidoTemaApi | null {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
return trimmed ? trimmed : null
|
||||||
|
}
|
||||||
|
if (isRecord(value)) {
|
||||||
|
const nombre = coerceString(value.nombre)
|
||||||
|
if (!nombre) return null
|
||||||
|
const horasEstimadas = coerceNumber(value.horasEstimadas)
|
||||||
|
const descripcion = coerceString(value.descripcion)
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
nombre,
|
||||||
|
horasEstimadas,
|
||||||
|
descripcion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapContenidoItem(value: unknown, index: number): ContenidoApi | null {
|
||||||
|
if (!isRecord(value)) return null
|
||||||
|
|
||||||
|
const unidad = coerceNumber(value.unidad) ?? index + 1
|
||||||
|
const titulo = coerceString(value.titulo) ?? 'Sin título'
|
||||||
|
|
||||||
|
let temas: Array<ContenidoTemaApi> = []
|
||||||
|
if (Array.isArray(value.temas)) {
|
||||||
|
temas = value.temas
|
||||||
|
.map(mapTemaValue)
|
||||||
|
.filter((t): t is ContenidoTemaApi => t !== null)
|
||||||
|
} else if (typeof value.temas === 'string' && value.temas.trim()) {
|
||||||
|
temas = value.temas
|
||||||
|
.split(/\r?\n|,/)
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { unidad, titulo, temas }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
|
||||||
|
if (value == null) return []
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
return mapContenidoTematicoFromDb(JSON.parse(value))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item, idx) => mapContenidoItem(item, idx))
|
||||||
|
.filter((x): x is ContenidoApi => x !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(value)) {
|
||||||
|
if (Array.isArray(value.contenido_tematico)) {
|
||||||
|
return mapContenidoTematicoFromDb(value.contenido_tematico)
|
||||||
|
}
|
||||||
|
if (Array.isArray(value.unidades)) {
|
||||||
|
return mapContenidoTematicoFromDb(value.unidades)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeUnidadesToApi(
|
||||||
|
unidades: Array<UnidadTematica>,
|
||||||
|
): Array<ContenidoApi> {
|
||||||
|
return unidades
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.numero - b.numero)
|
||||||
|
.map((u, idx) => ({
|
||||||
|
unidad: u.numero || idx + 1,
|
||||||
|
titulo: u.nombre || 'Sin título',
|
||||||
|
temas: u.temas.map((t) => ({
|
||||||
|
nombre: t.nombre || 'Tema',
|
||||||
|
horasEstimadas: t.horasEstimadas ?? 0,
|
||||||
|
descripcion: t.descripcion,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Props del componente
|
// Props del componente
|
||||||
interface ContenidoTematicoProps {
|
|
||||||
data: {
|
export function ContenidoTematico() {
|
||||||
contenido_tematico: ContenidoApi[]
|
const updateContenido = useUpdateSubjectContenido()
|
||||||
}
|
const { asignaturaId } = useParams({
|
||||||
isLoading: boolean
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
}
|
})
|
||||||
export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
|
|
||||||
const [unidades, setUnidades] = useState<UnidadTematica[]>([])
|
const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
|
||||||
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(
|
const [unidades, setUnidades] = useState<Array<UnidadTematica>>([])
|
||||||
new Set(['u1']),
|
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(new Set())
|
||||||
|
const unitContainerRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||||
|
const unitTitleInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const temaNombreInputElRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const [pendingScrollUnitId, setPendingScrollUnitId] = useState<string | null>(
|
||||||
|
null,
|
||||||
)
|
)
|
||||||
|
const cancelNextBlurRef = useRef(false)
|
||||||
const [deleteDialog, setDeleteDialog] = useState<{
|
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 [isSaving, setIsSaving] = useState(false)
|
const [temaDraftNombre, setTemaDraftNombre] = useState('')
|
||||||
|
const [temaOriginalNombre, setTemaOriginalNombre] = useState('')
|
||||||
|
const [temaDraftHoras, setTemaDraftHoras] = useState('')
|
||||||
|
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
|
||||||
|
|
||||||
|
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
|
||||||
|
const payload = serializeUnidadesToApi(nextUnidades)
|
||||||
|
await updateContenido.mutateAsync({
|
||||||
|
subjectId: asignaturaId,
|
||||||
|
unidades: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const beginEditUnit = (unitId: string) => {
|
||||||
|
const unit = unidades.find((u) => u.id === unitId)
|
||||||
|
const nombre = unit?.nombre ?? ''
|
||||||
|
setEditingUnit(unitId)
|
||||||
|
setUnitDraftNombre(nombre)
|
||||||
|
setUnitOriginalNombre(nombre)
|
||||||
|
setExpandedUnits((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(unitId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitEditUnit = () => {
|
||||||
|
if (!editingUnit) return
|
||||||
|
const next = unidades.map((u) =>
|
||||||
|
u.id === editingUnit ? { ...u, nombre: unitDraftNombre } : u,
|
||||||
|
)
|
||||||
|
setUnidades(next)
|
||||||
|
setEditingUnit(null)
|
||||||
|
void persistUnidades(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEditUnit = () => {
|
||||||
|
setEditingUnit(null)
|
||||||
|
setUnitDraftNombre(unitOriginalNombre)
|
||||||
|
}
|
||||||
|
|
||||||
|
const beginEditTema = (unitId: string, temaId: string) => {
|
||||||
|
const unit = unidades.find((u) => u.id === unitId)
|
||||||
|
const tema = unit?.temas.find((t) => t.id === temaId)
|
||||||
|
const nombre = tema?.nombre ?? ''
|
||||||
|
const horas = tema?.horasEstimadas ?? 0
|
||||||
|
|
||||||
|
setEditingTema({ unitId, temaId })
|
||||||
|
setTemaDraftNombre(nombre)
|
||||||
|
setTemaOriginalNombre(nombre)
|
||||||
|
setTemaDraftHoras(String(horas))
|
||||||
|
setTemaOriginalHoras(horas)
|
||||||
|
setExpandedUnits((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(unitId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitEditTema = () => {
|
||||||
|
if (!editingTema) return
|
||||||
|
const parsedHoras = Number.parseInt(temaDraftHoras, 10)
|
||||||
|
const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
|
||||||
|
|
||||||
|
const next = unidades.map((u) => {
|
||||||
|
if (u.id !== editingTema.unitId) return u
|
||||||
|
return {
|
||||||
|
...u,
|
||||||
|
temas: u.temas.map((t) =>
|
||||||
|
t.id === editingTema.temaId
|
||||||
|
? { ...t, nombre: temaDraftNombre, horasEstimadas }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setUnidades(next)
|
||||||
|
setEditingTema(null)
|
||||||
|
void persistUnidades(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEditTema = () => {
|
||||||
|
setEditingTema(null)
|
||||||
|
setTemaDraftNombre(temaOriginalNombre)
|
||||||
|
setTemaDraftHoras(String(temaOriginalHoras))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTemaEditorBlurCapture = (e: FocusEvent<HTMLDivElement>) => {
|
||||||
|
if (cancelNextBlurRef.current) {
|
||||||
|
cancelNextBlurRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextFocus = e.relatedTarget as Node | null
|
||||||
|
if (nextFocus && e.currentTarget.contains(nextFocus)) return
|
||||||
|
commitEditTema()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTemaEditorKeyDownCapture = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (e.target instanceof HTMLElement) e.target.blur()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancelNextBlurRef.current = true
|
||||||
|
cancelEditTema()
|
||||||
|
if (e.target instanceof HTMLElement) e.target.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.contenido_tematico) {
|
const contenido = mapContenidoTematicoFromDb(
|
||||||
const transformed = data.contenido_tematico.map(
|
data ? data.contenido_tematico : undefined,
|
||||||
(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-${idx}-${tidx}`,
|
id: `t-${u.unidad || idx + 1}-${tidx + 1}`,
|
||||||
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)
|
|
||||||
|
|
||||||
// Expandir la primera unidad automáticamente
|
setUnidades(transformed)
|
||||||
if (transformed.length > 0) {
|
// Mantener las unidades ya expandidas si existen; si no, expandir la primera.
|
||||||
setExpandedUnits(new Set([transformed[0].id]))
|
setExpandedUnits((prev) => {
|
||||||
}
|
const validIds = new Set(transformed.map((u) => u.id))
|
||||||
}
|
const filtered = new Set(
|
||||||
|
Array.from(prev).filter((id) => validIds.has(id)),
|
||||||
|
)
|
||||||
|
if (filtered.size > 0) return filtered
|
||||||
|
return transformed.length > 0 ? new Set([transformed[0].id]) : new Set()
|
||||||
|
})
|
||||||
}, [data])
|
}, [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>
|
||||||
|
|
||||||
@@ -132,79 +365,76 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addUnidad = () => {
|
const addUnidad = () => {
|
||||||
const newId = `u-${Date.now()}`
|
const newNumero = unidades.length + 1
|
||||||
|
const newId = `u-${newNumero}`
|
||||||
const newUnidad: UnidadTematica = {
|
const newUnidad: UnidadTematica = {
|
||||||
id: newId,
|
id: newId,
|
||||||
nombre: 'Nueva Unidad',
|
nombre: 'Nueva Unidad',
|
||||||
numero: unidades.length + 1,
|
numero: newNumero,
|
||||||
temas: [],
|
temas: [],
|
||||||
}
|
}
|
||||||
setUnidades([...unidades, newUnidad])
|
const next = [...unidades, newUnidad]
|
||||||
setExpandedUnits(new Set([...expandedUnits, newId]))
|
setUnidades(next)
|
||||||
setEditingUnit(newId)
|
setExpandedUnits((prev) => {
|
||||||
}
|
const n = new Set(prev)
|
||||||
|
n.add(newId)
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
setPendingScrollUnitId(newId)
|
||||||
|
|
||||||
const updateUnidadNombre = (id: string, nombre: string) => {
|
// Abrir edición del título inmediatamente
|
||||||
setUnidades(unidades.map((u) => (u.id === id ? { ...u, nombre } : u)))
|
setEditingUnit(newId)
|
||||||
|
setUnitDraftNombre(newUnidad.nombre)
|
||||||
|
setUnitOriginalNombre(newUnidad.nombre)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Lógica de Temas ---
|
// --- Lógica de Temas ---
|
||||||
const addTema = (unidadId: string) => {
|
const addTema = (unidadId: string) => {
|
||||||
setUnidades(
|
const unit = unidades.find((u) => u.id === unidadId)
|
||||||
unidades.map((u) => {
|
const unitNumero = unit?.numero ?? 0
|
||||||
if (u.id === unidadId) {
|
const newTemaIndex = (unit?.temas.length ?? 0) + 1
|
||||||
const newTemaId = `t-${Date.now()}`
|
const newTemaId = `t-${unitNumero}-${newTemaIndex}`
|
||||||
const newTema: Tema = {
|
const newTema: Tema = {
|
||||||
id: newTemaId,
|
id: newTemaId,
|
||||||
nombre: 'Nuevo tema',
|
nombre: 'Nuevo tema',
|
||||||
horasEstimadas: 2,
|
horasEstimadas: 2,
|
||||||
}
|
}
|
||||||
setEditingTema({ unitId: unidadId, temaId: newTemaId })
|
|
||||||
return { ...u, temas: [...u.temas, newTema] }
|
|
||||||
}
|
|
||||||
return u
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateTema = (
|
const next = unidades.map((u) =>
|
||||||
unidadId: string,
|
u.id === unidadId ? { ...u, temas: [...u.temas, newTema] } : u,
|
||||||
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
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
setUnidades(next)
|
||||||
|
|
||||||
|
// Expandir unidad y poner el subtema en edición con foco en el nombre
|
||||||
|
setExpandedUnits((prev) => {
|
||||||
|
const n = new Set(prev)
|
||||||
|
n.add(unidadId)
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
setEditingTema({ unitId: unidadId, temaId: newTemaId })
|
||||||
|
setTemaDraftNombre(newTema.nombre)
|
||||||
|
setTemaOriginalNombre(newTema.nombre)
|
||||||
|
setTemaDraftHoras(String(newTema.horasEstimadas ?? 0))
|
||||||
|
setTemaOriginalHoras(newTema.horasEstimadas ?? 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (!deleteDialog) return
|
if (!deleteDialog) return
|
||||||
|
let next: Array<UnidadTematica> = unidades
|
||||||
if (deleteDialog.type === 'unidad') {
|
if (deleteDialog.type === 'unidad') {
|
||||||
setUnidades(
|
next = unidades
|
||||||
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) {
|
||||||
setUnidades(
|
next = unidades.map((u) =>
|
||||||
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,32 +449,18 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
|
|||||||
{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) => (
|
||||||
<Card
|
<div
|
||||||
key={unidad.id}
|
key={unidad.id}
|
||||||
className="overflow-hidden border-slate-200 shadow-sm"
|
ref={(el) => {
|
||||||
|
if (el) unitContainerRefs.current.set(unidad.id, el)
|
||||||
|
else unitContainerRefs.current.delete(unidad.id)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
||||||
<Collapsible
|
<Collapsible
|
||||||
open={expandedUnits.has(unidad.id)}
|
open={expandedUnits.has(unidad.id)}
|
||||||
onOpenChange={() => toggleUnit(unidad.id)}
|
onOpenChange={() => toggleUnit(unidad.id)}
|
||||||
@@ -267,21 +483,35 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
|
|||||||
|
|
||||||
{editingUnit === unidad.id ? (
|
{editingUnit === unidad.id ? (
|
||||||
<Input
|
<Input
|
||||||
value={unidad.nombre}
|
ref={unitTitleInputRef}
|
||||||
onChange={(e) =>
|
value={unitDraftNombre}
|
||||||
updateUnidadNombre(unidad.id, e.target.value)
|
onChange={(e) => setUnitDraftNombre(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
if (cancelNextBlurRef.current) {
|
||||||
|
cancelNextBlurRef.current = false
|
||||||
|
return
|
||||||
}
|
}
|
||||||
onBlur={() => setEditingUnit(null)}
|
commitEditUnit()
|
||||||
onKeyDown={(e) =>
|
}}
|
||||||
e.key === 'Enter' && setEditingUnit(null)
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.currentTarget.blur()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancelNextBlurRef.current = true
|
||||||
|
cancelEditUnit()
|
||||||
|
e.currentTarget.blur()
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="h-8 max-w-md bg-white"
|
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={() => setEditingUnit(unidad.id)}
|
onClick={() => beginEditUnit(unidad.id)}
|
||||||
>
|
>
|
||||||
{unidad.nombre}
|
{unidad.nombre}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -318,16 +548,22 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
|
|||||||
tema={tema}
|
tema={tema}
|
||||||
index={idx + 1}
|
index={idx + 1}
|
||||||
isEditing={
|
isEditing={
|
||||||
editingTema?.unitId === unidad.id &&
|
!!editingTema &&
|
||||||
editingTema?.temaId === tema.id
|
editingTema.unitId === unidad.id &&
|
||||||
|
editingTema.temaId === tema.id
|
||||||
}
|
}
|
||||||
onEdit={() =>
|
draftNombre={temaDraftNombre}
|
||||||
setEditingTema({ unitId: unidad.id, temaId: tema.id })
|
draftHoras={temaDraftHoras}
|
||||||
}
|
onBeginEdit={() => beginEditTema(unidad.id, tema.id)}
|
||||||
onStopEditing={() => setEditingTema(null)}
|
onDraftNombreChange={setTemaDraftNombre}
|
||||||
onUpdate={(updates) =>
|
onDraftHorasChange={setTemaDraftHoras}
|
||||||
updateTema(unidad.id, tema.id, updates)
|
onEditorBlurCapture={handleTemaEditorBlurCapture}
|
||||||
|
onEditorKeyDownCapture={
|
||||||
|
handleTemaEditorKeyDownCapture
|
||||||
}
|
}
|
||||||
|
onNombreInputRef={(el) => {
|
||||||
|
temaNombreInputElRef.current = el
|
||||||
|
}}
|
||||||
onDelete={() =>
|
onDelete={() =>
|
||||||
setDeleteDialog({
|
setDeleteDialog({
|
||||||
type: 'tema',
|
type: 'tema',
|
||||||
@@ -350,9 +586,24 @@ export function ContenidoTematico({ data, isLoading }: ContenidoTematicoProps) {
|
|||||||
</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}
|
||||||
@@ -367,9 +618,14 @@ interface TemaRowProps {
|
|||||||
tema: Tema
|
tema: Tema
|
||||||
index: number
|
index: number
|
||||||
isEditing: boolean
|
isEditing: boolean
|
||||||
onEdit: () => void
|
draftNombre: string
|
||||||
onStopEditing: () => void
|
draftHoras: string
|
||||||
onUpdate: (updates: Partial<Tema>) => void
|
onBeginEdit: () => void
|
||||||
|
onDraftNombreChange: (value: string) => void
|
||||||
|
onDraftHorasChange: (value: string) => void
|
||||||
|
onEditorBlurCapture: (e: FocusEvent<HTMLDivElement>) => void
|
||||||
|
onEditorKeyDownCapture: (e: KeyboardEvent<HTMLDivElement>) => void
|
||||||
|
onNombreInputRef: (el: HTMLInputElement | null) => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,9 +633,14 @@ function TemaRow({
|
|||||||
tema,
|
tema,
|
||||||
index,
|
index,
|
||||||
isEditing,
|
isEditing,
|
||||||
onEdit,
|
draftNombre,
|
||||||
onStopEditing,
|
draftHoras,
|
||||||
onUpdate,
|
onBeginEdit,
|
||||||
|
onDraftNombreChange,
|
||||||
|
onDraftHorasChange,
|
||||||
|
onEditorBlurCapture,
|
||||||
|
onEditorKeyDownCapture,
|
||||||
|
onNombreInputRef,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: TemaRowProps) {
|
}: TemaRowProps) {
|
||||||
return (
|
return (
|
||||||
@@ -391,44 +652,49 @@ 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 className="animate-in slide-in-from-left-2 flex flex-1 items-center gap-2">
|
<div
|
||||||
|
className="animate-in slide-in-from-left-2 flex flex-1 items-center gap-2"
|
||||||
|
onBlurCapture={onEditorBlurCapture}
|
||||||
|
onKeyDownCapture={onEditorKeyDownCapture}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={tema.nombre}
|
ref={onNombreInputRef}
|
||||||
onChange={(e) => onUpdate({ nombre: e.target.value })}
|
value={draftNombre}
|
||||||
|
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={tema.horasEstimadas}
|
value={draftHoras}
|
||||||
onChange={(e) =>
|
onChange={(e) => onDraftHorasChange(e.target.value)}
|
||||||
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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex-1 cursor-pointer" onClick={onEdit}>
|
<button
|
||||||
|
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={onEdit}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onBeginEdit()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Edit3 className="h-3 w-3" />
|
<Edit3 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -436,7 +702,10 @@ 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={onDelete}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDelete()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
|
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,
|
||||||
@@ -22,54 +12,34 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import type {
|
import { Button } from '@/components/ui/button'
|
||||||
DocumentoMateria,
|
import { Card } from '@/components/ui/card'
|
||||||
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 {
|
||||||
documento: DocumentoMateria | null
|
pdfUrl: string | null
|
||||||
materia: Materia
|
isLoading: boolean
|
||||||
estructura: MateriaStructure
|
onDownload: () => void
|
||||||
datosGenerales: Record<string, any>
|
|
||||||
onRegenerate: () => void
|
onRegenerate: () => void
|
||||||
isRegenerating: boolean
|
isRegenerating: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentoSEPTab({
|
export function DocumentoSEPTab({
|
||||||
documento,
|
pdfUrl,
|
||||||
materia,
|
isLoading,
|
||||||
estructura,
|
onDownload,
|
||||||
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">
|
||||||
@@ -77,28 +47,24 @@ 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 para la SEP
|
Previsualización del documento oficial generado
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{documento?.estado === 'listo' && (
|
{pdfUrl && !isLoading && (
|
||||||
<Button
|
<Button variant="outline" onClick={onDownload}>
|
||||||
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 || !isComplete}>
|
<Button disabled={isRegenerating}>
|
||||||
{isRegenerating ? (
|
{isRegenerating ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
@@ -107,15 +73,16 @@ 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 creará una nueva versión del documento con los datos
|
Se generará una nueva versión del documento con la información
|
||||||
actuales de la materia. La versión anterior quedará en el
|
actual.
|
||||||
historial.
|
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
|
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleRegenerate}>
|
<AlertDialogAction onClick={handleRegenerate}>
|
||||||
@@ -127,307 +94,24 @@ export function DocumentoSEPTab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
{/* PDF Preview */}
|
||||||
{/* Document preview */}
|
<Card className="h-[800px] overflow-hidden">
|
||||||
<div className="lg:col-span-2">
|
{isLoading ? (
|
||||||
<Card className="card-elevated h-[700px] overflow-hidden">
|
|
||||||
{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="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<Loader2 className="h-10 w-10 animate-spin" />
|
||||||
<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
|
||||||
<div className="flex h-full items-center justify-center">
|
src={`${pdfUrl}#toolbar=0`}
|
||||||
<div className="max-w-sm text-center">
|
className="h-full w-full border-none"
|
||||||
<FileText className="text-muted-foreground/50 mx-auto mb-4 h-12 w-12" />
|
title="Documento SEP"
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info sidebar */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Status */}
|
|
||||||
<Card className="card-elevated">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">
|
|
||||||
Estado del documento
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{documento && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-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
|
<div className="text-muted-foreground flex h-full items-center justify-center">
|
||||||
className={cn(
|
No se pudo cargar el documento.
|
||||||
'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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</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>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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 {
|
||||||
@@ -53,7 +54,10 @@ const tipoConfig: Record<string, { label: string; icon: any; color: string }> =
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistorialTab({ asignaturaId }) {
|
export function HistorialTab() {
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRouterState } from '@tanstack/react-router'
|
import { useParams, useRouterState } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Send,
|
Send,
|
||||||
@@ -13,16 +13,17 @@ 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, CampoEstructura } from '@/types/materia'
|
import type { IAMessage, IASugerencia } from '@/types/asignatura'
|
||||||
|
|
||||||
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 materia
|
// Tipos importados de tu archivo de asignatura
|
||||||
|
|
||||||
const PRESETS = [
|
const PRESETS = [
|
||||||
{
|
{
|
||||||
@@ -35,7 +36,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 materia...',
|
prompt: 'Genera un desglose de temas para esta asignatura...',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actividades',
|
id: 'actividades',
|
||||||
@@ -57,25 +58,27 @@ interface SelectedField {
|
|||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IAMateriaTabProps {
|
interface IAAsignaturaTabProps {
|
||||||
campos: Array<CampoEstructura>
|
asignatura: Record<string, any>
|
||||||
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 IAMateriaTab({
|
export function IAAsignaturaTab({
|
||||||
campos,
|
|
||||||
datosGenerales,
|
|
||||||
messages,
|
messages,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAcceptSuggestion,
|
onAcceptSuggestion,
|
||||||
onRejectSuggestion,
|
onRejectSuggestion,
|
||||||
}: IAMateriaTabProps) {
|
}: IAAsignaturaTabProps) {
|
||||||
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>>([])
|
||||||
@@ -83,29 +86,29 @@ export function IAMateriaTab({
|
|||||||
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 materia para el menú
|
// 1. Transformar datos de la asignatura para el menú
|
||||||
const availableFields = useMemo(() => {
|
const availableFields = useMemo(() => {
|
||||||
// Extraemos las claves directamente del objeto datosGenerales
|
if (!datosGenerales?.datos) return []
|
||||||
// ["nombre", "descripcion", "perfil_de_egreso", "fines_de_aprendizaje_o_formacion"]
|
|
||||||
if (!datosGenerales.datos) return []
|
const estructuraProps =
|
||||||
return Object.keys(datosGenerales.datos).map((key) => {
|
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
|
||||||
// Buscamos si existe un nombre amigable en la estructura de campos
|
|
||||||
const estructuraCampo = campos.find((c) => c.id === key)
|
return Object.keys(datosGenerales.datos).map((key) => {
|
||||||
|
const estructuraCampo = estructuraProps[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?.nombre ||
|
estructuraCampo?.title ||
|
||||||
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[key] || ''),
|
value: String(datosGenerales.datos[key] || ''),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [campos, datosGenerales])
|
}, [datosGenerales])
|
||||||
|
|
||||||
// 2. Manejar el estado inicial si viene de "Datos de Materia" (Prefill)
|
// 2. Manejar el estado inicial si viene de "Datos de Asignatura" (Prefill)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const state = routerState.location.state as any
|
const state = routerState.location.state as any
|
||||||
@@ -214,7 +217,7 @@ export function IAMateriaTab({
|
|||||||
<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`}
|
||||||
@@ -244,7 +247,7 @@ export function IAMateriaTab({
|
|||||||
{msg.content}
|
{msg.content}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Renderizado de Sugerencias (Homologado con lógica de Materia) */}
|
{/* Renderizado de Sugerencias (Homologado con lógica de Asignatura) */}
|
||||||
{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">
|
||||||
@@ -302,7 +305,7 @@ export function IAMateriaTab({
|
|||||||
{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 materia
|
Seleccionar campo de asignatura
|
||||||
</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) => (
|
||||||
@@ -1,706 +0,0 @@
|
|||||||
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. Ejemplo: {placeholder}
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
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} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
459
src/components/asignaturas/wizard/PasoDetallesPanel.tsx
Normal file
459
src/components/asignaturas/wizard/PasoDetallesPanel.tsx
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import * as Icons from 'lucide-react'
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
import type {
|
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||||
ModoCreacion,
|
|
||||||
NewSubjectWizardState,
|
|
||||||
SubModoClonado,
|
|
||||||
} from '@/features/asignaturas/nueva/types'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -21,19 +17,33 @@ export function PasoMetodoCardGroup({
|
|||||||
wizard: NewSubjectWizardState
|
wizard: NewSubjectWizardState
|
||||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
}) {
|
}) {
|
||||||
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
|
const isSelected = (modo: NewSubjectWizardState['tipoOrigen']) =>
|
||||||
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
|
wizard.tipoOrigen === modo
|
||||||
|
const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => {
|
||||||
|
const key = e.key
|
||||||
|
if (
|
||||||
|
key === 'Enter' ||
|
||||||
|
key === ' ' ||
|
||||||
|
key === 'Spacebar' ||
|
||||||
|
key === 'Space'
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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((w) => ({
|
onChange(
|
||||||
|
(w): NewSubjectWizardState => ({
|
||||||
...w,
|
...w,
|
||||||
modoCreacion: 'MANUAL',
|
tipoOrigen: 'MANUAL',
|
||||||
subModoClonado: undefined,
|
}),
|
||||||
}))
|
)
|
||||||
}
|
}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -51,11 +61,12 @@ export function PasoMetodoCardGroup({
|
|||||||
<Card
|
<Card
|
||||||
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
|
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onChange((w) => ({
|
onChange(
|
||||||
|
(w): NewSubjectWizardState => ({
|
||||||
...w,
|
...w,
|
||||||
modoCreacion: 'IA',
|
tipoOrigen: 'IA',
|
||||||
subModoClonado: undefined,
|
}),
|
||||||
}))
|
)
|
||||||
}
|
}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -66,11 +77,94 @@ 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={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
|
onClick={() =>
|
||||||
|
onChange(
|
||||||
|
(w): NewSubjectWizardState => ({ ...w, tipoOrigen: 'CLONADO' }),
|
||||||
|
)
|
||||||
|
}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
@@ -80,18 +174,34 @@ export function PasoMetodoCardGroup({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>De otra asignatura o archivo Word.</CardDescription>
|
<CardDescription>De otra asignatura o archivo Word.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{wizard.modoCreacion === 'CLONADO' && (
|
{(wizard.tipoOrigen === 'CLONADO' ||
|
||||||
<CardContent>
|
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
||||||
<div className="flex flex-col gap-3">
|
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
|
||||||
|
<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((w) => ({ ...w, subModoClonado: 'INTERNO' }))
|
onChange(
|
||||||
|
(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 ${
|
||||||
isSubSelected('INTERNO')
|
isSelected('CLONADO_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'
|
||||||
}`}
|
}`}
|
||||||
@@ -110,10 +220,25 @@ export function PasoMetodoCardGroup({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' }))
|
onChange(
|
||||||
|
(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 ${
|
||||||
isSubSelected('TRADICIONAL')
|
isSelected('CLONADO_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'
|
||||||
}`}
|
}`}
|
||||||
@@ -121,10 +246,7 @@ 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">
|
<span className="text-xs opacity-70">Subir Word existente</span>
|
||||||
Subir Word existente
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -9,9 +9,45 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { ESTRUCTURAS_SEP } from '@/features/asignaturas/nueva/catalogs'
|
import { usePlan, usePlanLineas, useSubjectEstructuras } from '@/data'
|
||||||
|
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>
|
||||||
@@ -20,54 +56,238 @@ 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 className="grid gap-4 text-sm">
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid gap-4 text-sm">
|
||||||
|
<div className="grid gap-2">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Nombre:</span>
|
<span className="text-muted-foreground">Plan de estudios: </span>
|
||||||
<div className="font-medium">{wizard.datosBasicos.nombre}</div>
|
<span className="font-medium">
|
||||||
|
{plan?.nombre || wizard.plan_estudio_id || '—'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{plan?.carreras?.nombre ? (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Tipo:</span>
|
<span className="text-muted-foreground">Carrera: </span>
|
||||||
<div className="font-medium">{wizard.datosBasicos.tipo}</div>
|
<span className="font-medium">{plan.carreras.nombre}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
) : null}
|
||||||
<span className="text-muted-foreground">Créditos:</span>
|
|
||||||
<div className="font-medium">{wizard.datosBasicos.creditos}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Estructura:</span>
|
<div className="bg-muted rounded-md p-3">
|
||||||
<div className="font-medium">
|
<span className="text-muted-foreground">Tipo de origen: </span>
|
||||||
{
|
<span className="inline-flex items-center gap-2 font-medium">
|
||||||
ESTRUCTURAS_SEP.find(
|
{wizard.tipoOrigen === 'MANUAL' && (
|
||||||
(e) => e.id === wizard.datosBasicos.estructuraId,
|
<Icons.Pencil className="h-4 w-4" />
|
||||||
)?.label
|
)}
|
||||||
}
|
{(wizard.tipoOrigen === 'IA' ||
|
||||||
|
wizard.tipoOrigen === 'IA_SIMPLE' ||
|
||||||
|
wizard.tipoOrigen === 'IA_MULTIPLE') && (
|
||||||
|
<Icons.Sparkles className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{(wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
||||||
|
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
|
||||||
|
<Icons.Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{modoLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{wizard.tipoOrigen === 'IA_MULTIPLE' ? (
|
||||||
|
<>
|
||||||
|
<div className="border-border/60 bg-muted/30 grid gap-3 rounded-xl border p-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="text-foreground text-base font-semibold">
|
||||||
|
Configuración
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Se crearán {materiasSeleccionadas.length} asignatura(s) a
|
||||||
|
partir de tus selecciones.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-background/40 border-border/60 rounded-lg border p-3">
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Estructura
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground mt-1 text-sm font-medium">
|
||||||
|
{estructuraNombre}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-muted rounded-md p-3">
|
<div className="border-border/60 bg-muted/30 grid gap-3 rounded-xl border p-4">
|
||||||
<span className="text-muted-foreground">Modo de creación:</span>
|
<div className="flex items-end justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 font-medium">
|
<div className="text-foreground text-base font-semibold">
|
||||||
{wizard.modoCreacion === 'MANUAL' && (
|
Materias seleccionadas
|
||||||
<>
|
</div>
|
||||||
<Icons.Pencil className="h-4 w-4" /> Manual (Vacía)
|
<div className="text-muted-foreground text-xs">
|
||||||
</>
|
{materiasSeleccionadas.length} en total
|
||||||
)}
|
</div>
|
||||||
{wizard.modoCreacion === 'IA' && (
|
</div>
|
||||||
<>
|
|
||||||
<Icons.Sparkles className="h-4 w-4" /> Generada con IA
|
{materiasSeleccionadas.length === 0 ? (
|
||||||
</>
|
<div className="text-muted-foreground text-sm">
|
||||||
)}
|
No hay materias seleccionadas.
|
||||||
{wizard.modoCreacion === 'CLONADO' && (
|
</div>
|
||||||
<>
|
) : (
|
||||||
<Icons.Copy className="h-4 w-4" /> Clonada
|
<div className="grid gap-3">
|
||||||
{wizard.subModoClonado === 'INTERNO'
|
{materiasSeleccionadas.map((m) => {
|
||||||
? ' (Sistema)'
|
const lineaNombre = m.linea_plan_id
|
||||||
: ' (Archivo)'}
|
? (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>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,66 +1,477 @@
|
|||||||
|
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,
|
||||||
canContinueDesdeMetodo,
|
setWizard,
|
||||||
canContinueDesdeBasicos,
|
errorMessage,
|
||||||
canContinueDesdeConfig,
|
onPrev,
|
||||||
onCreate,
|
onNext,
|
||||||
|
disablePrev,
|
||||||
|
disableNext,
|
||||||
|
disableCreate,
|
||||||
|
isLastStep,
|
||||||
}: {
|
}: {
|
||||||
Wizard: any
|
|
||||||
methods: any
|
|
||||||
wizard: NewSubjectWizardState
|
wizard: NewSubjectWizardState
|
||||||
canContinueDesdeMetodo: boolean
|
setWizard: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
canContinueDesdeBasicos: boolean
|
errorMessage?: string | null
|
||||||
canContinueDesdeConfig: boolean
|
onPrev: () => void
|
||||||
onCreate: () => void
|
onNext: () => void
|
||||||
|
disablePrev: boolean
|
||||||
|
disableNext: boolean
|
||||||
|
disableCreate: boolean
|
||||||
|
isLastStep: boolean
|
||||||
}) {
|
}) {
|
||||||
const idx = Wizard.utils.getIndex(methods.current.id)
|
const navigate = useNavigate()
|
||||||
const isLast = idx >= Wizard.steps.length - 1
|
const qc = useQueryClient()
|
||||||
|
const generateSubjectAI = useGenerateSubjectAI()
|
||||||
|
const createSubjectManual = useCreateSubjectManual()
|
||||||
|
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
||||||
|
const cancelledRef = useRef(false)
|
||||||
|
const realtimeChannelRef = useRef<RealtimeChannel | null>(null)
|
||||||
|
const watchSubjectIdRef = useRef<string | null>(null)
|
||||||
|
const watchTimeoutRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cancelledRef.current = false
|
||||||
|
return () => {
|
||||||
|
cancelledRef.current = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stopSubjectWatch = useCallback(() => {
|
||||||
|
if (watchTimeoutRef.current) {
|
||||||
|
window.clearTimeout(watchTimeoutRef.current)
|
||||||
|
watchTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
watchSubjectIdRef.current = null
|
||||||
|
|
||||||
|
const ch = realtimeChannelRef.current
|
||||||
|
if (ch) {
|
||||||
|
realtimeChannelRef.current = null
|
||||||
|
try {
|
||||||
|
supabaseBrowser().removeChannel(ch)
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopSubjectWatch()
|
||||||
|
}
|
||||||
|
}, [stopSubjectWatch])
|
||||||
|
|
||||||
|
const handleSubjectReady = (args: {
|
||||||
|
id: string
|
||||||
|
plan_estudio_id: string
|
||||||
|
estado?: unknown
|
||||||
|
}) => {
|
||||||
|
if (cancelledRef.current) return
|
||||||
|
|
||||||
|
const estado = String(args.estado ?? '').toLowerCase()
|
||||||
|
if (estado === 'generando') return
|
||||||
|
|
||||||
|
stopSubjectWatch()
|
||||||
|
setIsSpinningIA(false)
|
||||||
|
setWizard((w) => ({ ...w, isLoading: false }))
|
||||||
|
|
||||||
|
navigate({
|
||||||
|
to: `/planes/${args.plan_estudio_id}/asignaturas/${args.id}`,
|
||||||
|
state: { showConfetti: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const beginSubjectWatch = (args: { subjectId: string; planId: string }) => {
|
||||||
|
stopSubjectWatch()
|
||||||
|
|
||||||
|
watchSubjectIdRef.current = args.subjectId
|
||||||
|
|
||||||
|
// Timeout de seguridad (mismo límite que teníamos con polling)
|
||||||
|
watchTimeoutRef.current = window.setTimeout(
|
||||||
|
() => {
|
||||||
|
if (cancelledRef.current) return
|
||||||
|
if (watchSubjectIdRef.current !== args.subjectId) return
|
||||||
|
|
||||||
|
stopSubjectWatch()
|
||||||
|
setIsSpinningIA(false)
|
||||||
|
setWizard((w) => ({
|
||||||
|
...w,
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage:
|
||||||
|
'La generación está tardando demasiado. Intenta de nuevo en unos minutos.',
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
6 * 60 * 1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const channel = supabase.channel(`asignaturas-status-${args.subjectId}`)
|
||||||
|
realtimeChannelRef.current = channel
|
||||||
|
|
||||||
|
channel.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: 'UPDATE',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'asignaturas',
|
||||||
|
filter: `id=eq.${args.subjectId}`,
|
||||||
|
},
|
||||||
|
(payload) => {
|
||||||
|
if (cancelledRef.current) return
|
||||||
|
|
||||||
|
const next: any = (payload as any)?.new
|
||||||
|
if (!next?.id || !next?.plan_estudio_id) return
|
||||||
|
handleSubjectReady({
|
||||||
|
id: String(next.id),
|
||||||
|
plan_estudio_id: String(next.plan_estudio_id),
|
||||||
|
estado: next.estado,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
channel.subscribe((status) => {
|
||||||
|
if (cancelledRef.current) return
|
||||||
|
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
|
||||||
|
stopSubjectWatch()
|
||||||
|
setIsSpinningIA(false)
|
||||||
|
setWizard((w) => ({
|
||||||
|
...w,
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage:
|
||||||
|
'No se pudo suscribir al estado de la asignatura. Intenta de nuevo.',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadAiAttachments = async (args: {
|
||||||
|
planId: string
|
||||||
|
files: Array<{ file: File }>
|
||||||
|
}): Promise<Array<string>> => {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
if (!args.files.length) return []
|
||||||
|
|
||||||
|
const runId = crypto.randomUUID()
|
||||||
|
const basePath = `planes/${args.planId}/asignaturas/ai/${runId}`
|
||||||
|
|
||||||
|
const keys: Array<string> = []
|
||||||
|
for (const f of args.files) {
|
||||||
|
const safeName = (f.file.name || 'archivo').replace(/[\\/]+/g, '_')
|
||||||
|
const key = `${basePath}/${crypto.randomUUID()}-${safeName}`
|
||||||
|
|
||||||
|
const { error } = await supabase.storage
|
||||||
|
.from('ai-storage')
|
||||||
|
.upload(key, f.file, {
|
||||||
|
contentType: f.file.type || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
keys.push(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setWizard((w) => ({
|
||||||
|
...w,
|
||||||
|
isLoading: true,
|
||||||
|
errorMessage: null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
let startedWaiting = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
||||||
|
if (!wizard.plan_estudio_id) {
|
||||||
|
throw new Error('Plan de estudio inválido.')
|
||||||
|
}
|
||||||
|
if (!wizard.datosBasicos.estructuraId) {
|
||||||
|
throw new Error('Estructura inválida.')
|
||||||
|
}
|
||||||
|
if (!wizard.datosBasicos.nombre.trim()) {
|
||||||
|
throw new Error('Nombre inválido.')
|
||||||
|
}
|
||||||
|
if (wizard.datosBasicos.creditos == null) {
|
||||||
|
throw new Error('Créditos inválidos.')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${new Date().toISOString()} - Insertando asignatura IA`)
|
||||||
|
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const placeholder: TablesInsert<'asignaturas'> = {
|
||||||
|
plan_estudio_id: wizard.plan_estudio_id,
|
||||||
|
estructura_id: wizard.datosBasicos.estructuraId,
|
||||||
|
nombre: wizard.datosBasicos.nombre,
|
||||||
|
codigo: wizard.datosBasicos.codigo ?? null,
|
||||||
|
tipo: wizard.datosBasicos.tipo ?? undefined,
|
||||||
|
creditos: wizard.datosBasicos.creditos,
|
||||||
|
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
|
||||||
|
horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
|
||||||
|
estado: 'generando',
|
||||||
|
tipo_origen: 'IA',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: inserted, error: insertError } = await supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.insert(placeholder)
|
||||||
|
.select('id,plan_estudio_id')
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (insertError) throw new Error(insertError.message)
|
||||||
|
const subjectId = inserted.id
|
||||||
|
|
||||||
|
setIsSpinningIA(true)
|
||||||
|
|
||||||
|
// Inicia watch realtime antes de disparar la Edge para no perder updates.
|
||||||
|
startedWaiting = true
|
||||||
|
beginSubjectWatch({ subjectId, planId: wizard.plan_estudio_id })
|
||||||
|
|
||||||
|
const archivosAdjuntos = await uploadAiAttachments({
|
||||||
|
planId: wizard.plan_estudio_id,
|
||||||
|
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
|
||||||
|
file: x.file,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload: AISubjectUnifiedInput = {
|
||||||
|
datosUpdate: {
|
||||||
|
id: subjectId,
|
||||||
|
plan_estudio_id: wizard.plan_estudio_id,
|
||||||
|
estructura_id: wizard.datosBasicos.estructuraId,
|
||||||
|
nombre: wizard.datosBasicos.nombre,
|
||||||
|
codigo: wizard.datosBasicos.codigo ?? null,
|
||||||
|
tipo: wizard.datosBasicos.tipo ?? null,
|
||||||
|
creditos: wizard.datosBasicos.creditos,
|
||||||
|
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
|
||||||
|
horas_independientes:
|
||||||
|
wizard.datosBasicos.horasIndependientes ?? null,
|
||||||
|
},
|
||||||
|
iaConfig: {
|
||||||
|
descripcionEnfoqueAcademico:
|
||||||
|
wizard.iaConfig?.descripcionEnfoqueAcademico ?? undefined,
|
||||||
|
instruccionesAdicionalesIA:
|
||||||
|
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
|
||||||
|
archivosAdjuntos,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${new Date().toISOString()} - Disparando Edge IA asignatura (unified)`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await generateSubjectAI.mutateAsync(payload as any)
|
||||||
|
|
||||||
|
// Fallback: una lectura puntual por si el UPDATE llegó antes de suscribir.
|
||||||
|
const latest = await subjects_get_maybe(subjectId)
|
||||||
|
if (latest) {
|
||||||
|
handleSubjectReady({
|
||||||
|
id: latest.id as any,
|
||||||
|
plan_estudio_id: latest.plan_estudio_id as any,
|
||||||
|
estado: (latest as any).estado,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
|
||||||
|
const selected = wizard.sugerencias.filter((s) => s.selected)
|
||||||
|
|
||||||
|
if (selected.length === 0) {
|
||||||
|
throw new Error('Selecciona al menos una sugerencia.')
|
||||||
|
}
|
||||||
|
if (!wizard.plan_estudio_id) {
|
||||||
|
throw new Error('Plan de estudio inválido.')
|
||||||
|
}
|
||||||
|
if (!wizard.estructuraId) {
|
||||||
|
throw new Error('Selecciona una estructura para continuar.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
setIsSpinningIA(true)
|
||||||
|
|
||||||
|
const archivosAdjuntos = await uploadAiAttachments({
|
||||||
|
planId: wizard.plan_estudio_id,
|
||||||
|
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
|
||||||
|
file: x.file,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
const placeholders: Array<TablesInsert<'asignaturas'>> = selected.map(
|
||||||
|
(s): TablesInsert<'asignaturas'> => ({
|
||||||
|
plan_estudio_id: wizard.plan_estudio_id,
|
||||||
|
estructura_id: wizard.estructuraId,
|
||||||
|
estado: 'generando',
|
||||||
|
nombre: s.nombre,
|
||||||
|
codigo: s.codigo ?? null,
|
||||||
|
tipo: s.tipo ?? undefined,
|
||||||
|
creditos: s.creditos ?? 0,
|
||||||
|
horas_academicas: s.horasAcademicas ?? null,
|
||||||
|
horas_independientes: s.horasIndependientes ?? null,
|
||||||
|
linea_plan_id: s.linea_plan_id ?? null,
|
||||||
|
numero_ciclo: s.numero_ciclo ?? null,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: inserted, error: insertError } = await supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.insert(placeholders)
|
||||||
|
.select('id')
|
||||||
|
|
||||||
|
if (insertError) {
|
||||||
|
throw new Error(insertError.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertedIds = inserted.map((r) => r.id)
|
||||||
|
if (insertedIds.length !== selected.length) {
|
||||||
|
throw new Error('No se pudieron crear todas las asignaturas.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disparar generación en paralelo (no bloquear navegación)
|
||||||
|
insertedIds.forEach((id, idx) => {
|
||||||
|
const s = selected[idx]
|
||||||
|
const creditosForEdge =
|
||||||
|
typeof s.creditos === 'number' && s.creditos > 0
|
||||||
|
? s.creditos
|
||||||
|
: undefined
|
||||||
|
const payload: AISubjectUnifiedInput = {
|
||||||
|
datosUpdate: {
|
||||||
|
id,
|
||||||
|
plan_estudio_id: wizard.plan_estudio_id,
|
||||||
|
estructura_id: wizard.estructuraId ?? undefined,
|
||||||
|
nombre: s.nombre,
|
||||||
|
codigo: s.codigo ?? null,
|
||||||
|
tipo: s.tipo ?? null,
|
||||||
|
creditos: creditosForEdge,
|
||||||
|
horas_academicas: s.horasAcademicas ?? null,
|
||||||
|
horas_independientes: s.horasIndependientes ?? null,
|
||||||
|
numero_ciclo: s.numero_ciclo ?? null,
|
||||||
|
linea_plan_id: s.linea_plan_id ?? null,
|
||||||
|
},
|
||||||
|
iaConfig: {
|
||||||
|
descripcionEnfoqueAcademico: s.descripcion,
|
||||||
|
instruccionesAdicionalesIA:
|
||||||
|
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
|
||||||
|
archivosAdjuntos,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
void generateSubjectAI.mutateAsync(payload as any).catch((e) => {
|
||||||
|
console.error('Error generando asignatura IA (multiple):', e)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Invalidar la query del listado del plan (una vez) para que la lista
|
||||||
|
// muestre el estado actualizado y recargue cuando lleguen updates.
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: qk.planAsignaturas(wizard.plan_estudio_id),
|
||||||
|
})
|
||||||
|
|
||||||
|
navigate({
|
||||||
|
to: `/planes/${wizard.plan_estudio_id}/asignaturas`,
|
||||||
|
resetScroll: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
setIsSpinningIA(false)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wizard.tipoOrigen === 'MANUAL') {
|
||||||
|
if (!wizard.plan_estudio_id) {
|
||||||
|
throw new Error('Plan de estudio inválido.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const asignatura = await createSubjectManual.mutateAsync({
|
||||||
|
plan_estudio_id: wizard.plan_estudio_id,
|
||||||
|
estructura_id: wizard.datosBasicos.estructuraId!,
|
||||||
|
nombre: wizard.datosBasicos.nombre,
|
||||||
|
codigo: wizard.datosBasicos.codigo ?? null,
|
||||||
|
tipo: wizard.datosBasicos.tipo ?? undefined,
|
||||||
|
creditos: wizard.datosBasicos.creditos ?? 0,
|
||||||
|
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
|
||||||
|
horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
|
||||||
|
linea_plan_id: null,
|
||||||
|
numero_ciclo: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
navigate({
|
||||||
|
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
|
||||||
|
state: { showConfetti: true },
|
||||||
|
resetScroll: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setIsSpinningIA(false)
|
||||||
|
stopSubjectWatch()
|
||||||
|
setWizard((w) => ({
|
||||||
|
...w,
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: err?.message ?? 'Error creando la asignatura',
|
||||||
|
}))
|
||||||
|
} finally {
|
||||||
|
if (!startedWaiting) {
|
||||||
|
setIsSpinningIA(false)
|
||||||
|
setWizard((w) => ({ ...w, isLoading: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-none border-t bg-white p-6">
|
<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">
|
||||||
{wizard.errorMessage}
|
{errorMessage ?? wizard.errorMessage}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="mx-2 flex w-5 items-center justify-center">
|
||||||
<Button
|
<Loader2
|
||||||
variant="secondary"
|
className={
|
||||||
onClick={() => methods.prev()}
|
wizard.tipoOrigen === 'IA_SIMPLE' && isSpinningIA
|
||||||
disabled={idx === 0 || wizard.isLoading}
|
? 'text-muted-foreground h-6 w-6 animate-spin'
|
||||||
>
|
: 'h-6 w-6 opacity-0'
|
||||||
Anterior
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{!isLast ? (
|
|
||||||
<Button
|
|
||||||
onClick={() => methods.next()}
|
|
||||||
disabled={
|
|
||||||
wizard.isLoading ||
|
|
||||||
(idx === 0 && !canContinueDesdeMetodo) ||
|
|
||||||
(idx === 1 && !canContinueDesdeBasicos) ||
|
|
||||||
(idx === 2 && !canContinueDesdeConfig)
|
|
||||||
}
|
}
|
||||||
>
|
aria-hidden={!(wizard.tipoOrigen === 'IA_SIMPLE' && isSpinningIA)}
|
||||||
Siguiente
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLastStep ? (
|
||||||
|
<Button onClick={handleCreate} disabled={disableCreate}>
|
||||||
|
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={onCreate} disabled={wizard.isLoading}>
|
<Button onClick={onNext} disabled={disableNext}>
|
||||||
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
|
Siguiente
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,44 @@
|
|||||||
|
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 () => {
|
||||||
/* await supabase.auth.signInWithPassword({
|
setIsLoading(true)
|
||||||
|
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 (
|
||||||
@@ -34,7 +60,11 @@ export function ExternalLoginForm() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={setPassword}
|
||||||
/>
|
/>
|
||||||
<SubmitButton />
|
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||||
|
<SubmitButton
|
||||||
|
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,45 @@
|
|||||||
|
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 () => {
|
||||||
/* await supabase.auth.signInWithPassword({
|
setIsLoading(true)
|
||||||
email: `${clave}@ulsa.mx`,
|
setError(null)
|
||||||
|
|
||||||
|
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 (
|
||||||
@@ -30,7 +57,11 @@ export function InternalLoginForm() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={setPassword}
|
||||||
/>
|
/>
|
||||||
<SubmitButton />
|
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||||
|
<SubmitButton
|
||||||
|
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
122
src/components/planes/detalle/Ia/ImprovementCard.tsx
Normal file
122
src/components/planes/detalle/Ia/ImprovementCard.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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.facultadId
|
const facId = wizard.datosBasicos.facultad.id
|
||||||
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,6 +50,7 @@ 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 => ({
|
||||||
@@ -68,15 +69,20 @@ 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.facultadId}
|
value={wizard.datosBasicos.facultad.id}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
onChange(
|
onChange(
|
||||||
(w): NewPlanWizardState => ({
|
(w): NewPlanWizardState => ({
|
||||||
...w,
|
...w,
|
||||||
datosBasicos: {
|
datosBasicos: {
|
||||||
...w.datosBasicos,
|
...w.datosBasicos,
|
||||||
facultadId: value,
|
facultad: {
|
||||||
carreraId: '',
|
id: value,
|
||||||
|
nombre:
|
||||||
|
facultadesList.find((f) => f.id === value)?.nombre ||
|
||||||
|
'',
|
||||||
|
},
|
||||||
|
carrera: { id: '', nombre: '' },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -86,7 +92,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.facultadId
|
!wizard.datosBasicos.facultad.id
|
||||||
? '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)
|
||||||
)}
|
)}
|
||||||
@@ -106,22 +112,30 @@ 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.carreraId}
|
value={wizard.datosBasicos.carrera.id}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
onChange(
|
onChange(
|
||||||
(w): NewPlanWizardState => ({
|
(w): NewPlanWizardState => ({
|
||||||
...w,
|
...w,
|
||||||
datosBasicos: { ...w.datosBasicos, carreraId: value },
|
datosBasicos: {
|
||||||
|
...w.datosBasicos,
|
||||||
|
carrera: {
|
||||||
|
id: value,
|
||||||
|
nombre:
|
||||||
|
filteredCarreras.find((c) => c.id === value)?.nombre ||
|
||||||
|
'',
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
disabled={!wizard.datosBasicos.facultadId}
|
disabled={!wizard.datosBasicos.facultad.id}
|
||||||
>
|
>
|
||||||
<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.carreraId
|
!wizard.datosBasicos.carrera.id
|
||||||
? '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)
|
||||||
)}
|
)}
|
||||||
@@ -215,6 +229,7 @@ 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]*"
|
||||||
@@ -233,12 +248,13 @@ 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 undefined
|
if (raw === '') return null
|
||||||
const asNumber = Number(raw)
|
const asNumber = Number(raw)
|
||||||
if (Number.isNaN(asNumber)) return undefined
|
if (Number.isNaN(asNumber)) return null
|
||||||
// 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))
|
||||||
return n >= 1 ? n : 1
|
const capped = Math.min(n >= 1 ? n : 1, 99)
|
||||||
|
return capped
|
||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ 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="Describe el enfoque del programa…"
|
placeholder="Define el perfil de egreso, visión pedagógica y sector profesional. Ej.: Programa semestral orientado a la Industria 4.0, con enfoque en competencias directivas y emprendimiento tecnológico..."
|
||||||
|
maxLength={7000}
|
||||||
value={wizard.iaConfig?.descripcionEnfoqueAcademico || ''}
|
value={wizard.iaConfig?.descripcionEnfoqueAcademico || ''}
|
||||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||||
onChange((w) => ({
|
onChange((w) => ({
|
||||||
@@ -72,14 +73,15 @@ 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="Lineamientos institucionales, restricciones, etc."
|
placeholder="Opcional: Estándares, estructura y limitaciones. Ej.: Estructura de 9 ciclos, carga pesada en ciencias básicas, sigue normativa CACEI, incluye 15% de materias optativas..."
|
||||||
|
maxLength={7000}
|
||||||
value={wizard.iaConfig?.instruccionesAdicionalesIA || ''}
|
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,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -90,7 +92,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) => {
|
onChange((w): NewPlanWizardState => {
|
||||||
const prev = w.iaConfig?.archivosReferencia || []
|
const prev = w.iaConfig?.archivosReferencia || []
|
||||||
const next = checked
|
const next = checked
|
||||||
? [...prev, id]
|
? [...prev, id]
|
||||||
@@ -105,7 +107,7 @@ export function PasoDetallesPanel({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
onToggleRepositorio={(id, checked) =>
|
onToggleRepositorio={(id, checked) =>
|
||||||
onChange((w) => {
|
onChange((w): NewPlanWizardState => {
|
||||||
const prev = w.iaConfig?.repositoriosReferencia || []
|
const prev = w.iaConfig?.repositoriosReferencia || []
|
||||||
const next = checked
|
const next = checked
|
||||||
? [...prev, id]
|
? [...prev, id]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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 = [],
|
||||||
@@ -87,7 +88,10 @@ const ReferenciasParaIA = ({
|
|||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
onToggleArchivo?.(archivo.id, !!checked)
|
onToggleArchivo?.(archivo.id, !!checked)
|
||||||
}
|
}
|
||||||
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"
|
className={cn(
|
||||||
|
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
selectedArchivoIds.includes(archivo.id) ? '' : 'invisible',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FileText className="text-muted-foreground h-4 w-4" />
|
<FileText className="text-muted-foreground h-4 w-4" />
|
||||||
@@ -134,7 +138,12 @@ const ReferenciasParaIA = ({
|
|||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
onToggleRepositorio?.(repositorio.id, !!checked)
|
onToggleRepositorio?.(repositorio.id, !!checked)
|
||||||
}
|
}
|
||||||
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"
|
className={cn(
|
||||||
|
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
selectedRepositorioIds.includes(repositorio.id)
|
||||||
|
? ''
|
||||||
|
: 'invisible',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FolderOpen className="text-muted-foreground h-4 w-4" />
|
<FolderOpen className="text-muted-foreground h-4 w-4" />
|
||||||
|
|||||||
@@ -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.facultadId || '—'} /{' '}
|
{wizard.datosBasicos.facultad.nombre || '—'} /{' '}
|
||||||
{wizard.datosBasicos.carreraId || '—'}
|
{wizard.datosBasicos.carrera.nombre || '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
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 { supabaseBrowser } from '@/data'
|
import { plans_get_maybe } from '@/data/api/plans.api'
|
||||||
import { useCreatePlanManual, useGeneratePlanAI } from '@/data/hooks/usePlans'
|
import {
|
||||||
|
useCreatePlanManual,
|
||||||
|
useDeletePlanEstudio,
|
||||||
|
useGeneratePlanAI,
|
||||||
|
} from '@/data/hooks/usePlans'
|
||||||
|
import { supabaseBrowser } from '@/data/supabase/client'
|
||||||
|
|
||||||
export function WizardControls({
|
export function WizardControls({
|
||||||
errorMessage,
|
errorMessage,
|
||||||
@@ -32,8 +41,152 @@ export function WizardControls({
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const generatePlanAI = useGeneratePlanAI()
|
const generatePlanAI = useGeneratePlanAI()
|
||||||
const createPlanManual = useCreatePlanManual()
|
const createPlanManual = useCreatePlanManual()
|
||||||
// const supabaseClient = supabaseBrowser()
|
const deletePlan = useDeletePlanEstudio()
|
||||||
// const persistPlanFromAI = usePersistPlanFromAI()
|
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
||||||
|
const cancelledRef = useRef(false)
|
||||||
|
const realtimeChannelRef = useRef<RealtimeChannel | null>(null)
|
||||||
|
const watchPlanIdRef = useRef<string | null>(null)
|
||||||
|
const watchTimeoutRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cancelledRef.current = false
|
||||||
|
return () => {
|
||||||
|
cancelledRef.current = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stopPlanWatch = useCallback(() => {
|
||||||
|
if (watchTimeoutRef.current) {
|
||||||
|
window.clearTimeout(watchTimeoutRef.current)
|
||||||
|
watchTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
watchPlanIdRef.current = null
|
||||||
|
|
||||||
|
const ch = realtimeChannelRef.current
|
||||||
|
if (ch) {
|
||||||
|
realtimeChannelRef.current = null
|
||||||
|
try {
|
||||||
|
supabaseBrowser().removeChannel(ch)
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopPlanWatch()
|
||||||
|
}
|
||||||
|
}, [stopPlanWatch])
|
||||||
|
|
||||||
|
const checkPlanStateAndAct = useCallback(
|
||||||
|
async (planId: string) => {
|
||||||
|
if (cancelledRef.current) return
|
||||||
|
if (watchPlanIdRef.current !== planId) return
|
||||||
|
|
||||||
|
const plan = await plans_get_maybe(planId as any)
|
||||||
|
if (!plan) return
|
||||||
|
|
||||||
|
const clave = String(plan.estados_plan?.clave ?? '').toUpperCase()
|
||||||
|
|
||||||
|
if (clave.startsWith('GENERANDO')) return
|
||||||
|
|
||||||
|
if (clave.startsWith('BORRADOR')) {
|
||||||
|
stopPlanWatch()
|
||||||
|
setIsSpinningIA(false)
|
||||||
|
setWizard((w) => ({ ...w, isLoading: false }))
|
||||||
|
navigate({
|
||||||
|
to: `/planes/${plan.id}`,
|
||||||
|
state: { showConfetti: true },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clave.startsWith('FALLID')) {
|
||||||
|
stopPlanWatch()
|
||||||
|
setIsSpinningIA(false)
|
||||||
|
|
||||||
|
deletePlan
|
||||||
|
.mutateAsync(plan.id)
|
||||||
|
.catch(() => {
|
||||||
|
// Si falla el borrado, igual mostramos el error.
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setWizard((w) => ({
|
||||||
|
...w,
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: 'La generación del plan falló',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deletePlan, navigate, setWizard, stopPlanWatch],
|
||||||
|
)
|
||||||
|
|
||||||
|
const beginPlanWatch = useCallback(
|
||||||
|
(planId: string) => {
|
||||||
|
stopPlanWatch()
|
||||||
|
watchPlanIdRef.current = planId
|
||||||
|
|
||||||
|
watchTimeoutRef.current = window.setTimeout(
|
||||||
|
() => {
|
||||||
|
if (cancelledRef.current) return
|
||||||
|
if (watchPlanIdRef.current !== planId) return
|
||||||
|
|
||||||
|
stopPlanWatch()
|
||||||
|
setIsSpinningIA(false)
|
||||||
|
setWizard((w) => ({
|
||||||
|
...w,
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage:
|
||||||
|
'La generación está tardando demasiado. Intenta de nuevo en unos minutos.',
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
6 * 60 * 1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const channel = supabase.channel(`planes-status-${planId}`)
|
||||||
|
realtimeChannelRef.current = channel
|
||||||
|
|
||||||
|
channel.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: '*',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'planes_estudio',
|
||||||
|
filter: `id=eq.${planId}`,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
void checkPlanStateAndAct(planId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
channel.subscribe((status) => {
|
||||||
|
const st = status as
|
||||||
|
| 'SUBSCRIBED'
|
||||||
|
| 'TIMED_OUT'
|
||||||
|
| 'CLOSED'
|
||||||
|
| 'CHANNEL_ERROR'
|
||||||
|
if (cancelledRef.current) return
|
||||||
|
if (st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
|
||||||
|
stopPlanWatch()
|
||||||
|
setIsSpinningIA(false)
|
||||||
|
setWizard((w) => ({
|
||||||
|
...w,
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage:
|
||||||
|
'No se pudo suscribir al estado del plan. Intenta de nuevo.',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fallback inmediato por si el plan ya cambió antes de suscribir.
|
||||||
|
void checkPlanStateAndAct(planId)
|
||||||
|
},
|
||||||
|
[checkPlanStateAndAct, setWizard, stopPlanWatch],
|
||||||
|
)
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
// Start loading
|
// Start loading
|
||||||
@@ -54,11 +207,11 @@ export function WizardControls({
|
|||||||
? wizard.datosBasicos.numCiclos
|
? wizard.datosBasicos.numCiclos
|
||||||
: 1
|
: 1
|
||||||
|
|
||||||
const aiInput = {
|
const aiInput: AIGeneratePlanInput = {
|
||||||
datosBasicos: {
|
datosBasicos: {
|
||||||
nombrePlan: wizard.datosBasicos.nombrePlan,
|
nombrePlan: wizard.datosBasicos.nombrePlan,
|
||||||
carreraId: wizard.datosBasicos.carreraId,
|
carreraId: wizard.datosBasicos.carrera.id,
|
||||||
facultadId: wizard.datosBasicos.facultadId || undefined,
|
facultadId: wizard.datosBasicos.facultad.id,
|
||||||
nivel: wizard.datosBasicos.nivel as string,
|
nivel: wizard.datosBasicos.nivel as string,
|
||||||
tipoCiclo: tipoCicloSafe,
|
tipoCiclo: tipoCicloSafe,
|
||||||
numCiclos: numCiclosSafe,
|
numCiclos: numCiclosSafe,
|
||||||
@@ -77,20 +230,24 @@ export function WizardControls({
|
|||||||
|
|
||||||
console.log(`${new Date().toISOString()} - Enviando a generar plan IA`)
|
console.log(`${new Date().toISOString()} - Enviando a generar plan IA`)
|
||||||
|
|
||||||
const data = await generatePlanAI.mutateAsync(aiInput as any)
|
setIsSpinningIA(true)
|
||||||
console.log(`${new Date().toISOString()} - Plan IA generado`, data)
|
const resp: any = await generatePlanAI.mutateAsync(aiInput as any)
|
||||||
|
const planId = resp?.plan?.id ?? resp?.id
|
||||||
|
console.log(`${new Date().toISOString()} - Plan IA generado`, resp)
|
||||||
|
|
||||||
navigate({
|
if (!planId) {
|
||||||
to: `/planes/${data.plan.id}/datos`,
|
throw new Error('No se pudo obtener el id del plan generado por IA')
|
||||||
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.carreraId,
|
carreraId: wizard.datosBasicos.carrera.id,
|
||||||
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,
|
||||||
@@ -101,35 +258,47 @@ export function WizardControls({
|
|||||||
|
|
||||||
// Navegar al nuevo plan
|
// Navegar al nuevo plan
|
||||||
navigate({
|
navigate({
|
||||||
to: `/planes/${plan.id}/datos`,
|
to: `/planes/${plan.id}`,
|
||||||
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 {
|
||||||
setWizard((w) => ({ ...w, isLoading: false }))
|
// Si entramos en watch realtime, el loading se corta desde checkPlanStateAndAct.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex grow items-center justify-between">
|
||||||
<div className="flex-1">
|
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
<div className="mx-2 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="flex gap-4">
|
|
||||||
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
|
<div className="mx-2 flex w-5 items-center justify-center">
|
||||||
Anterior
|
<Loader2
|
||||||
</Button>
|
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
|
||||||
@@ -140,6 +309,5 @@ export function WizardControls({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/components/ui/NotFoundPage.tsx
Normal file
44
src/components/ui/NotFoundPage.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Link, useRouter } from '@tanstack/react-router'
|
||||||
|
import { FileQuestion, Home, ArrowLeft } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from './button'
|
||||||
|
|
||||||
|
interface NotFoundPageProps {
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotFoundPage({
|
||||||
|
title = 'Página no encontrada',
|
||||||
|
message = 'Lo sentimos, no pudimos encontrar lo que buscabas. Es posible que la página haya sido movida o eliminada.',
|
||||||
|
children,
|
||||||
|
}: NotFoundPageProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] flex-col items-center justify-center p-4 text-center">
|
||||||
|
<div className="bg-muted mb-6 rounded-full p-6">
|
||||||
|
<FileQuestion className="text-muted-foreground h-12 w-12" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mb-2 text-3xl font-bold tracking-tight">{title}</h1>
|
||||||
|
<p className="text-muted-foreground mb-8 max-w-125">{message}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button variant="outline" onClick={() => router.history.back()}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Regresar
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/">
|
||||||
|
<Home className="mr-2 h-4 w-4" />
|
||||||
|
Ir al inicio
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
text?: string
|
text?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubmitButton({ text = 'Iniciar sesión' }: Props) {
|
export function SubmitButton({ text = 'Iniciar sesión', disabled }: Props) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-[#7b0f1d] text-white py-2 rounded-lg
|
disabled={disabled}
|
||||||
font-semibold hover:opacity-90 transition"
|
className="w-full rounded-lg bg-[#7b0f1d] py-2 font-semibold text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
64
src/components/ui/accordion.tsx
Normal file
64
src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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 }
|
||||||
133
src/components/ui/drawer.tsx
Normal file
133
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
41
src/components/wizard/StepWithTooltip.tsx
Normal file
41
src/components/wizard/StepWithTooltip.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
export function StepWithTooltip({
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
desc: string
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
className="cursor-help decoration-dotted underline-offset-4 hover:underline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsOpen((prev) => !prev)
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setIsOpen(true)}
|
||||||
|
onMouseLeave={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-50 text-xs">
|
||||||
|
<p>{desc}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
src/components/wizard/WizardLayout.tsx
Normal file
52
src/components/wizard/WizardLayout.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
|
import { CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
export function WizardLayout({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
headerSlot,
|
||||||
|
footerSlot,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
onClose: () => void
|
||||||
|
headerSlot?: React.ReactNode
|
||||||
|
footerSlot?: React.ReactNode
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent
|
||||||
|
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="z-10 flex-none border-b bg-white">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-4">
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<Icons.X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Cerrar</span>
|
||||||
|
</button>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{headerSlot ? <div className="px-6 pb-6">{headerSlot}</div> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footerSlot ? (
|
||||||
|
<div className="flex-none border-t bg-white p-6">{footerSlot}</div>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
src/components/wizard/WizardResponsiveHeader.tsx
Normal file
66
src/components/wizard/WizardResponsiveHeader.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { CircularProgress } from '@/components/CircularProgress'
|
||||||
|
import { StepWithTooltip } from '@/components/wizard/StepWithTooltip'
|
||||||
|
|
||||||
|
export function WizardResponsiveHeader({
|
||||||
|
wizard,
|
||||||
|
methods,
|
||||||
|
titleOverrides,
|
||||||
|
}: {
|
||||||
|
wizard: any
|
||||||
|
methods: any
|
||||||
|
titleOverrides?: Record<string, string>
|
||||||
|
}) {
|
||||||
|
const idx = wizard.utils.getIndex(methods.current.id)
|
||||||
|
const totalSteps = wizard.steps.length
|
||||||
|
const currentIndex = idx + 1
|
||||||
|
const hasNextStep = idx < totalSteps - 1
|
||||||
|
const nextStep = wizard.steps[currentIndex]
|
||||||
|
|
||||||
|
const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="block sm:hidden">
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<CircularProgress current={currentIndex} total={totalSteps} />
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
<h2 className="text-lg font-bold text-slate-900">
|
||||||
|
<StepWithTooltip
|
||||||
|
title={resolveTitle(methods.current)}
|
||||||
|
desc={methods.current.description}
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
{hasNextStep && nextStep ? (
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Siguiente: {resolveTitle(nextStep)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-medium text-green-500">
|
||||||
|
¡Último paso!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
|
||||||
|
{wizard.steps.map((step: any) => (
|
||||||
|
<wizard.Stepper.Step
|
||||||
|
key={step.id}
|
||||||
|
of={step.id}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<wizard.Stepper.Title>
|
||||||
|
<StepWithTooltip
|
||||||
|
title={resolveTitle(step)}
|
||||||
|
desc={step.description}
|
||||||
|
/>
|
||||||
|
</wizard.Stepper.Title>
|
||||||
|
</wizard.Stepper.Step>
|
||||||
|
))}
|
||||||
|
</wizard.Stepper.Navigation>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,45 +1,56 @@
|
|||||||
import type { PostgrestError, AuthError, SupabaseClient } from "@supabase/supabase-js";
|
import type { Database } from '../types/database'
|
||||||
import type { Database } from "../types/database";
|
import type {
|
||||||
|
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>(data: T | null | undefined, message = "Respuesta vacía"): T {
|
export function requireData<T>(
|
||||||
if (data === null || data === undefined) throw new ApiError(message);
|
data: T | null | undefined,
|
||||||
return data;
|
message = 'Respuesta vacía',
|
||||||
|
): T {
|
||||||
|
if (data === null || data === undefined) throw new ApiError(message)
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserIdOrThrow(supabase: SupabaseClient<Database>): Promise<string> {
|
export async function getUserIdOrThrow(
|
||||||
const { data, error } = await supabase.auth.getUser();
|
supabase: SupabaseClient<Database>,
|
||||||
throwIfError(error);
|
): Promise<string> {
|
||||||
if (!data?.user?.id) throw new ApiError("No hay sesión activa (auth).");
|
const { data, error } = await supabase.auth.getUser()
|
||||||
return data.user.id;
|
throwIfError(error)
|
||||||
|
if (!data?.user?.id) throw new ApiError('No hay sesión activa (auth).')
|
||||||
|
return data.user.id
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRange(limit?: number, offset?: number): { from?: number; to?: number } {
|
export function buildRange(
|
||||||
if (!limit) return {};
|
limit?: number,
|
||||||
const from = Math.max(0, offset ?? 0);
|
offset?: number,
|
||||||
const to = from + Math.max(1, limit) - 1;
|
): { from?: number; to?: number } {
|
||||||
return { from, to };
|
if (!limit) return {}
|
||||||
|
const from = Math.max(0, offset ?? 0)
|
||||||
|
const to = from + Math.max(1, limit) - 1
|
||||||
|
return { from, to }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,238 @@
|
|||||||
import { invokeEdge } from "../supabase/invokeEdge";
|
import { supabaseBrowser } from '../supabase/client'
|
||||||
import type { InteraccionIA, UUID } from "../types/domain";
|
import { invokeEdge } from '../supabase/invokeEdge'
|
||||||
|
|
||||||
|
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?: UUID[];
|
archivosIds?: Array<UUID>
|
||||||
vectorStoresIds?: UUID[];
|
vectorStoresIds?: Array<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 }>(EDGE.ai_plan_improve, payload);
|
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(
|
||||||
|
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?: UUID[];
|
archivosIds?: Array<UUID>
|
||||||
vectorStoresIds?: UUID[];
|
vectorStoresIds?: Array<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 }>(EDGE.ai_plan_chat, payload);
|
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(
|
||||||
|
EDGE.ai_plan_chat,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ai_subject_improve(payload: {
|
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?: UUID[];
|
archivosIds?: Array<UUID>
|
||||||
vectorStoresIds?: UUID[];
|
vectorStoresIds?: Array<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 }>(EDGE.ai_subject_improve, payload);
|
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(
|
||||||
|
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?: UUID[];
|
archivosIds?: Array<UUID>
|
||||||
vectorStoresIds?: UUID[];
|
vectorStoresIds?: Array<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 }>(EDGE.ai_subject_chat, payload);
|
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(
|
||||||
|
EDGE.ai_subject_chat,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Biblioteca (Edge; adapta a tu API real) */
|
/** 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: { query: string; limit?: number }): Promise<LibraryItem[]> {
|
export async function library_search(payload: {
|
||||||
return invokeEdge<LibraryItem[]>(EDGE.library_search, payload);
|
query: string
|
||||||
|
limit?: number
|
||||||
|
}): Promise<Array<LibraryItem>> {
|
||||||
|
return invokeEdge<Array<LibraryItem>>(EDGE.library_search, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create_conversation(planId: string) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const { data, error } = await supabase.functions.invoke(
|
||||||
|
'create-chat-conversation/conversations',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
plan_estudio_id: planId, // O el nombre que confirmamos que funciona
|
||||||
|
instanciador: 'alex',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_chat_history(conversacionId: string) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const { data, error } = await supabase.functions.invoke(
|
||||||
|
`create-chat-conversation/conversations/${conversacionId}/messages`,
|
||||||
|
{ method: 'GET' },
|
||||||
|
)
|
||||||
|
if (error) throw error
|
||||||
|
return data // Retorna Array de mensajes
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update_conversation_status(
|
||||||
|
conversacionId: string,
|
||||||
|
nuevoEstado: 'ARCHIVADA' | 'ACTIVA',
|
||||||
|
) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('conversaciones_plan') // Asegúrate que el nombre de la tabla sea exacto
|
||||||
|
.update({ estado: nuevoEstado })
|
||||||
|
.eq('id', conversacionId)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modificamos la función de chat para que use la ruta de mensajes
|
||||||
|
export async function ai_plan_chat_v2(payload: {
|
||||||
|
conversacionId: string
|
||||||
|
content: string
|
||||||
|
campos?: Array<string>
|
||||||
|
}): Promise<{ reply: string; meta?: any }> {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const { data, error } = await supabase.functions.invoke(
|
||||||
|
`create-chat-conversation/conversations/${payload.conversacionId}/messages`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
content: payload.content,
|
||||||
|
campos: payload.campos || [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConversationByPlan(planId: string) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('conversaciones_plan')
|
||||||
|
.select('*')
|
||||||
|
.eq('plan_estudio_id', planId)
|
||||||
|
.order('creado_en', { ascending: false })
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
return data ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update_conversation_title(
|
||||||
|
conversacionId: string,
|
||||||
|
nuevoTitulo: string,
|
||||||
|
) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('conversaciones_plan')
|
||||||
|
.update({ nombre: nuevoTitulo }) // Asegúrate que la columna se llame 'title' o 'nombre'
|
||||||
|
.eq('id', conversacionId)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update_recommendation_applied_status(
|
||||||
|
conversacionId: string,
|
||||||
|
campoAfectado: string,
|
||||||
|
) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
// 1. Obtener el estado actual del JSON
|
||||||
|
const { data: conv, error: fetchError } = await supabase
|
||||||
|
.from('conversaciones_plan')
|
||||||
|
.select('conversacion_json')
|
||||||
|
.eq('id', conversacionId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (fetchError) throw fetchError
|
||||||
|
if (!conv.conversacion_json) throw new Error('No se encontró la conversación')
|
||||||
|
|
||||||
|
// 2. Transformar el JSON para marcar como aplicada la recomendación específica
|
||||||
|
// Usamos una transformación inmutable para evitar efectos secundarios
|
||||||
|
const nuevoJson = (conv.conversacion_json as Array<any>).map((msg) => {
|
||||||
|
if (msg.user === 'assistant' && Array.isArray(msg.recommendations)) {
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
recommendations: msg.recommendations.map((rec: any) =>
|
||||||
|
rec.campo_afectado === campoAfectado
|
||||||
|
? { ...rec, aplicada: true }
|
||||||
|
: rec,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Actualizar la base de datos con el nuevo JSON
|
||||||
|
const { data, error: updateError } = await supabase
|
||||||
|
.from('conversaciones_plan')
|
||||||
|
.update({ conversacion_json: nuevoJson })
|
||||||
|
.eq('id', conversacionId)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (updateError) throw updateError
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/materia */
|
/** “temporal” = evidencia usada para generar plan/asignatura */
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export async function plans_list(
|
|||||||
`,
|
`,
|
||||||
{ count: 'exact' },
|
{ count: 'exact' },
|
||||||
)
|
)
|
||||||
.order('actualizado_en', { ascending: false })
|
.order('creado_en', { ascending: false })
|
||||||
|
|
||||||
// 2. Aplicamos filtros dinámicos
|
// 2. Aplicamos filtros dinámicos
|
||||||
|
|
||||||
@@ -123,6 +123,8 @@ export async function plans_list(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
||||||
|
console.log('plans_get')
|
||||||
|
|
||||||
const supabase = supabaseBrowser()
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@@ -142,6 +144,48 @@ 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>> {
|
||||||
@@ -163,7 +207,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,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en',
|
'id,plan_estudio_id,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',
|
||||||
)
|
)
|
||||||
.eq('plan_estudio_id', planId)
|
.eq('plan_estudio_id', planId)
|
||||||
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
||||||
@@ -174,18 +218,31 @@ export async function plan_asignaturas_list(
|
|||||||
return data ?? []
|
return data ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function plans_history(planId: UUID): Promise<Array<CambioPlan>> {
|
export async function plans_history(
|
||||||
|
planId: UUID,
|
||||||
|
page: number = 0,
|
||||||
|
pageSize: number = 4,
|
||||||
|
): Promise<{ data: Array<CambioPlan>; count: number }> {
|
||||||
|
// Cambiamos el retorno
|
||||||
const supabase = supabaseBrowser()
|
const supabase = supabaseBrowser()
|
||||||
const { data, error } = await supabase
|
const from = page * pageSize
|
||||||
|
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',
|
'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,response_id',
|
||||||
|
{ 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 data ?? []
|
return {
|
||||||
|
data: data ?? [],
|
||||||
|
count: count ?? 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wizard: crear plan manual (Edge Function) */
|
/** Wizard: crear plan manual (Edge Function) */
|
||||||
@@ -266,8 +323,8 @@ export type AIGeneratePlanInput = {
|
|||||||
estructuraPlanId: UUID
|
estructuraPlanId: UUID
|
||||||
}
|
}
|
||||||
iaConfig: {
|
iaConfig: {
|
||||||
descripcionEnfoque: string
|
descripcionEnfoqueAcademico: string
|
||||||
notasAdicionales?: string
|
instruccionesAdicionalesIA?: string
|
||||||
archivosReferencia?: Array<UUID>
|
archivosReferencia?: Array<UUID>
|
||||||
repositoriosIds?: Array<UUID>
|
repositoriosIds?: Array<UUID>
|
||||||
archivosAdjuntos: Array<UploadedFile>
|
archivosAdjuntos: Array<UploadedFile>
|
||||||
@@ -289,7 +346,7 @@ export async function ai_generate_plan(
|
|||||||
archivosAdjuntos: undefined, // los manejamos aparte
|
archivosAdjuntos: undefined, // los manejamos aparte
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
input.iaConfig.archivosAdjuntos.forEach((file, index) => {
|
input.iaConfig.archivosAdjuntos.forEach((file) => {
|
||||||
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
|
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -331,7 +388,7 @@ export async function plans_import_from_files(payload: {
|
|||||||
}
|
}
|
||||||
archivoWordPlanId: UUID
|
archivoWordPlanId: UUID
|
||||||
archivoMapaExcelId?: UUID | null
|
archivoMapaExcelId?: UUID | null
|
||||||
archivoMateriasExcelId?: UUID | null
|
archivoAsignaturasExcelId?: UUID | null
|
||||||
}): Promise<PlanEstudio> {
|
}): Promise<PlanEstudio> {
|
||||||
return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload)
|
return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,181 +1,362 @@
|
|||||||
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 { DocumentoResult } from "./plans.api";
|
import type {
|
||||||
|
AsignaturaSugerida,
|
||||||
|
DataAsignaturaSugerida,
|
||||||
|
} from '@/features/asignaturas/nueva/types'
|
||||||
|
import type { Database, TablesInsert } from '@/types/supabase'
|
||||||
|
|
||||||
const EDGE = {
|
const EDGE = {
|
||||||
subjects_create_manual: "subjects_create_manual",
|
generate_subject_suggestions: 'generate-subject-suggestions',
|
||||||
ai_generate_subject: "ai_generate_subject",
|
subjects_create_manual: 'subjects_create_manual',
|
||||||
subjects_persist_from_ai: "subjects_persist_from_ai",
|
ai_generate_subject: 'ai-generate-subject',
|
||||||
subjects_clone_from_existing: "subjects_clone_from_existing",
|
subjects_persist_from_ai: 'subjects_persist_from_ai',
|
||||||
subjects_import_from_file: "subjects_import_from_file",
|
subjects_clone_from_existing: 'subjects_clone_from_existing',
|
||||||
|
subjects_import_from_file: 'subjects_import_from_file',
|
||||||
|
|
||||||
subjects_update_fields: "subjects_update_fields",
|
subjects_update_fields: 'subjects_update_fields',
|
||||||
subjects_update_contenido: "subjects_update_contenido",
|
subjects_update_bibliografia: 'subjects_update_bibliografia',
|
||||||
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 async function subjects_get(subjectId: UUID): Promise<Asignatura> {
|
export type ContenidoTemaApi =
|
||||||
const supabase = supabaseBrowser();
|
| string
|
||||||
|
| {
|
||||||
|
nombre: string
|
||||||
|
horasEstimadas?: number
|
||||||
|
descripcion?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estructura persistida en `asignaturas.contenido_tematico`.
|
||||||
|
* La BDD guarda un arreglo de unidades, cada una con temas (strings u objetos).
|
||||||
|
*/
|
||||||
|
export type ContenidoApi = {
|
||||||
|
unidad: number
|
||||||
|
titulo: string
|
||||||
|
temas: Array<ContenidoTemaApi>
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FacultadInSubject = Pick<
|
||||||
|
FacultadRow,
|
||||||
|
'id' | 'nombre' | 'nombre_corto' | 'color' | 'icono'
|
||||||
|
>
|
||||||
|
|
||||||
|
export type CarreraInSubject = Pick<
|
||||||
|
CarreraRow,
|
||||||
|
'id' | 'facultad_id' | 'nombre' | 'nombre_corto' | 'clave_sep' | 'activa'
|
||||||
|
> & {
|
||||||
|
facultades: FacultadInSubject | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlanEstudioInSubject = Pick<
|
||||||
|
PlanEstudioRow,
|
||||||
|
| 'id'
|
||||||
|
| 'carrera_id'
|
||||||
|
| 'estructura_id'
|
||||||
|
| 'nombre'
|
||||||
|
| 'nivel'
|
||||||
|
| 'tipo_ciclo'
|
||||||
|
| 'numero_ciclos'
|
||||||
|
| 'datos'
|
||||||
|
| 'estado_actual_id'
|
||||||
|
| 'activo'
|
||||||
|
| 'tipo_origen'
|
||||||
|
| 'meta_origen'
|
||||||
|
| 'creado_por'
|
||||||
|
| 'actualizado_por'
|
||||||
|
| 'creado_en'
|
||||||
|
| 'actualizado_en'
|
||||||
|
> & {
|
||||||
|
carreras: CarreraInSubject | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EstructuraAsignaturaInSubject = Pick<
|
||||||
|
EstructuraAsignatura,
|
||||||
|
'id' | 'nombre' | 'version' | 'definicion'
|
||||||
|
>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo real que devuelve `subjects_get` (asignatura + relaciones seleccionadas).
|
||||||
|
* Nota: `asignaturas_update` (update directo) NO devuelve estas relaciones.
|
||||||
|
*/
|
||||||
|
export type AsignaturaDetail = Omit<Asignatura, 'contenido_tematico'> & {
|
||||||
|
contenido_tematico: Array<ContenidoApi> | null
|
||||||
|
planes_estudio: PlanEstudioInSubject | null
|
||||||
|
estructuras_asignatura: EstructuraAsignaturaInSubject | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("asignaturas")
|
.from('asignaturas')
|
||||||
.select(
|
.select(
|
||||||
`
|
`
|
||||||
id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
id,plan_estudio_id,estructura_id,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,
|
||||||
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(data, "Materia no encontrada.");
|
return requireData(
|
||||||
|
data,
|
||||||
|
'Asignatura no encontrada.',
|
||||||
|
) as unknown as AsignaturaDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function subjects_history(subjectId: UUID): Promise<CambioAsignatura[]> {
|
export async function subjects_history(
|
||||||
const supabase = supabaseBrowser();
|
subjectId: UUID,
|
||||||
|
): 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(subjectId: UUID): Promise<BibliografiaAsignatura[]> {
|
export async function subjects_bibliografia_list(
|
||||||
const supabase = supabaseBrowser();
|
subjectId: UUID,
|
||||||
|
): Promise<Array<BibliografiaAsignatura>> {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("bibliografia_asignatura")
|
.from('bibliografia_asignatura')
|
||||||
.select("id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en")
|
.select(
|
||||||
.eq("asignatura_id", subjectId)
|
'id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en',
|
||||||
.order("tipo", { ascending: true })
|
)
|
||||||
.order("creado_en", { ascending: true });
|
.eq('asignatura_id', subjectId)
|
||||||
|
.order('tipo', { ascending: true })
|
||||||
|
.order('creado_en', { ascending: true })
|
||||||
|
|
||||||
throwIfError(error);
|
throwIfError(error)
|
||||||
return data ?? [];
|
return data ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wizard: crear materia manual (Edge Function) */
|
export async function subjects_create_manual(
|
||||||
export type SubjectsCreateManualInput = {
|
payload: TablesInsert<'asignaturas'>,
|
||||||
planId: UUID;
|
): Promise<Asignatura> {
|
||||||
datosBasicos: {
|
const supabase = supabaseBrowser()
|
||||||
nombre: string;
|
const { data, error } = await supabase
|
||||||
clave?: string;
|
.from('asignaturas')
|
||||||
tipo: TipoAsignatura;
|
.insert(payload)
|
||||||
creditos: number;
|
.select()
|
||||||
horasSemana?: number;
|
.single()
|
||||||
estructuraId: UUID;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function subjects_create_manual(payload: SubjectsCreateManualInput): Promise<Asignatura> {
|
throwIfError(error)
|
||||||
return invokeEdge<Asignatura>(EDGE.subjects_create_manual, payload);
|
return requireData(data, 'No se pudo crear la asignatura.')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ai_generate_subject(payload: {
|
/**
|
||||||
planId: UUID;
|
* Nuevo payload unificado (JSON) para la Edge `ai_generate_subject`.
|
||||||
datosBasicos: {
|
* - Siempre incluye `datosUpdate.plan_estudio_id`.
|
||||||
nombre: string;
|
* - `datosUpdate.id` es opcional (si no existe, la Edge puede crear).
|
||||||
clave?: string;
|
* En el frontend, insertamos primero y usamos `id` para actualizar.
|
||||||
tipo: TipoAsignatura;
|
*/
|
||||||
creditos: number;
|
export type AISubjectUnifiedInput = {
|
||||||
horasSemana?: number;
|
datosUpdate: Partial<{
|
||||||
estructuraId: UUID;
|
id: string
|
||||||
};
|
plan_estudio_id: string
|
||||||
iaConfig: {
|
estructura_id: string
|
||||||
descripcionEnfoque: string;
|
nombre: string
|
||||||
notasAdicionales?: string;
|
codigo: string | null
|
||||||
archivosExistentesIds?: UUID[];
|
tipo: string | null
|
||||||
repositoriosIds?: UUID[];
|
creditos: number
|
||||||
archivosAdhocIds?: UUID[];
|
horas_academicas: number | null
|
||||||
usarMCP?: boolean;
|
horas_independientes: number | null
|
||||||
};
|
numero_ciclo: number | null
|
||||||
}): Promise<any> {
|
linea_plan_id: string | null
|
||||||
return invokeEdge<any>(EDGE.ai_generate_subject, payload);
|
orden_celda: number | null
|
||||||
|
}> & {
|
||||||
|
plan_estudio_id: string
|
||||||
|
}
|
||||||
|
iaConfig?: {
|
||||||
|
descripcionEnfoqueAcademico?: string
|
||||||
|
instruccionesAdicionalesIA?: string
|
||||||
|
archivosAdjuntos?: Array<string>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function subjects_persist_from_ai(payload: { planId: UUID; jsonMateria: any }): Promise<Asignatura> {
|
export async function subjects_get_maybe(
|
||||||
return invokeEdge<Asignatura>(EDGE.subjects_persist_from_ai, payload);
|
subjectId: UUID,
|
||||||
|
): Promise<Asignatura | null> {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.select('id,plan_estudio_id,estado')
|
||||||
|
.eq('id', subjectId)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
throwIfError(error)
|
||||||
|
return (data ?? null) as unknown as Asignatura | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GenerateSubjectSuggestionsInput = {
|
||||||
|
plan_estudio_id: UUID
|
||||||
|
enfoque?: string
|
||||||
|
cantidad_de_sugerencias: number
|
||||||
|
sugerencias_conservadas: Array<{ nombre: string; descripcion: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generate_subject_suggestions(
|
||||||
|
input: GenerateSubjectSuggestionsInput,
|
||||||
|
): Promise<Array<AsignaturaSugerida>> {
|
||||||
|
const raw = await invokeEdge<Array<DataAsignaturaSugerida>>(
|
||||||
|
EDGE.generate_subject_suggestions,
|
||||||
|
input,
|
||||||
|
{ headers: { 'Content-Type': 'application/json' } },
|
||||||
|
)
|
||||||
|
|
||||||
|
return raw.map(
|
||||||
|
(s): AsignaturaSugerida => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
selected: false,
|
||||||
|
source: 'IA',
|
||||||
|
nombre: s.nombre,
|
||||||
|
codigo: s.codigo,
|
||||||
|
tipo: s.tipo ?? null,
|
||||||
|
creditos: s.creditos ?? null,
|
||||||
|
horasAcademicas: s.horasAcademicas ?? null,
|
||||||
|
horasIndependientes: s.horasIndependientes ?? null,
|
||||||
|
descripcion: s.descripcion,
|
||||||
|
linea_plan_id: null,
|
||||||
|
numero_ciclo: null,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ai_generate_subject(
|
||||||
|
input: AISubjectUnifiedInput,
|
||||||
|
): Promise<any> {
|
||||||
|
return invokeEdge<any>(EDGE.ai_generate_subject, input, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subjects_persist_from_ai(payload: {
|
||||||
|
planId: UUID
|
||||||
|
jsonAsignatura: any
|
||||||
|
}): Promise<Asignatura> {
|
||||||
|
return invokeEdge<Asignatura>(EDGE.subjects_persist_from_ai, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function subjects_clone_from_existing(payload: {
|
export async function subjects_clone_from_existing(payload: {
|
||||||
materiaOrigenId: UUID;
|
asignaturaOrigenId: 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
|
||||||
archivoWordMateriaId: UUID;
|
archivoWordAsignaturaId: UUID
|
||||||
archivosAdicionalesIds?: UUID[];
|
archivosAdicionalesIds?: Array<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(subjectId: UUID, patch: SubjectsUpdateFieldsPatch): Promise<Asignatura> {
|
export async function subjects_update_fields(
|
||||||
return invokeEdge<Asignatura>(EDGE.subjects_update_fields, { subjectId, patch });
|
subjectId: UUID,
|
||||||
|
patch: SubjectsUpdateFieldsPatch,
|
||||||
|
): Promise<Asignatura> {
|
||||||
|
return invokeEdge<Asignatura>(EDGE.subjects_update_fields, {
|
||||||
|
subjectId,
|
||||||
|
patch,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function subjects_update_contenido(subjectId: UUID, unidades: any[]): Promise<Asignatura> {
|
export async function subjects_update_contenido(
|
||||||
return invokeEdge<Asignatura>(EDGE.subjects_update_contenido, { subjectId, unidades });
|
subjectId: UUID,
|
||||||
|
unidades: Array<ContenidoApi>,
|
||||||
|
): Promise<Asignatura> {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
type AsignaturaUpdate = Database['public']['Tables']['asignaturas']['Update']
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.update({
|
||||||
|
contenido_tematico:
|
||||||
|
unidades as unknown as AsignaturaUpdate['contenido_tematico'],
|
||||||
|
})
|
||||||
|
.eq('id', subjectId)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
throwIfError(error)
|
||||||
|
return requireData(data, 'No se pudo actualizar la asignatura.')
|
||||||
}
|
}
|
||||||
|
|
||||||
export 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, { subjectId, entries });
|
return invokeEdge<{ ok: true }>(EDGE.subjects_update_bibliografia, {
|
||||||
|
subjectId,
|
||||||
|
entries,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Documento SEP materia */
|
/** Documento SEP asignatura */
|
||||||
/* export type DocumentoResult = {
|
/* export type DocumentoResult = {
|
||||||
archivoId: UUID;
|
archivoId: UUID;
|
||||||
signedUrl: string;
|
signedUrl: string;
|
||||||
@@ -183,10 +364,149 @@ export async function subjects_update_bibliografia(
|
|||||||
nombre?: string;
|
nombre?: string;
|
||||||
}; */
|
}; */
|
||||||
|
|
||||||
export async function subjects_generate_document(subjectId: UUID): Promise<DocumentoResult> {
|
export async function subjects_generate_document(
|
||||||
return invokeEdge<DocumentoResult>(EDGE.subjects_generate_document, { subjectId });
|
subjectId: UUID,
|
||||||
|
): Promise<DocumentoResult> {
|
||||||
|
return invokeEdge<DocumentoResult>(EDGE.subjects_generate_document, {
|
||||||
|
subjectId,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function subjects_get_document(subjectId: UUID): Promise<DocumentoResult | null> {
|
export async function subjects_get_document(
|
||||||
return invokeEdge<DocumentoResult | null>(EDGE.subjects_get_document, { subjectId });
|
subjectId: UUID,
|
||||||
|
): Promise<DocumentoResult | null> {
|
||||||
|
return invokeEdge<DocumentoResult | null>(EDGE.subjects_get_document, {
|
||||||
|
subjectId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function subjects_get_structure_catalog(): Promise<
|
||||||
|
Array<Database['public']['Tables']['estructuras_asignatura']['Row']>
|
||||||
|
> {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('estructuras_asignatura')
|
||||||
|
.select('*')
|
||||||
|
.order('nombre', { ascending: true })
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function asignaturas_update(
|
||||||
|
asignaturaId: UUID,
|
||||||
|
patch: Partial<Asignatura>, // O tu tipo específico para el Patch de materias
|
||||||
|
): Promise<Asignatura> {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.update(patch)
|
||||||
|
.eq('id', asignaturaId)
|
||||||
|
.select() // Trae la materia actualizada
|
||||||
|
.single()
|
||||||
|
|
||||||
|
throwIfError(error)
|
||||||
|
return requireData(data, 'No se pudo actualizar la asignatura.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insertar una nueva línea
|
||||||
|
export async function lineas_insert(linea: {
|
||||||
|
nombre: string
|
||||||
|
plan_estudio_id: string
|
||||||
|
orden: number
|
||||||
|
area?: string
|
||||||
|
}) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('lineas_plan') // Asegúrate que el nombre de la tabla sea correcto
|
||||||
|
.insert([linea])
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar una línea existente
|
||||||
|
export async function lineas_update(
|
||||||
|
lineaId: string,
|
||||||
|
patch: { nombre?: string; orden?: number; area?: string },
|
||||||
|
) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('lineas_plan')
|
||||||
|
.update(patch)
|
||||||
|
.eq('id', lineaId)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lineas_delete(lineaId: string) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
// Nota: Si configuraste "ON DELETE SET NULL" en tu base de datos,
|
||||||
|
// las asignaturas se desvincularán solas. Si no, Supabase podría dar error.
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('lineas_plan')
|
||||||
|
.delete()
|
||||||
|
.eq('id', lineaId)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return lineaId
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bibliografia_insert(entry: {
|
||||||
|
asignatura_id: string
|
||||||
|
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
||||||
|
cita: string
|
||||||
|
tipo_fuente: 'MANUAL' | 'BIBLIOTECA'
|
||||||
|
biblioteca_item_id?: string | null
|
||||||
|
}) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('bibliografia_asignatura')
|
||||||
|
.insert([entry])
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bibliografia_update(
|
||||||
|
id: string,
|
||||||
|
updates: {
|
||||||
|
cita?: string
|
||||||
|
tipo?: 'BASICA' | 'COMPLEMENTARIA'
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('bibliografia_asignatura')
|
||||||
|
.update(updates) // Ahora 'updates' es compatible con lo que espera Supabase
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bibliografia_delete(id: string) {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('bibliografia_asignatura')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,139 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ai_plan_chat,
|
ai_plan_chat_v2,
|
||||||
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,
|
||||||
} from "../api/ai.api";
|
update_conversation_status,
|
||||||
|
update_recommendation_applied_status,
|
||||||
|
update_conversation_title,
|
||||||
|
} from '../api/ai.api'
|
||||||
|
|
||||||
|
// eslint-disable-next-line node/prefer-node-protocol
|
||||||
|
import type { UUID } from 'crypto'
|
||||||
|
|
||||||
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({ mutationFn: ai_plan_chat });
|
return useMutation({
|
||||||
|
mutationFn: async (payload: {
|
||||||
|
planId: UUID
|
||||||
|
content: string
|
||||||
|
campos?: Array<string>
|
||||||
|
conversacionId?: string
|
||||||
|
}) => {
|
||||||
|
let currentId = payload.conversacionId
|
||||||
|
|
||||||
|
// 1. Si no hay ID, creamos la conversación
|
||||||
|
if (!currentId) {
|
||||||
|
const response = await create_conversation(payload.planId)
|
||||||
|
|
||||||
|
// CAMBIO AQUÍ: Accedemos a la estructura correcta según tu consola
|
||||||
|
currentId = response.conversation_plan.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Ahora enviamos el mensaje con el ID garantizado
|
||||||
|
const result = await ai_plan_chat_v2({
|
||||||
|
conversacionId: currentId!,
|
||||||
|
content: payload.content,
|
||||||
|
campos: payload.campos,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Retornamos el resultado del chat y el ID para el estado del componente
|
||||||
|
return { ...result, conversacionId: currentId }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatHistory(conversacionId?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['chat-history', conversacionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
return get_chat_history(conversacionId!)
|
||||||
|
},
|
||||||
|
enabled: Boolean(conversacionId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateConversationStatus() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
estado,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
estado: 'ARCHIVADA' | 'ACTIVA'
|
||||||
|
}) => update_conversation_status(id, estado),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Esto refresca las listas automáticamente
|
||||||
|
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConversationByPlan(planId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['conversation-by-plan', planId],
|
||||||
|
queryFn: () => getConversationByPlan(planId!),
|
||||||
|
enabled: !!planId, // solo ejecuta si existe planId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateRecommendationApplied() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
conversacionId,
|
||||||
|
campoAfectado,
|
||||||
|
}: {
|
||||||
|
conversacionId: string
|
||||||
|
campoAfectado: string
|
||||||
|
}) => update_recommendation_applied_status(conversacionId, campoAfectado),
|
||||||
|
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
// Invalidamos la query para que useConversationByPlan refresque el JSON
|
||||||
|
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
|
||||||
|
console.log(
|
||||||
|
`Recomendación ${variables.campoAfectado} marcada como aplicada.`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Error al actualizar el estado de la recomendación:', error)
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,145 @@
|
|||||||
import { useEffect } from "react";
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useEffect } from 'react'
|
||||||
import { supabaseBrowser } from "../supabase/client";
|
|
||||||
import { qk } from "../query/keys";
|
import { throwIfError } from '../api/_helpers'
|
||||||
import { throwIfError } from "../api/_helpers";
|
import { qk } from '../query/keys'
|
||||||
|
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.auth });
|
qc.invalidateQueries({ queryKey: qk.meAccess() })
|
||||||
});
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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,
|
||||||
@@ -12,6 +13,7 @@ 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,
|
||||||
@@ -23,7 +25,9 @@ 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,
|
||||||
@@ -53,7 +57,10 @@ export function usePlanes(filters: PlanListFilters) {
|
|||||||
export function usePlan(planId: UUID | null | undefined) {
|
export function usePlan(planId: UUID | null | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: planId ? qk.plan(planId) : ['planes', 'detail', null],
|
queryKey: planId ? qk.plan(planId) : ['planes', 'detail', null],
|
||||||
queryFn: () => plans_get(planId as UUID),
|
queryFn: () => {
|
||||||
|
console.log('usePlan')
|
||||||
|
return plans_get(planId as UUID)
|
||||||
|
},
|
||||||
enabled: Boolean(planId),
|
enabled: Boolean(planId),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -67,20 +74,92 @@ export function usePlanLineas(planId: UUID | null | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePlanAsignaturas(planId: UUID | null | undefined) {
|
export function usePlanAsignaturas(planId: UUID | null | undefined) {
|
||||||
return useQuery({
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePlanHistorial(planId: UUID | null | undefined) {
|
const newRow: any = payload.new
|
||||||
|
if (!newRow?.id) return
|
||||||
|
|
||||||
|
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
|
||||||
|
if (!Array.isArray(prev)) return prev
|
||||||
|
|
||||||
|
const idx = prev.findIndex(
|
||||||
|
(a: any) => String(a?.id) === String(newRow.id),
|
||||||
|
)
|
||||||
|
if (idx === -1) return [...prev, newRow]
|
||||||
|
|
||||||
|
const next = [...prev]
|
||||||
|
next[idx] = { ...prev[idx], ...newRow }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
channel.subscribe()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
supabase.removeChannel(channel)
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [planId, qc])
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlanHistorial(
|
||||||
|
planId: UUID | null | undefined,
|
||||||
|
page: number,
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: planId ? qk.planHistorial(planId) : ['planes', 'historial', null],
|
queryKey: planId
|
||||||
queryFn: () => plans_history(planId as UUID),
|
? [...qk.planHistorial(planId), page]
|
||||||
|
: ['planes', 'historial', null, page],
|
||||||
|
queryFn: () => plans_history(planId as UUID, page),
|
||||||
enabled: Boolean(planId),
|
enabled: Boolean(planId),
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +174,7 @@ export function usePlanDocumento(planId: UUID | null | undefined) {
|
|||||||
|
|
||||||
export function useCatalogosPlanes() {
|
export function useCatalogosPlanes() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['catalogos_planes'],
|
queryKey: qk.estructurasPlan(),
|
||||||
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)
|
||||||
})
|
})
|
||||||
@@ -243,6 +322,23 @@ 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()
|
||||||
|
|
||||||
@@ -254,3 +350,15 @@ 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'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
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,
|
||||||
@@ -21,6 +22,15 @@ 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({
|
||||||
@@ -63,13 +73,20 @@ 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: SubjectsCreateManualInput) =>
|
mutationFn: (payload: TablesInsert<'asignaturas'>) =>
|
||||||
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)
|
||||||
@@ -84,14 +101,16 @@ export function useCreateSubjectManual() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useGenerateSubjectAI() {
|
export function useGenerateSubjectAI() {
|
||||||
return useMutation({ mutationFn: ai_generate_subject })
|
return useMutation({
|
||||||
|
mutationFn: ai_generate_subject,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePersistSubjectFromAI() {
|
export function usePersistSubjectFromAI() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (payload: { planId: UUID; jsonMateria: any }) =>
|
mutationFn: (payload: { planId: UUID; jsonAsignatura: 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)
|
||||||
@@ -146,7 +165,9 @@ 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), updated)
|
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
|
||||||
|
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),
|
||||||
})
|
})
|
||||||
@@ -159,10 +180,19 @@ export function useUpdateSubjectContenido() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (vars: { subjectId: UUID; unidades: any[] }) =>
|
mutationFn: (vars: { subjectId: UUID; unidades: Array<ContenidoApi> }) =>
|
||||||
subjects_update_contenido(vars.subjectId, vars.unidades),
|
subjects_update_contenido(vars.subjectId, vars.unidades),
|
||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
qc.setQueryData(qk.asignatura(updated.id), updated)
|
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
|
||||||
|
prev ? { ...(prev as any), ...(updated as any) } : updated,
|
||||||
|
)
|
||||||
|
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
|
||||||
|
})
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: qk.planHistorial(updated.plan_estudio_id),
|
||||||
|
})
|
||||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
|
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -194,3 +224,96 @@ 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),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
311
src/data/mockAsignaturaData.ts
Normal file
311
src/data/mockAsignaturaData.ts
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
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',
|
||||||
|
}
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,31 +1,38 @@
|
|||||||
export const qk = {
|
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) =>
|
||||||
["meta", "carreras", { facultadId: facultadId ?? null }] as const,
|
['meta', 'carreras', { facultadId: facultadId ?? null }] as const,
|
||||||
estructurasPlan: (nivel?: string | null) =>
|
estructurasPlan: (nivel?: string | null) =>
|
||||||
["meta", "estructurasPlan", { nivel: nivel ?? null }] as const,
|
['meta', 'estructurasPlan', { nivel: nivel ?? null }] as const,
|
||||||
estructurasAsignatura: () => ["meta", "estructurasAsignatura"] as const,
|
estructurasAsignatura: () => ['meta', 'estructurasAsignatura'] as const,
|
||||||
estadosPlan: () => ["meta", "estadosPlan"] as const,
|
estadosPlan: () => ['meta', 'estadosPlan'] as const,
|
||||||
|
|
||||||
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,
|
||||||
planLineas: (planId: string) => ["planes", planId, "lineas"] as const,
|
planMaybe: (planId: string) => ['planes', 'detail-maybe', planId] as const,
|
||||||
planAsignaturas: (planId: string) => ["planes", planId, "asignaturas"] as const,
|
planLineas: (planId: string) => ['planes', planId, 'lineas'] as const,
|
||||||
planHistorial: (planId: string) => ["planes", planId, "historial"] as const,
|
planAsignaturas: (planId: string) =>
|
||||||
planDocumento: (planId: string) => ["planes", planId, "documento"] as const,
|
['planes', planId, 'asignaturas'] as const,
|
||||||
|
planHistorial: (planId: string) => ['planes', planId, 'historial'] as const,
|
||||||
|
planDocumento: (planId: string) => ['planes', planId, 'documento'] as const,
|
||||||
|
|
||||||
asignatura: (asignaturaId: string) => ["asignaturas", "detail", asignaturaId] as const,
|
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
|
||||||
|
asignatura: (asignaturaId: string) =>
|
||||||
|
['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) =>
|
||||||
["asignaturas", asignaturaId, "historial"] as const,
|
['asignaturas', asignaturaId, 'historial'] as const,
|
||||||
asignaturaDocumento: (asignaturaId: string) =>
|
asignaturaDocumento: (asignaturaId: string) =>
|
||||||
["asignaturas", asignaturaId, "documento"] as const,
|
['asignaturas', asignaturaId, 'documento'] as const,
|
||||||
|
|
||||||
tareas: () => ["tareas", "mias"] as const,
|
tareas: () => ['tareas', 'mias'] as const,
|
||||||
notificaciones: () => ["notificaciones", "mias"] as const,
|
notificaciones: () => ['notificaciones', 'mias'] as const,
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -1,8 +1,44 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import {
|
||||||
|
MutationCache,
|
||||||
|
QueryCache,
|
||||||
|
QueryClient,
|
||||||
|
QueryClientProvider,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { qk } from './keys'
|
||||||
|
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
function isRlsViolationError(error: unknown): boolean {
|
||||||
|
const anyErr = error as any
|
||||||
|
const code = anyErr?.code
|
||||||
|
const status = anyErr?.status ?? anyErr?.response?.status
|
||||||
|
console.log('Checking RLS violation error:', { code, status })
|
||||||
|
// Supabase/PostgREST suele devolver 403 (Forbidden) o código PG 42501 (insufficient_privilege)
|
||||||
|
return status === 403 || code === '42501'
|
||||||
|
}
|
||||||
|
|
||||||
export function getContext() {
|
export function getContext() {
|
||||||
const queryClient = new QueryClient(
|
const queryClientRef: { current: QueryClient | null } = { current: null }
|
||||||
{
|
|
||||||
|
const handleAuthzDesync = (error: unknown) => {
|
||||||
|
if (!isRlsViolationError(error)) return
|
||||||
|
// Forzar resincronización “database-first” del rol/permisos
|
||||||
|
console.log('RLS violation detected, invalidating queries...')
|
||||||
|
queryClientRef.current?.invalidateQueries({ queryKey: qk.meAccess() })
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
queryCache: new QueryCache({
|
||||||
|
onError: (error) => {
|
||||||
|
handleAuthzDesync(error)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
mutationCache: new MutationCache({
|
||||||
|
onError: (error) => {
|
||||||
|
handleAuthzDesync(error)
|
||||||
|
},
|
||||||
|
}),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
@@ -13,8 +49,9 @@ export function getContext() {
|
|||||||
retry: 0,
|
retry: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
queryClientRef.current = queryClient
|
||||||
return {
|
return {
|
||||||
queryClient,
|
queryClient,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { supabaseBrowser } from "./client";
|
import {
|
||||||
|
FunctionsFetchError,
|
||||||
|
FunctionsHttpError,
|
||||||
|
FunctionsRelayError,
|
||||||
|
} from '@supabase/supabase-js'
|
||||||
|
|
||||||
import type { Database } from "@/types/supabase";
|
import { supabaseBrowser } from './client'
|
||||||
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(
|
||||||
@@ -15,8 +21,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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,23 +40,69 @@ 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) {
|
||||||
const anyErr = error;
|
// Valores por defecto (por si falla el parseo o es otro tipo de error)
|
||||||
throw new EdgeFunctionError(
|
let message = error.message // El genérico "returned a non-2xx status code"
|
||||||
anyErr.message ?? "Error en Edge Function",
|
let status = undefined
|
||||||
functionName,
|
let details: unknown = error
|
||||||
anyErr.status,
|
|
||||||
anyErr,
|
// 2. Verificamos si es un error HTTP (4xx o 5xx) que trae cuerpo JSON
|
||||||
);
|
if (error instanceof FunctionsHttpError) {
|
||||||
|
try {
|
||||||
|
// Obtenemos el status real (ej. 404, 400)
|
||||||
|
status = error.context.status
|
||||||
|
|
||||||
|
// ¡LA CLAVE! Leemos el JSON que tu Edge Function envió
|
||||||
|
const errorBody = await error.context.json()
|
||||||
|
details = errorBody
|
||||||
|
|
||||||
|
// Intentamos extraer el mensaje humano según tu estructura { error: { message: "..." } }
|
||||||
|
// o la estructura simple { error: "..." }
|
||||||
|
if (errorBody && typeof errorBody === 'object') {
|
||||||
|
// Caso 1: Estructura anidada (la que definimos hace poco: { error: { message: "..." } })
|
||||||
|
if (
|
||||||
|
'error' in errorBody &&
|
||||||
|
typeof errorBody.error === 'object' &&
|
||||||
|
errorBody.error !== null &&
|
||||||
|
'message' in errorBody.error
|
||||||
|
) {
|
||||||
|
message = (errorBody.error as { message: string }).message
|
||||||
|
}
|
||||||
|
// Caso 2: Estructura simple ({ error: "Mensaje de error" })
|
||||||
|
else if (
|
||||||
|
'error' in errorBody &&
|
||||||
|
typeof errorBody.error === 'string'
|
||||||
|
) {
|
||||||
|
message = errorBody.error
|
||||||
|
}
|
||||||
|
// Caso 3: Propiedad message directa ({ message: "..." })
|
||||||
|
else if (
|
||||||
|
'message' in errorBody &&
|
||||||
|
typeof errorBody.message === 'string'
|
||||||
|
) {
|
||||||
|
message = errorBody.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('No se pudo parsear el error JSON de la Edge Function', e)
|
||||||
|
}
|
||||||
|
} else if (error instanceof FunctionsRelayError) {
|
||||||
|
message = `Error de Relay Supabase: ${error.message}`
|
||||||
|
} else if (error instanceof FunctionsFetchError) {
|
||||||
|
message = `Error de conexión (Fetch): ${error.message}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return data as TOut;
|
// 3. Lanzamos tu error personalizado con los datos reales extraídos
|
||||||
|
throw new EdgeFunctionError(message, functionName, status, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as TOut
|
||||||
}
|
}
|
||||||
|
|||||||
151
src/features/asignaturas/nueva/AIProgressLoader.tsx
Normal file
151
src/features/asignaturas/nueva/AIProgressLoader.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
|
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
|
||||||
|
|
||||||
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm'
|
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
|
||||||
import { PasoConfiguracionPanel } from '@/components/asignaturas/wizard/PasoConfiguracionPanel'
|
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
|
||||||
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 { VistaSinPermisos } from '@/components/asignaturas/wizard/VistaSinPermisos'
|
|
||||||
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
|
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
|
||||||
import { WizardHeader } from '@/components/asignaturas/wizard/WizardHeader'
|
|
||||||
import { defineStepper } from '@/components/stepper'
|
import { defineStepper } from '@/components/stepper'
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { WizardLayout } from '@/components/wizard/WizardLayout'
|
||||||
|
import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader'
|
||||||
|
|
||||||
|
const auth_get_current_user_role = (): string => 'JEFE_CARRERA'
|
||||||
|
|
||||||
const Wizard = defineStepper(
|
const Wizard = defineStepper(
|
||||||
{
|
{
|
||||||
@@ -24,8 +34,8 @@ const Wizard = defineStepper(
|
|||||||
description: 'Nombre y estructura',
|
description: 'Nombre y estructura',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'configuracion',
|
id: 'detalles',
|
||||||
title: 'Configuración',
|
title: 'Detalles',
|
||||||
description: 'Detalles según modo',
|
description: 'Detalles según modo',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -35,8 +45,6 @@ const Wizard = defineStepper(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
|
|
||||||
|
|
||||||
export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const role = auth_get_current_user_role()
|
const role = auth_get_current_user_role()
|
||||||
@@ -46,82 +54,112 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
|||||||
setWizard,
|
setWizard,
|
||||||
canContinueDesdeMetodo,
|
canContinueDesdeMetodo,
|
||||||
canContinueDesdeBasicos,
|
canContinueDesdeBasicos,
|
||||||
canContinueDesdeConfig,
|
canContinueDesdeDetalles,
|
||||||
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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role !== 'JEFE_CARRERA') {
|
||||||
|
return (
|
||||||
|
<WizardLayout title="Nueva Asignatura" onClose={handleClose}>
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive flex items-center gap-2">
|
||||||
|
<Icons.ShieldAlert className="h-5 w-5" />
|
||||||
|
Sin permisos
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Solo el Jefe de Carrera puede crear asignaturas.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex justify-end">
|
||||||
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</WizardLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
|
|
||||||
<DialogContent
|
|
||||||
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
|
|
||||||
onInteractOutside={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
{role !== 'JEFE_CARRERA' ? (
|
|
||||||
<VistaSinPermisos onClose={handleClose} />
|
|
||||||
) : (
|
|
||||||
<Wizard.Stepper.Provider
|
<Wizard.Stepper.Provider
|
||||||
initialStep={Wizard.utils.getFirst().id}
|
initialStep={Wizard.utils.getFirst().id}
|
||||||
className="flex h-full flex-col"
|
className="flex h-full flex-col"
|
||||||
>
|
>
|
||||||
{({ methods }) => (
|
{({ methods }) => {
|
||||||
<>
|
const idx = Wizard.utils.getIndex(methods.current.id)
|
||||||
<WizardHeader
|
|
||||||
title="Nueva Asignatura"
|
|
||||||
Wizard={Wizard}
|
|
||||||
methods={{ ...methods, onClose: handleClose }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
|
return (
|
||||||
<div className="mx-auto max-w-3xl">
|
<WizardLayout
|
||||||
{Wizard.utils.getIndex(methods.current.id) === 0 && (
|
title="Nueva Asignatura"
|
||||||
<Wizard.Stepper.Panel>
|
onClose={handleClose}
|
||||||
<PasoMetodoCardGroup
|
headerSlot={
|
||||||
wizard={wizard}
|
<WizardResponsiveHeader
|
||||||
onChange={setWizard}
|
wizard={Wizard}
|
||||||
|
methods={methods}
|
||||||
|
titleOverrides={titleOverrides}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
footerSlot={
|
||||||
|
<Wizard.Stepper.Controls>
|
||||||
|
<WizardControls
|
||||||
|
errorMessage={wizard.errorMessage}
|
||||||
|
onPrev={() => methods.prev()}
|
||||||
|
onNext={() => methods.next()}
|
||||||
|
disablePrev={idx === 0 || wizard.isLoading}
|
||||||
|
disableNext={
|
||||||
|
wizard.isLoading ||
|
||||||
|
(idx === 0 && !canContinueDesdeMetodo) ||
|
||||||
|
(idx === 1 && !canContinueDesdeBasicos) ||
|
||||||
|
(idx === 2 && !canContinueDesdeDetalles)
|
||||||
|
}
|
||||||
|
disableCreate={wizard.isLoading}
|
||||||
|
isLastStep={idx >= Wizard.steps.length - 1}
|
||||||
|
wizard={wizard}
|
||||||
|
setWizard={setWizard}
|
||||||
|
/>
|
||||||
|
</Wizard.Stepper.Controls>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-3xl">
|
||||||
|
{idx === 0 && (
|
||||||
|
<Wizard.Stepper.Panel>
|
||||||
|
<PasoMetodoCardGroup wizard={wizard} onChange={setWizard} />
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
)}
|
)}
|
||||||
{Wizard.utils.getIndex(methods.current.id) === 1 && (
|
|
||||||
|
{idx === 1 && (
|
||||||
<Wizard.Stepper.Panel>
|
<Wizard.Stepper.Panel>
|
||||||
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
|
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
)}
|
)}
|
||||||
{Wizard.utils.getIndex(methods.current.id) === 2 && (
|
|
||||||
|
{idx === 2 && (
|
||||||
<Wizard.Stepper.Panel>
|
<Wizard.Stepper.Panel>
|
||||||
<PasoConfiguracionPanel
|
<PasoDetallesPanel wizard={wizard} onChange={setWizard} />
|
||||||
wizard={wizard}
|
|
||||||
onChange={setWizard}
|
|
||||||
onGenerarIA={simularGeneracionIA}
|
|
||||||
/>
|
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
)}
|
)}
|
||||||
{Wizard.utils.getIndex(methods.current.id) === 3 && (
|
|
||||||
|
{idx === 3 && (
|
||||||
<Wizard.Stepper.Panel>
|
<Wizard.Stepper.Panel>
|
||||||
<PasoResumenCard wizard={wizard} />
|
<PasoResumenCard wizard={wizard} />
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</WizardLayout>
|
||||||
|
)
|
||||||
<WizardControls
|
}}
|
||||||
Wizard={Wizard}
|
|
||||||
methods={methods}
|
|
||||||
wizard={wizard}
|
|
||||||
canContinueDesdeMetodo={canContinueDesdeMetodo}
|
|
||||||
canContinueDesdeBasicos={canContinueDesdeBasicos}
|
|
||||||
canContinueDesdeConfig={canContinueDesdeConfig}
|
|
||||||
onCreate={() => crearAsignatura(handleClose)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Wizard.Stepper.Provider>
|
</Wizard.Stepper.Provider>
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,83 @@
|
|||||||
import { useState } from "react";
|
import { useState } from 'react'
|
||||||
|
|
||||||
import type { AsignaturaPreview, NewSubjectWizardState } from "../types";
|
import type { 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,
|
||||||
planId,
|
plan_estudio_id: planId,
|
||||||
modoCreacion: null,
|
estructuraId: null,
|
||||||
|
tipoOrigen: null,
|
||||||
datosBasicos: {
|
datosBasicos: {
|
||||||
nombre: "",
|
nombre: '',
|
||||||
clave: "",
|
codigo: '',
|
||||||
tipo: "OBLIGATORIA",
|
tipo: null,
|
||||||
creditos: 0,
|
creditos: null,
|
||||||
horasSemana: 0,
|
horasAcademicas: null,
|
||||||
estructuraId: "",
|
horasIndependientes: null,
|
||||||
|
estructuraId: '',
|
||||||
},
|
},
|
||||||
|
sugerencias: [],
|
||||||
clonInterno: {},
|
clonInterno: {},
|
||||||
clonTradicional: {
|
clonTradicional: {
|
||||||
archivoWordAsignaturaId: null,
|
archivoWordAsignaturaId: null,
|
||||||
archivosAdicionalesIds: [],
|
archivosAdicionalesIds: [],
|
||||||
},
|
},
|
||||||
iaConfig: {
|
iaConfig: {
|
||||||
descripcionEnfoque: "",
|
descripcionEnfoqueAcademico: '',
|
||||||
notasAdicionales: "",
|
instruccionesAdicionalesIA: '',
|
||||||
archivosExistentesIds: [],
|
archivosReferencia: [],
|
||||||
|
repositoriosReferencia: [],
|
||||||
|
archivosAdjuntos: [],
|
||||||
|
},
|
||||||
|
iaMultiple: {
|
||||||
|
enfoque: '',
|
||||||
|
cantidadDeSugerencias: 10,
|
||||||
|
isLoading: false,
|
||||||
},
|
},
|
||||||
resumen: {},
|
resumen: {},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
});
|
})
|
||||||
|
|
||||||
const canContinueDesdeMetodo = wizard.modoCreacion === "MANUAL" ||
|
const canContinueDesdeMetodo =
|
||||||
wizard.modoCreacion === "IA" ||
|
wizard.tipoOrigen === 'MANUAL' ||
|
||||||
(wizard.modoCreacion === "CLONADO" && !!wizard.subModoClonado);
|
wizard.tipoOrigen === 'IA_SIMPLE' ||
|
||||||
|
wizard.tipoOrigen === 'IA_MULTIPLE' ||
|
||||||
|
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
||||||
|
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
|
||||||
|
|
||||||
const canContinueDesdeBasicos = !!wizard.datosBasicos.nombre &&
|
const canContinueDesdeBasicos =
|
||||||
|
(!!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 canContinueDesdeConfig = (() => {
|
const canContinueDesdeDetalles = (() => {
|
||||||
if (wizard.modoCreacion === "MANUAL") return true;
|
if (wizard.tipoOrigen === 'MANUAL') return true
|
||||||
if (wizard.modoCreacion === "IA") {
|
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
||||||
return !!wizard.iaConfig?.descripcionEnfoque;
|
return !!wizard.iaConfig?.descripcionEnfoqueAcademico
|
||||||
}
|
}
|
||||||
if (wizard.modoCreacion === "CLONADO") {
|
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
||||||
if (wizard.subModoClonado === "INTERNO") {
|
return !!wizard.clonInterno?.asignaturaOrigenId
|
||||||
return !!wizard.clonInterno?.asignaturaOrigenId;
|
|
||||||
}
|
}
|
||||||
if (wizard.subModoClonado === "TRADICIONAL") {
|
if (wizard.tipoOrigen === 'CLONADO_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,
|
||||||
canContinueDesdeConfig,
|
canContinueDesdeDetalles,
|
||||||
simularGeneracionIA,
|
}
|
||||||
crearAsignatura,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,79 @@
|
|||||||
export type ModoCreacion = "MANUAL" | "IA" | "CLONADO";
|
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
||||||
export type SubModoClonado = "INTERNO" | "TRADICIONAL";
|
import type { Asignatura } from '@/data'
|
||||||
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
|
||||||
planId: string;
|
plan_estudio_id: Asignatura['plan_estudio_id']
|
||||||
modoCreacion: ModoCreacion | null;
|
estructuraId: Asignatura['estructura_id'] | null
|
||||||
subModoClonado?: SubModoClonado;
|
tipoOrigen:
|
||||||
|
| Asignatura['tipo_origen']
|
||||||
|
| 'CLONADO'
|
||||||
|
| 'IA_SIMPLE'
|
||||||
|
| 'IA_MULTIPLE'
|
||||||
|
| null
|
||||||
datosBasicos: {
|
datosBasicos: {
|
||||||
nombre: string;
|
nombre: Asignatura['nombre']
|
||||||
clave?: string;
|
codigo?: Asignatura['codigo']
|
||||||
tipo: TipoAsignatura;
|
tipo: Asignatura['tipo'] | null
|
||||||
creditos: number;
|
creditos: Asignatura['creditos'] | null
|
||||||
horasSemana?: number;
|
horasAcademicas?: Asignatura['horas_academicas'] | null
|
||||||
estructuraId: string;
|
horasIndependientes?: Asignatura['horas_independientes'] | null
|
||||||
};
|
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?: {
|
||||||
descripcionEnfoque: string;
|
descripcionEnfoqueAcademico: string
|
||||||
notasAdicionales: string;
|
instruccionesAdicionalesIA: string
|
||||||
archivosExistentesIds: Array<string>;
|
archivosReferencia: 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
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel/
|
|||||||
import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup'
|
import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup'
|
||||||
import { PasoResumenCard } from '@/components/planes/wizard/PasoResumenCard'
|
import { PasoResumenCard } from '@/components/planes/wizard/PasoResumenCard'
|
||||||
import { WizardControls } from '@/components/planes/wizard/WizardControls'
|
import { WizardControls } from '@/components/planes/wizard/WizardControls'
|
||||||
import { WizardHeader } from '@/components/planes/wizard/WizardHeader'
|
|
||||||
import { defineStepper } from '@/components/stepper'
|
import { defineStepper } from '@/components/stepper'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -19,16 +18,12 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import {
|
import { WizardLayout } from '@/components/wizard/WizardLayout'
|
||||||
Dialog,
|
import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader'
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
// import { useGeneratePlanAI } from '@/data/hooks/usePlans'
|
// import { useGeneratePlanAI } from '@/data/hooks/usePlans'
|
||||||
|
|
||||||
// Mock de permisos/rol
|
// Mock de permisos/rol
|
||||||
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
|
const auth_get_current_user_role = (): string => 'JEFE_CARRERA'
|
||||||
|
|
||||||
const Wizard = defineStepper(
|
const Wizard = defineStepper(
|
||||||
{
|
{
|
||||||
@@ -64,20 +59,9 @@ export default function NuevoPlanModalContainer() {
|
|||||||
|
|
||||||
// Crear plan: ahora la lógica vive en WizardControls
|
// Crear plan: ahora la lógica vive en WizardControls
|
||||||
|
|
||||||
|
if (role !== 'JEFE_CARRERA') {
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
|
<WizardLayout title="Nuevo plan de estudios" onClose={handleClose}>
|
||||||
<DialogContent
|
|
||||||
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
|
|
||||||
onInteractOutside={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{role !== 'JEFE_CARRERA' ? (
|
|
||||||
<>
|
|
||||||
<DialogHeader className="flex-none border-b p-6">
|
|
||||||
<DialogTitle>Nuevo plan de estudios</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex-1 p-6">
|
|
||||||
<Card className="border-destructive/40">
|
<Card className="border-destructive/40">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -97,52 +81,58 @@ export default function NuevoPlanModalContainer() {
|
|||||||
</button>
|
</button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</WizardLayout>
|
||||||
</>
|
)
|
||||||
) : (
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<Wizard.Stepper.Provider
|
<Wizard.Stepper.Provider
|
||||||
initialStep={Wizard.utils.getFirst().id}
|
initialStep={Wizard.utils.getFirst().id}
|
||||||
className="flex h-full flex-col"
|
className="flex h-full flex-col"
|
||||||
>
|
>
|
||||||
{({ methods }) => {
|
{({ methods }) => {
|
||||||
const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1
|
const idx = Wizard.utils.getIndex(methods.current.id)
|
||||||
const totalSteps = Wizard.steps.length
|
|
||||||
const nextStep = Wizard.steps[currentIndex] ?? {
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<WizardLayout
|
||||||
<WizardHeader
|
title="Nuevo plan de estudios"
|
||||||
currentIndex={currentIndex}
|
|
||||||
totalSteps={totalSteps}
|
|
||||||
currentTitle={methods.current.title}
|
|
||||||
currentDescription={methods.current.description}
|
|
||||||
nextTitle={nextStep.title}
|
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
Wizard={Wizard}
|
headerSlot={
|
||||||
|
<WizardResponsiveHeader wizard={Wizard} methods={methods} />
|
||||||
|
}
|
||||||
|
footerSlot={
|
||||||
|
<Wizard.Stepper.Controls>
|
||||||
|
<WizardControls
|
||||||
|
errorMessage={wizard.errorMessage}
|
||||||
|
onPrev={() => methods.prev()}
|
||||||
|
onNext={() => methods.next()}
|
||||||
|
disablePrev={idx === 0 || wizard.isLoading}
|
||||||
|
disableNext={
|
||||||
|
wizard.isLoading ||
|
||||||
|
(idx === 0 && !canContinueDesdeModo) ||
|
||||||
|
(idx === 1 && !canContinueDesdeBasicos) ||
|
||||||
|
(idx === 2 && !canContinueDesdeDetalles)
|
||||||
|
}
|
||||||
|
disableCreate={wizard.isLoading}
|
||||||
|
isLastStep={idx >= Wizard.steps.length - 1}
|
||||||
|
wizard={wizard}
|
||||||
|
setWizard={setWizard}
|
||||||
/>
|
/>
|
||||||
|
</Wizard.Stepper.Controls>
|
||||||
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
|
}
|
||||||
|
>
|
||||||
<div className="mx-auto max-w-3xl">
|
<div className="mx-auto max-w-3xl">
|
||||||
{Wizard.utils.getIndex(methods.current.id) === 0 && (
|
{idx === 0 && (
|
||||||
<Wizard.Stepper.Panel>
|
<Wizard.Stepper.Panel>
|
||||||
<PasoModoCardGroup
|
<PasoModoCardGroup wizard={wizard} onChange={setWizard} />
|
||||||
wizard={wizard}
|
|
||||||
onChange={setWizard}
|
|
||||||
/>
|
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
)}
|
)}
|
||||||
{Wizard.utils.getIndex(methods.current.id) === 1 && (
|
{idx === 1 && (
|
||||||
<Wizard.Stepper.Panel>
|
<Wizard.Stepper.Panel>
|
||||||
<PasoBasicosForm
|
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
|
||||||
wizard={wizard}
|
|
||||||
onChange={setWizard}
|
|
||||||
/>
|
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
)}
|
)}
|
||||||
{Wizard.utils.getIndex(methods.current.id) === 2 && (
|
{idx === 2 && (
|
||||||
<Wizard.Stepper.Panel>
|
<Wizard.Stepper.Panel>
|
||||||
<PasoDetallesPanel
|
<PasoDetallesPanel
|
||||||
wizard={wizard}
|
wizard={wizard}
|
||||||
@@ -151,49 +141,15 @@ export default function NuevoPlanModalContainer() {
|
|||||||
/>
|
/>
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
)}
|
)}
|
||||||
{Wizard.utils.getIndex(methods.current.id) === 3 && (
|
{idx === 3 && (
|
||||||
<Wizard.Stepper.Panel>
|
<Wizard.Stepper.Panel>
|
||||||
<PasoResumenCard wizard={wizard} />
|
<PasoResumenCard wizard={wizard} />
|
||||||
</Wizard.Stepper.Panel>
|
</Wizard.Stepper.Panel>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</WizardLayout>
|
||||||
|
|
||||||
<div className="flex-none border-t bg-white p-6">
|
|
||||||
<Wizard.Stepper.Controls>
|
|
||||||
<WizardControls
|
|
||||||
errorMessage={wizard.errorMessage}
|
|
||||||
onPrev={() => methods.prev()}
|
|
||||||
onNext={() => methods.next()}
|
|
||||||
disablePrev={
|
|
||||||
Wizard.utils.getIndex(methods.current.id) === 0 ||
|
|
||||||
wizard.isLoading
|
|
||||||
}
|
|
||||||
disableNext={
|
|
||||||
wizard.isLoading ||
|
|
||||||
(Wizard.utils.getIndex(methods.current.id) === 0 &&
|
|
||||||
!canContinueDesdeModo) ||
|
|
||||||
(Wizard.utils.getIndex(methods.current.id) === 1 &&
|
|
||||||
!canContinueDesdeBasicos) ||
|
|
||||||
(Wizard.utils.getIndex(methods.current.id) === 2 &&
|
|
||||||
!canContinueDesdeDetalles)
|
|
||||||
}
|
|
||||||
disableCreate={wizard.isLoading}
|
|
||||||
isLastStep={
|
|
||||||
Wizard.utils.getIndex(methods.current.id) >=
|
|
||||||
Wizard.steps.length - 1
|
|
||||||
}
|
|
||||||
wizard={wizard}
|
|
||||||
setWizard={setWizard}
|
|
||||||
/>
|
|
||||||
</Wizard.Stepper.Controls>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Wizard.Stepper.Provider>
|
</Wizard.Stepper.Provider>
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: "Material didáctico Web.pdf",
|
nombre: 'Asignatural 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: "Materiales ISC 2024",
|
nombre: 'Asignaturales 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'],
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ export function useNuevoPlanWizard() {
|
|||||||
tipoOrigen: null,
|
tipoOrigen: null,
|
||||||
datosBasicos: {
|
datosBasicos: {
|
||||||
nombrePlan: '',
|
nombrePlan: '',
|
||||||
carreraId: '',
|
facultad: { id: '', nombre: '' },
|
||||||
facultadId: '',
|
carrera: { id: '', nombre: '' },
|
||||||
nivel: '',
|
nivel: '',
|
||||||
tipoCiclo: '',
|
tipoCiclo: '',
|
||||||
numCiclos: undefined,
|
numCiclos: null,
|
||||||
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.carreraId &&
|
!!wizard.datosBasicos.carrera.id &&
|
||||||
!!wizard.datosBasicos.facultadId &&
|
!!wizard.datosBasicos.facultad.id &&
|
||||||
!!wizard.datosBasicos.nivel &&
|
!!wizard.datosBasicos.nivel &&
|
||||||
wizard.datosBasicos.numCiclos !== undefined &&
|
wizard.datosBasicos.numCiclos !== null &&
|
||||||
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
|
||||||
|
|||||||
@@ -19,11 +19,17 @@ export type NewPlanWizardState = {
|
|||||||
tipoOrigen: TipoOrigen | null
|
tipoOrigen: TipoOrigen | null
|
||||||
datosBasicos: {
|
datosBasicos: {
|
||||||
nombrePlan: string
|
nombrePlan: string
|
||||||
carreraId: string
|
facultad: {
|
||||||
facultadId: string
|
id: string
|
||||||
|
nombre: string
|
||||||
|
}
|
||||||
|
carrera: {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
}
|
||||||
nivel: NivelPlanEstudio | ''
|
nivel: NivelPlanEstudio | ''
|
||||||
tipoCiclo: TipoCiclo | ''
|
tipoCiclo: TipoCiclo | ''
|
||||||
numCiclos: number | undefined
|
numCiclos: number | null
|
||||||
// Selección de plantillas (obligatorias)
|
// Selección de plantillas (obligatorias)
|
||||||
estructuraPlanId: string | null
|
estructuraPlanId: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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'
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ const router = createRouter({
|
|||||||
routeTree,
|
routeTree,
|
||||||
context: {
|
context: {
|
||||||
...TanStackQueryProviderContext,
|
...TanStackQueryProviderContext,
|
||||||
|
supabase: supabaseBrowser(),
|
||||||
},
|
},
|
||||||
defaultPreload: 'intent',
|
defaultPreload: 'intent',
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
|
|||||||
@@ -12,23 +12,25 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as PlanesListaRouteImport } from './routes/planes/_lista'
|
||||||
import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query'
|
import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query'
|
||||||
import { Route as PlanesListaRouteRouteImport } from './routes/planes/_lista/route'
|
|
||||||
import { Route as PlanesPlanIdIndexRouteImport } from './routes/planes/$planId/index'
|
|
||||||
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
|
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
|
||||||
import { Route as PlanesPlanIdAsignaturasRouteRouteImport } from './routes/planes/$planId/asignaturas/route'
|
import { Route as PlanesPlanIdDetalleRouteImport } from './routes/planes/$planId/_detalle'
|
||||||
import { Route as PlanesPlanIdDetalleRouteRouteImport } from './routes/planes/$planId/_detalle/route'
|
import { Route as PlanesPlanIdDetalleIndexRouteImport } from './routes/planes/$planId/_detalle/index'
|
||||||
import { Route as PlanesPlanIdAsignaturasIndexRouteImport } from './routes/planes/$planId/asignaturas/index'
|
|
||||||
import { Route as PlanesPlanIdDetalleMateriasRouteImport } from './routes/planes/$planId/_detalle/materias'
|
|
||||||
import { Route as PlanesPlanIdDetalleMapaRouteImport } from './routes/planes/$planId/_detalle/mapa'
|
import { Route as 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 PlanesPlanIdDetalleDatosRouteImport } from './routes/planes/$planId/_detalle/datos'
|
import { Route as PlanesPlanIdDetalleAsignaturasRouteImport } from './routes/planes/$planId/_detalle/asignaturas'
|
||||||
import { Route as PlanesPlanIdAsignaturasListaRouteRouteImport } from './routes/planes/$planId/asignaturas/_lista/route'
|
|
||||||
import { Route as PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/route'
|
import { Route as PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/route'
|
||||||
import { Route as PlanesPlanIdAsignaturasListaNuevaRouteImport } from './routes/planes/$planId/asignaturas/_lista/nueva'
|
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'
|
||||||
|
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
id: '/login',
|
id: '/login',
|
||||||
@@ -45,165 +47,186 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const PlanesListaRoute = PlanesListaRouteImport.update({
|
||||||
|
id: '/planes/_lista',
|
||||||
|
path: '/planes',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
|
const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
|
||||||
id: '/demo/tanstack-query',
|
id: '/demo/tanstack-query',
|
||||||
path: '/demo/tanstack-query',
|
path: '/demo/tanstack-query',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const PlanesListaRouteRoute = PlanesListaRouteRouteImport.update({
|
|
||||||
id: '/planes/_lista',
|
|
||||||
path: '/planes',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const PlanesPlanIdIndexRoute = PlanesPlanIdIndexRouteImport.update({
|
|
||||||
id: '/planes/$planId/',
|
|
||||||
path: '/planes/$planId/',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const PlanesListaNuevoRoute = PlanesListaNuevoRouteImport.update({
|
const PlanesListaNuevoRoute = PlanesListaNuevoRouteImport.update({
|
||||||
id: '/nuevo',
|
id: '/nuevo',
|
||||||
path: '/nuevo',
|
path: '/nuevo',
|
||||||
getParentRoute: () => PlanesListaRouteRoute,
|
getParentRoute: () => PlanesListaRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const PlanesPlanIdAsignaturasRouteRoute =
|
const PlanesPlanIdDetalleRoute = PlanesPlanIdDetalleRouteImport.update({
|
||||||
PlanesPlanIdAsignaturasRouteRouteImport.update({
|
|
||||||
id: '/planes/$planId/asignaturas',
|
|
||||||
path: '/planes/$planId/asignaturas',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const PlanesPlanIdDetalleRouteRoute =
|
|
||||||
PlanesPlanIdDetalleRouteRouteImport.update({
|
|
||||||
id: '/planes/$planId/_detalle',
|
id: '/planes/$planId/_detalle',
|
||||||
path: '/planes/$planId',
|
path: '/planes/$planId',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const PlanesPlanIdAsignaturasIndexRoute =
|
const PlanesPlanIdDetalleIndexRoute =
|
||||||
PlanesPlanIdAsignaturasIndexRouteImport.update({
|
PlanesPlanIdDetalleIndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => PlanesPlanIdAsignaturasRouteRoute,
|
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||||
} as any)
|
|
||||||
const PlanesPlanIdDetalleMateriasRoute =
|
|
||||||
PlanesPlanIdDetalleMateriasRouteImport.update({
|
|
||||||
id: '/materias',
|
|
||||||
path: '/materias',
|
|
||||||
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
|
|
||||||
} as any)
|
} as any)
|
||||||
const PlanesPlanIdDetalleMapaRoute = PlanesPlanIdDetalleMapaRouteImport.update({
|
const PlanesPlanIdDetalleMapaRoute = PlanesPlanIdDetalleMapaRouteImport.update({
|
||||||
id: '/mapa',
|
id: '/mapa',
|
||||||
path: '/mapa',
|
path: '/mapa',
|
||||||
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
|
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const PlanesPlanIdDetalleIaplanRoute =
|
const PlanesPlanIdDetalleIaplanRoute =
|
||||||
PlanesPlanIdDetalleIaplanRouteImport.update({
|
PlanesPlanIdDetalleIaplanRouteImport.update({
|
||||||
id: '/iaplan',
|
id: '/iaplan',
|
||||||
path: '/iaplan',
|
path: '/iaplan',
|
||||||
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
|
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const PlanesPlanIdDetalleHistorialRoute =
|
const PlanesPlanIdDetalleHistorialRoute =
|
||||||
PlanesPlanIdDetalleHistorialRouteImport.update({
|
PlanesPlanIdDetalleHistorialRouteImport.update({
|
||||||
id: '/historial',
|
id: '/historial',
|
||||||
path: '/historial',
|
path: '/historial',
|
||||||
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
|
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const PlanesPlanIdDetalleFlujoRoute =
|
const PlanesPlanIdDetalleFlujoRoute =
|
||||||
PlanesPlanIdDetalleFlujoRouteImport.update({
|
PlanesPlanIdDetalleFlujoRouteImport.update({
|
||||||
id: '/flujo',
|
id: '/flujo',
|
||||||
path: '/flujo',
|
path: '/flujo',
|
||||||
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
|
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const PlanesPlanIdDetalleDocumentoRoute =
|
const PlanesPlanIdDetalleDocumentoRoute =
|
||||||
PlanesPlanIdDetalleDocumentoRouteImport.update({
|
PlanesPlanIdDetalleDocumentoRouteImport.update({
|
||||||
id: '/documento',
|
id: '/documento',
|
||||||
path: '/documento',
|
path: '/documento',
|
||||||
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
|
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const PlanesPlanIdDetalleDatosRoute =
|
const PlanesPlanIdDetalleAsignaturasRoute =
|
||||||
PlanesPlanIdDetalleDatosRouteImport.update({
|
PlanesPlanIdDetalleAsignaturasRouteImport.update({
|
||||||
id: '/datos',
|
id: '/asignaturas',
|
||||||
path: '/datos',
|
path: '/asignaturas',
|
||||||
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
|
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||||
} as any)
|
|
||||||
const PlanesPlanIdAsignaturasListaRouteRoute =
|
|
||||||
PlanesPlanIdAsignaturasListaRouteRouteImport.update({
|
|
||||||
id: '/_lista',
|
|
||||||
getParentRoute: () => PlanesPlanIdAsignaturasRouteRoute,
|
|
||||||
} as any)
|
} as any)
|
||||||
const PlanesPlanIdAsignaturasAsignaturaIdRouteRoute =
|
const PlanesPlanIdAsignaturasAsignaturaIdRouteRoute =
|
||||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport.update({
|
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport.update({
|
||||||
id: '/$asignaturaId',
|
id: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
path: '/$asignaturaId',
|
path: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
getParentRoute: () => PlanesPlanIdAsignaturasRouteRoute,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const PlanesPlanIdAsignaturasListaNuevaRoute =
|
const PlanesPlanIdAsignaturasAsignaturaIdIndexRoute =
|
||||||
PlanesPlanIdAsignaturasListaNuevaRouteImport.update({
|
PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute =
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport.update({
|
||||||
|
id: '/iaasignatura',
|
||||||
|
path: '/iaasignatura',
|
||||||
|
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute =
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport.update({
|
||||||
|
id: '/historial',
|
||||||
|
path: '/historial',
|
||||||
|
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute =
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport.update({
|
||||||
|
id: '/documento',
|
||||||
|
path: '/documento',
|
||||||
|
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute =
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport.update({
|
||||||
|
id: '/contenido',
|
||||||
|
path: '/contenido',
|
||||||
|
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute =
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport.update({
|
||||||
|
id: '/bibliografia',
|
||||||
|
path: '/bibliografia',
|
||||||
|
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const PlanesPlanIdDetalleAsignaturasNuevaRoute =
|
||||||
|
PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({
|
||||||
id: '/nueva',
|
id: '/nueva',
|
||||||
path: '/nueva',
|
path: '/nueva',
|
||||||
getParentRoute: () => PlanesPlanIdAsignaturasListaRouteRoute,
|
getParentRoute: () => PlanesPlanIdDetalleAsignaturasRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/planes': typeof PlanesListaRouteRouteWithChildren
|
|
||||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||||
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
'/planes': typeof PlanesListaRouteWithChildren
|
||||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
|
'/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren
|
||||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||||
'/planes/$planId/': typeof PlanesPlanIdIndexRoute
|
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
|
||||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
||||||
'/planes/$planId/datos': typeof PlanesPlanIdDetalleDatosRoute
|
|
||||||
'/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/materias': typeof PlanesPlanIdDetalleMateriasRoute
|
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
|
||||||
'/planes/$planId/asignaturas/': typeof PlanesPlanIdAsignaturasIndexRoute
|
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||||
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdAsignaturasListaNuevaRoute
|
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/planes': typeof PlanesListaRouteRouteWithChildren
|
|
||||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||||
'/planes/$planId': typeof PlanesPlanIdIndexRoute
|
'/planes': typeof PlanesListaRouteWithChildren
|
||||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
||||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasIndexRoute
|
|
||||||
'/planes/$planId/datos': typeof PlanesPlanIdDetalleDatosRoute
|
|
||||||
'/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/materias': typeof PlanesPlanIdDetalleMateriasRoute
|
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
|
||||||
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdAsignaturasListaNuevaRoute
|
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/dashboard': typeof DashboardRoute
|
'/dashboard': typeof DashboardRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/planes/_lista': typeof PlanesListaRouteRouteWithChildren
|
|
||||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||||
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
'/planes/_lista': typeof PlanesListaRouteWithChildren
|
||||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
|
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren
|
||||||
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
|
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
|
||||||
'/planes/$planId/': typeof PlanesPlanIdIndexRoute
|
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
|
||||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
'/planes/$planId/_detalle/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
||||||
'/planes/$planId/asignaturas/_lista': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
|
|
||||||
'/planes/$planId/_detalle/datos': typeof PlanesPlanIdDetalleDatosRoute
|
|
||||||
'/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/_detalle/materias': typeof PlanesPlanIdDetalleMateriasRoute
|
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
|
||||||
'/planes/$planId/asignaturas/': typeof PlanesPlanIdAsignaturasIndexRoute
|
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||||
'/planes/$planId/asignaturas/_lista/nueva': typeof PlanesPlanIdAsignaturasListaNuevaRoute
|
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -211,74 +234,81 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/planes'
|
|
||||||
| '/demo/tanstack-query'
|
| '/demo/tanstack-query'
|
||||||
|
| '/planes'
|
||||||
| '/planes/$planId'
|
| '/planes/$planId'
|
||||||
| '/planes/$planId/asignaturas'
|
|
||||||
| '/planes/nuevo'
|
| '/planes/nuevo'
|
||||||
| '/planes/$planId/'
|
|
||||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||||
| '/planes/$planId/datos'
|
| '/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/materias'
|
| '/planes/$planId/'
|
||||||
| '/planes/$planId/asignaturas/'
|
|
||||||
| '/planes/$planId/asignaturas/nueva'
|
| '/planes/$planId/asignaturas/nueva'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/planes'
|
|
||||||
| '/demo/tanstack-query'
|
| '/demo/tanstack-query'
|
||||||
| '/planes/$planId'
|
| '/planes'
|
||||||
| '/planes/nuevo'
|
| '/planes/nuevo'
|
||||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
|
||||||
| '/planes/$planId/asignaturas'
|
| '/planes/$planId/asignaturas'
|
||||||
| '/planes/$planId/datos'
|
|
||||||
| '/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/materias'
|
| '/planes/$planId'
|
||||||
| '/planes/$planId/asignaturas/nueva'
|
| '/planes/$planId/asignaturas/nueva'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/planes/_lista'
|
|
||||||
| '/demo/tanstack-query'
|
| '/demo/tanstack-query'
|
||||||
|
| '/planes/_lista'
|
||||||
| '/planes/$planId/_detalle'
|
| '/planes/$planId/_detalle'
|
||||||
| '/planes/$planId/asignaturas'
|
|
||||||
| '/planes/_lista/nuevo'
|
| '/planes/_lista/nuevo'
|
||||||
| '/planes/$planId/'
|
|
||||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||||
| '/planes/$planId/asignaturas/_lista'
|
| '/planes/$planId/_detalle/asignaturas'
|
||||||
| '/planes/$planId/_detalle/datos'
|
|
||||||
| '/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/_detalle/materias'
|
| '/planes/$planId/_detalle/'
|
||||||
| '/planes/$planId/asignaturas/'
|
| '/planes/$planId/_detalle/asignaturas/nueva'
|
||||||
| '/planes/$planId/asignaturas/_lista/nueva'
|
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
DashboardRoute: typeof DashboardRoute
|
DashboardRoute: typeof DashboardRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren
|
|
||||||
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
|
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
|
||||||
PlanesPlanIdDetalleRouteRoute: typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
PlanesListaRoute: typeof PlanesListaRouteWithChildren
|
||||||
PlanesPlanIdAsignaturasRouteRoute: typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
|
PlanesPlanIdDetalleRoute: typeof PlanesPlanIdDetalleRouteWithChildren
|
||||||
PlanesPlanIdIndexRoute: typeof PlanesPlanIdIndexRoute
|
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -304,6 +334,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/planes/_lista': {
|
||||||
|
id: '/planes/_lista'
|
||||||
|
path: '/planes'
|
||||||
|
fullPath: '/planes'
|
||||||
|
preLoaderRoute: typeof PlanesListaRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/demo/tanstack-query': {
|
'/demo/tanstack-query': {
|
||||||
id: '/demo/tanstack-query'
|
id: '/demo/tanstack-query'
|
||||||
path: '/demo/tanstack-query'
|
path: '/demo/tanstack-query'
|
||||||
@@ -311,215 +348,218 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DemoTanstackQueryRouteImport
|
preLoaderRoute: typeof DemoTanstackQueryRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/planes/_lista': {
|
|
||||||
id: '/planes/_lista'
|
|
||||||
path: '/planes'
|
|
||||||
fullPath: '/planes'
|
|
||||||
preLoaderRoute: typeof PlanesListaRouteRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/planes/$planId/': {
|
|
||||||
id: '/planes/$planId/'
|
|
||||||
path: '/planes/$planId'
|
|
||||||
<<<<<<< HEAD
|
|
||||||
fullPath: '/planes/$planId/'
|
|
||||||
=======
|
|
||||||
<<<<<<< HEAD
|
|
||||||
<<<<<<< HEAD
|
|
||||||
fullPath: '/planes/$planId/'
|
|
||||||
=======
|
|
||||||
fullPath: '/planes/$planId'
|
|
||||||
>>>>>>> 4950f7efbf664bbd31ac8a673fe594af5baf07f6
|
|
||||||
=======
|
|
||||||
fullPath: '/planes/$planId/'
|
|
||||||
>>>>>>> cbe4e54 (Se cierran incidencias #10, #21, #24, #25; se añade generación manual de planes)
|
|
||||||
>>>>>>> 9584cd0c048cf1f4477a4db80947de38e6c75632
|
|
||||||
preLoaderRoute: typeof PlanesPlanIdIndexRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/planes/_lista/nuevo': {
|
'/planes/_lista/nuevo': {
|
||||||
id: '/planes/_lista/nuevo'
|
id: '/planes/_lista/nuevo'
|
||||||
path: '/nuevo'
|
path: '/nuevo'
|
||||||
fullPath: '/planes/nuevo'
|
fullPath: '/planes/nuevo'
|
||||||
preLoaderRoute: typeof PlanesListaNuevoRouteImport
|
preLoaderRoute: typeof PlanesListaNuevoRouteImport
|
||||||
parentRoute: typeof PlanesListaRouteRoute
|
parentRoute: typeof PlanesListaRoute
|
||||||
}
|
|
||||||
'/planes/$planId/asignaturas': {
|
|
||||||
id: '/planes/$planId/asignaturas'
|
|
||||||
path: '/planes/$planId/asignaturas'
|
|
||||||
fullPath: '/planes/$planId/asignaturas'
|
|
||||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasRouteRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
}
|
||||||
'/planes/$planId/_detalle': {
|
'/planes/$planId/_detalle': {
|
||||||
id: '/planes/$planId/_detalle'
|
id: '/planes/$planId/_detalle'
|
||||||
path: '/planes/$planId'
|
path: '/planes/$planId'
|
||||||
fullPath: '/planes/$planId'
|
fullPath: '/planes/$planId'
|
||||||
preLoaderRoute: typeof PlanesPlanIdDetalleRouteRouteImport
|
preLoaderRoute: typeof PlanesPlanIdDetalleRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/planes/$planId/asignaturas/': {
|
'/planes/$planId/_detalle/': {
|
||||||
id: '/planes/$planId/asignaturas/'
|
id: '/planes/$planId/_detalle/'
|
||||||
path: '/'
|
path: '/'
|
||||||
fullPath: '/planes/$planId/asignaturas/'
|
fullPath: '/planes/$planId/'
|
||||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasIndexRouteImport
|
preLoaderRoute: typeof PlanesPlanIdDetalleIndexRouteImport
|
||||||
parentRoute: typeof PlanesPlanIdAsignaturasRouteRoute
|
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||||
}
|
|
||||||
'/planes/$planId/_detalle/materias': {
|
|
||||||
id: '/planes/$planId/_detalle/materias'
|
|
||||||
path: '/materias'
|
|
||||||
fullPath: '/planes/$planId/materias'
|
|
||||||
preLoaderRoute: typeof PlanesPlanIdDetalleMateriasRouteImport
|
|
||||||
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
|
|
||||||
}
|
}
|
||||||
'/planes/$planId/_detalle/mapa': {
|
'/planes/$planId/_detalle/mapa': {
|
||||||
id: '/planes/$planId/_detalle/mapa'
|
id: '/planes/$planId/_detalle/mapa'
|
||||||
path: '/mapa'
|
path: '/mapa'
|
||||||
fullPath: '/planes/$planId/mapa'
|
fullPath: '/planes/$planId/mapa'
|
||||||
preLoaderRoute: typeof PlanesPlanIdDetalleMapaRouteImport
|
preLoaderRoute: typeof PlanesPlanIdDetalleMapaRouteImport
|
||||||
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
|
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||||
}
|
}
|
||||||
'/planes/$planId/_detalle/iaplan': {
|
'/planes/$planId/_detalle/iaplan': {
|
||||||
id: '/planes/$planId/_detalle/iaplan'
|
id: '/planes/$planId/_detalle/iaplan'
|
||||||
path: '/iaplan'
|
path: '/iaplan'
|
||||||
fullPath: '/planes/$planId/iaplan'
|
fullPath: '/planes/$planId/iaplan'
|
||||||
preLoaderRoute: typeof PlanesPlanIdDetalleIaplanRouteImport
|
preLoaderRoute: typeof PlanesPlanIdDetalleIaplanRouteImport
|
||||||
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
|
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||||
}
|
}
|
||||||
'/planes/$planId/_detalle/historial': {
|
'/planes/$planId/_detalle/historial': {
|
||||||
id: '/planes/$planId/_detalle/historial'
|
id: '/planes/$planId/_detalle/historial'
|
||||||
path: '/historial'
|
path: '/historial'
|
||||||
fullPath: '/planes/$planId/historial'
|
fullPath: '/planes/$planId/historial'
|
||||||
preLoaderRoute: typeof PlanesPlanIdDetalleHistorialRouteImport
|
preLoaderRoute: typeof PlanesPlanIdDetalleHistorialRouteImport
|
||||||
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
|
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||||
}
|
}
|
||||||
'/planes/$planId/_detalle/flujo': {
|
'/planes/$planId/_detalle/flujo': {
|
||||||
id: '/planes/$planId/_detalle/flujo'
|
id: '/planes/$planId/_detalle/flujo'
|
||||||
path: '/flujo'
|
path: '/flujo'
|
||||||
fullPath: '/planes/$planId/flujo'
|
fullPath: '/planes/$planId/flujo'
|
||||||
preLoaderRoute: typeof PlanesPlanIdDetalleFlujoRouteImport
|
preLoaderRoute: typeof PlanesPlanIdDetalleFlujoRouteImport
|
||||||
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
|
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||||
}
|
}
|
||||||
'/planes/$planId/_detalle/documento': {
|
'/planes/$planId/_detalle/documento': {
|
||||||
id: '/planes/$planId/_detalle/documento'
|
id: '/planes/$planId/_detalle/documento'
|
||||||
path: '/documento'
|
path: '/documento'
|
||||||
fullPath: '/planes/$planId/documento'
|
fullPath: '/planes/$planId/documento'
|
||||||
preLoaderRoute: typeof PlanesPlanIdDetalleDocumentoRouteImport
|
preLoaderRoute: typeof PlanesPlanIdDetalleDocumentoRouteImport
|
||||||
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
|
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||||
}
|
}
|
||||||
'/planes/$planId/_detalle/datos': {
|
'/planes/$planId/_detalle/asignaturas': {
|
||||||
id: '/planes/$planId/_detalle/datos'
|
id: '/planes/$planId/_detalle/asignaturas'
|
||||||
path: '/datos'
|
path: '/asignaturas'
|
||||||
fullPath: '/planes/$planId/datos'
|
|
||||||
preLoaderRoute: typeof PlanesPlanIdDetalleDatosRouteImport
|
|
||||||
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
|
|
||||||
}
|
|
||||||
'/planes/$planId/asignaturas/_lista': {
|
|
||||||
id: '/planes/$planId/asignaturas/_lista'
|
|
||||||
path: ''
|
|
||||||
fullPath: '/planes/$planId/asignaturas'
|
fullPath: '/planes/$planId/asignaturas'
|
||||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasListaRouteRouteImport
|
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasRouteImport
|
||||||
parentRoute: typeof PlanesPlanIdAsignaturasRouteRoute
|
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||||
}
|
}
|
||||||
'/planes/$planId/asignaturas/$asignaturaId': {
|
'/planes/$planId/asignaturas/$asignaturaId': {
|
||||||
id: '/planes/$planId/asignaturas/$asignaturaId'
|
id: '/planes/$planId/asignaturas/$asignaturaId'
|
||||||
path: '/$asignaturaId'
|
path: '/planes/$planId/asignaturas/$asignaturaId'
|
||||||
fullPath: '/planes/$planId/asignaturas/$asignaturaId'
|
fullPath: '/planes/$planId/asignaturas/$asignaturaId'
|
||||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport
|
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport
|
||||||
parentRoute: typeof PlanesPlanIdAsignaturasRouteRoute
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/planes/$planId/asignaturas/_lista/nueva': {
|
'/planes/$planId/asignaturas/$asignaturaId/': {
|
||||||
id: '/planes/$planId/asignaturas/_lista/nueva'
|
id: '/planes/$planId/asignaturas/$asignaturaId/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/planes/$planId/asignaturas/$asignaturaId/'
|
||||||
|
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport
|
||||||
|
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||||
|
}
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': {
|
||||||
|
id: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||||
|
path: '/iaasignatura'
|
||||||
|
fullPath: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||||
|
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport
|
||||||
|
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||||
|
}
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/historial': {
|
||||||
|
id: '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||||
|
path: '/historial'
|
||||||
|
fullPath: '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||||
|
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport
|
||||||
|
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||||
|
}
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/documento': {
|
||||||
|
id: '/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||||
|
path: '/documento'
|
||||||
|
fullPath: '/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||||
|
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport
|
||||||
|
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||||
|
}
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/contenido': {
|
||||||
|
id: '/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||||
|
path: '/contenido'
|
||||||
|
fullPath: '/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||||
|
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport
|
||||||
|
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||||
|
}
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': {
|
||||||
|
id: '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||||
|
path: '/bibliografia'
|
||||||
|
fullPath: '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||||
|
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport
|
||||||
|
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||||
|
}
|
||||||
|
'/planes/$planId/_detalle/asignaturas/nueva': {
|
||||||
|
id: '/planes/$planId/_detalle/asignaturas/nueva'
|
||||||
path: '/nueva'
|
path: '/nueva'
|
||||||
fullPath: '/planes/$planId/asignaturas/nueva'
|
fullPath: '/planes/$planId/asignaturas/nueva'
|
||||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasListaNuevaRouteImport
|
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRouteImport
|
||||||
parentRoute: typeof PlanesPlanIdAsignaturasListaRouteRoute
|
parentRoute: typeof PlanesPlanIdDetalleAsignaturasRoute
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlanesListaRouteRouteChildren {
|
interface PlanesListaRouteChildren {
|
||||||
PlanesListaNuevoRoute: typeof PlanesListaNuevoRoute
|
PlanesListaNuevoRoute: typeof PlanesListaNuevoRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlanesListaRouteRouteChildren: PlanesListaRouteRouteChildren = {
|
const PlanesListaRouteChildren: PlanesListaRouteChildren = {
|
||||||
PlanesListaNuevoRoute: PlanesListaNuevoRoute,
|
PlanesListaNuevoRoute: PlanesListaNuevoRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlanesListaRouteRouteWithChildren =
|
const PlanesListaRouteWithChildren = PlanesListaRoute._addFileChildren(
|
||||||
PlanesListaRouteRoute._addFileChildren(PlanesListaRouteRouteChildren)
|
PlanesListaRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
interface PlanesPlanIdDetalleRouteRouteChildren {
|
interface PlanesPlanIdDetalleAsignaturasRouteChildren {
|
||||||
PlanesPlanIdDetalleDatosRoute: typeof PlanesPlanIdDetalleDatosRoute
|
PlanesPlanIdDetalleAsignaturasNuevaRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlanesPlanIdDetalleAsignaturasRouteChildren: PlanesPlanIdDetalleAsignaturasRouteChildren =
|
||||||
|
{
|
||||||
|
PlanesPlanIdDetalleAsignaturasNuevaRoute:
|
||||||
|
PlanesPlanIdDetalleAsignaturasNuevaRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlanesPlanIdDetalleAsignaturasRouteWithChildren =
|
||||||
|
PlanesPlanIdDetalleAsignaturasRoute._addFileChildren(
|
||||||
|
PlanesPlanIdDetalleAsignaturasRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
PlanesPlanIdDetalleMateriasRoute: typeof PlanesPlanIdDetalleMateriasRoute
|
PlanesPlanIdDetalleIndexRoute: typeof PlanesPlanIdDetalleIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlanesPlanIdDetalleRouteRouteChildren: PlanesPlanIdDetalleRouteRouteChildren =
|
const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = {
|
||||||
{
|
PlanesPlanIdDetalleAsignaturasRoute:
|
||||||
PlanesPlanIdDetalleDatosRoute: PlanesPlanIdDetalleDatosRoute,
|
PlanesPlanIdDetalleAsignaturasRouteWithChildren,
|
||||||
PlanesPlanIdDetalleDocumentoRoute: PlanesPlanIdDetalleDocumentoRoute,
|
PlanesPlanIdDetalleDocumentoRoute: PlanesPlanIdDetalleDocumentoRoute,
|
||||||
PlanesPlanIdDetalleFlujoRoute: PlanesPlanIdDetalleFlujoRoute,
|
PlanesPlanIdDetalleFlujoRoute: PlanesPlanIdDetalleFlujoRoute,
|
||||||
PlanesPlanIdDetalleHistorialRoute: PlanesPlanIdDetalleHistorialRoute,
|
PlanesPlanIdDetalleHistorialRoute: PlanesPlanIdDetalleHistorialRoute,
|
||||||
PlanesPlanIdDetalleIaplanRoute: PlanesPlanIdDetalleIaplanRoute,
|
PlanesPlanIdDetalleIaplanRoute: PlanesPlanIdDetalleIaplanRoute,
|
||||||
PlanesPlanIdDetalleMapaRoute: PlanesPlanIdDetalleMapaRoute,
|
PlanesPlanIdDetalleMapaRoute: PlanesPlanIdDetalleMapaRoute,
|
||||||
PlanesPlanIdDetalleMateriasRoute: PlanesPlanIdDetalleMateriasRoute,
|
PlanesPlanIdDetalleIndexRoute: PlanesPlanIdDetalleIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlanesPlanIdDetalleRouteRouteWithChildren =
|
const PlanesPlanIdDetalleRouteWithChildren =
|
||||||
PlanesPlanIdDetalleRouteRoute._addFileChildren(
|
PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren)
|
||||||
PlanesPlanIdDetalleRouteRouteChildren,
|
|
||||||
)
|
|
||||||
|
|
||||||
interface PlanesPlanIdAsignaturasListaRouteRouteChildren {
|
interface PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren {
|
||||||
PlanesPlanIdAsignaturasListaNuevaRoute: typeof PlanesPlanIdAsignaturasListaNuevaRoute
|
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlanesPlanIdAsignaturasListaRouteRouteChildren: PlanesPlanIdAsignaturasListaRouteRouteChildren =
|
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren =
|
||||||
{
|
{
|
||||||
PlanesPlanIdAsignaturasListaNuevaRoute:
|
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute:
|
||||||
PlanesPlanIdAsignaturasListaNuevaRoute,
|
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute,
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute:
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute,
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute:
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute,
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute:
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute,
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute:
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute,
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute:
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlanesPlanIdAsignaturasListaRouteRouteWithChildren =
|
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren =
|
||||||
PlanesPlanIdAsignaturasListaRouteRoute._addFileChildren(
|
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute._addFileChildren(
|
||||||
PlanesPlanIdAsignaturasListaRouteRouteChildren,
|
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren,
|
||||||
)
|
|
||||||
|
|
||||||
interface PlanesPlanIdAsignaturasRouteRouteChildren {
|
|
||||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
|
||||||
PlanesPlanIdAsignaturasListaRouteRoute: typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
|
|
||||||
PlanesPlanIdAsignaturasIndexRoute: typeof PlanesPlanIdAsignaturasIndexRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
const PlanesPlanIdAsignaturasRouteRouteChildren: PlanesPlanIdAsignaturasRouteRouteChildren =
|
|
||||||
{
|
|
||||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute:
|
|
||||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
|
||||||
PlanesPlanIdAsignaturasListaRouteRoute:
|
|
||||||
PlanesPlanIdAsignaturasListaRouteRouteWithChildren,
|
|
||||||
PlanesPlanIdAsignaturasIndexRoute: PlanesPlanIdAsignaturasIndexRoute,
|
|
||||||
}
|
|
||||||
|
|
||||||
const PlanesPlanIdAsignaturasRouteRouteWithChildren =
|
|
||||||
PlanesPlanIdAsignaturasRouteRoute._addFileChildren(
|
|
||||||
PlanesPlanIdAsignaturasRouteRouteChildren,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
DashboardRoute: DashboardRoute,
|
DashboardRoute: DashboardRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren,
|
|
||||||
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
|
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
|
||||||
PlanesPlanIdDetalleRouteRoute: PlanesPlanIdDetalleRouteRouteWithChildren,
|
PlanesListaRoute: PlanesListaRouteWithChildren,
|
||||||
PlanesPlanIdAsignaturasRouteRoute:
|
PlanesPlanIdDetalleRoute: PlanesPlanIdDetalleRouteWithChildren,
|
||||||
PlanesPlanIdAsignaturasRouteRouteWithChildren,
|
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute:
|
||||||
PlanesPlanIdIndexRoute: PlanesPlanIdIndexRoute,
|
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -1,20 +1,59 @@
|
|||||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||||
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
import {
|
||||||
|
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 { 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: () => (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<AuthSync />
|
||||||
|
<MaybeHeader />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<TanStackDevtools
|
<TanStackDevtools
|
||||||
config={{
|
config={{
|
||||||
@@ -31,6 +70,8 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|
||||||
|
notFoundComponent: () => <NotFoundPage />,
|
||||||
|
|
||||||
errorComponent: ({ error, reset }) => {
|
errorComponent: ({ error, reset }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[50vh] flex-col items-center justify-center space-y-4 p-6 text-center">
|
<div className="flex min-h-[50vh] flex-col items-center justify-center space-y-4 p-6 text-center">
|
||||||
@@ -56,3 +97,40 @@ 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,32 +1,56 @@
|
|||||||
import { createFileRoute, Outlet, Link } from '@tanstack/react-router'
|
import { createFileRoute, Outlet, Link, notFound } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
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,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { NotFoundPage } from '@/components/ui/NotFoundPage'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { usePlan } from '@/data/hooks/usePlans'
|
import { plans_get } from '@/data/api/plans.api'
|
||||||
|
import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans'
|
||||||
|
import { qk } from '@/data/query/keys'
|
||||||
|
|
||||||
export const Route = createFileRoute('/planes/$planId/_detalle')({
|
export const Route = createFileRoute('/planes/$planId/_detalle')({
|
||||||
|
loader: async ({ context: { queryClient }, params: { planId } }) => {
|
||||||
|
try {
|
||||||
|
await queryClient.ensureQueryData({
|
||||||
|
queryKey: qk.plan(planId),
|
||||||
|
queryFn: () => plans_get(planId),
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
// PGRST116: The result contains 0 rows
|
||||||
|
if (e?.code === 'PGRST116') {
|
||||||
|
throw notFound()
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
notFoundComponent: () => {
|
||||||
|
return (
|
||||||
|
<NotFoundPage
|
||||||
|
title="Plan de Estudios no encontrado"
|
||||||
|
message="El plan de estudios que intentas consultar no existe o no tienes permisos para verlo."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
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('')
|
||||||
@@ -48,32 +72,49 @@ function RouteComponent() {
|
|||||||
'Especialidad',
|
'Especialidad',
|
||||||
]
|
]
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const persistChange = (patch: any) => {
|
||||||
|
mutate({ planId, patch })
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_CHARACTERS = 200
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||||
|
// 1. Permitir teclas de control (Borrar, flechas, etc.) siempre
|
||||||
|
const isControlKey =
|
||||||
|
e.key === 'Backspace' ||
|
||||||
|
e.key === 'Delete' ||
|
||||||
|
e.key.includes('Arrow') ||
|
||||||
|
e.metaKey ||
|
||||||
|
e.ctrlKey
|
||||||
|
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault() // Evita el salto de línea
|
e.preventDefault()
|
||||||
e.currentTarget.blur() // Quita el foco, lo que dispara el onBlur y "guarda" en el estado
|
e.currentTarget.blur()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Bloquear si excede los 200 caracteres y no es una tecla de control
|
||||||
|
const currentText = e.currentTarget.textContent || ''
|
||||||
|
if (currentText.length >= MAX_CHARACTERS && !isControlKey) {
|
||||||
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handlePaste = (e: React.ClipboardEvent<HTMLSpanElement>) => {
|
||||||
console.log('Guardando en DB...', { nombrePlan, nivelPlan })
|
e.preventDefault()
|
||||||
// Aquí iría tu mutation
|
const text = e.clipboardData.getData('text/plain')
|
||||||
setIsDirty(false)
|
const currentText = e.currentTarget.textContent || ''
|
||||||
}
|
|
||||||
|
|
||||||
|
// Calcular cuánto espacio queda
|
||||||
|
const remainingSpace = MAX_CHARACTERS - currentText.length
|
||||||
|
|
||||||
|
if (remainingSpace > 0) {
|
||||||
|
const slicedText = text.slice(0, remainingSpace)
|
||||||
|
document.execCommand('insertText', false, slicedText)
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
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">
|
||||||
@@ -86,38 +127,40 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto max-w-[1600px] space-y-8 p-8">
|
<div className="mx-auto max-w-400 space-y-8 p-8">
|
||||||
{/* Header del Plan */}
|
{/* 2. Header del Plan */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
/* ===== SKELETON ===== */
|
/* ===== SKELETON ===== */
|
||||||
<div className="mx-auto max-w-[1600px] 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 items-baseline gap-2 text-3xl font-bold tracking-tight text-slate-900">
|
<h1 className="flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight text-slate-900">
|
||||||
<span>{nivelPlan} en</span>
|
{/* El prefijo "Nivel en" lo mantenemos simple */}
|
||||||
|
<span className="shrink-0">{nivelPlan} en</span>
|
||||||
<span
|
<span
|
||||||
role="textbox"
|
role="textbox"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
contentEditable
|
contentEditable
|
||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
spellCheck={false} // Quita el subrayado rojo de error ortográfico
|
spellCheck={false}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onBlur={(e) =>
|
onPaste={handlePaste} // Añadido para controlar lo que pegan
|
||||||
setNombrePlan(e.currentTarget.textContent || '')
|
onBlur={(e) => {
|
||||||
|
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"
|
}}
|
||||||
style={{
|
// Clases añadidas: break-words y whitespace-pre-wrap para el wrap
|
||||||
WebkitTextDecoration: 'none',
|
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"
|
||||||
textDecoration: 'none',
|
style={{ textDecoration: 'none' }}
|
||||||
}} // Doble seguridad contra subrayados
|
|
||||||
>
|
>
|
||||||
{nombrePlan}
|
{nombrePlan}
|
||||||
</span>
|
</span>
|
||||||
@@ -129,20 +172,14 @@ 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 con Context Menu */}
|
{/* 3. Cards de Información */}
|
||||||
<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>
|
||||||
@@ -160,7 +197,9 @@ function RouteComponent() {
|
|||||||
key={n}
|
key={n}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNivelPlan(n)
|
setNivelPlan(n)
|
||||||
setIsDirty(true)
|
if (n !== data?.nivel) {
|
||||||
|
mutate({ planId, patch: { nivel: n } })
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{n}
|
{n}
|
||||||
@@ -182,21 +221,21 @@ 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]} // Cortamos la fecha para que no sea tan larga
|
value={data?.creado_en?.split('T')[0]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 4. Navegación de Tabs */}
|
{/* 4. Navegación de Tabs */}
|
||||||
<div className="scrollbar-hide overflow-x-auto border-b">
|
<div className="scrollbar-hide overflow-x-auto border-b">
|
||||||
<nav className="flex min-w-max gap-8">
|
<nav className="flex min-w-max gap-8">
|
||||||
<Tab to="/planes/$planId/datos" params={{ planId }}>
|
<Tab to="/planes/$planId/" params={{ planId }}>
|
||||||
Datos Generales
|
Datos Generales
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab to="/planes/$planId/mapa" params={{ planId }}>
|
<Tab to="/planes/$planId/mapa" params={{ planId }}>
|
||||||
Mapa Curricular
|
Mapa Curricular
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab to="/planes/$planId/asignaturas" params={{ planId }}>
|
<Tab to="/planes/$planId/asignaturas" params={{ planId }}>
|
||||||
Materias
|
Asignaturas
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab to="/planes/$planId/flujo" params={{ planId }}>
|
<Tab to="/planes/$planId/flujo" params={{ planId }}>
|
||||||
Flujo y Estados
|
Flujo y Estados
|
||||||
@@ -237,7 +276,7 @@ const InfoCard = forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
className={`flex h-[72px] w-full items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm transition-all ${
|
className={`flex h-18 w-full items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm transition-all ${
|
||||||
isEditable
|
isEditable
|
||||||
? 'cursor-pointer hover:border-teal-200 hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500/40'
|
? 'cursor-pointer hover:border-teal-200 hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500/40'
|
||||||
: ''
|
: ''
|
||||||
@@ -265,6 +304,7 @@ function Tab({
|
|||||||
}: {
|
}: {
|
||||||
to: string
|
to: string
|
||||||
params?: any
|
params?: any
|
||||||
|
search?: any
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -273,6 +313,9 @@ function Tab({
|
|||||||
params={params}
|
params={params}
|
||||||
className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800"
|
className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800"
|
||||||
activeProps={{ className: 'border-teal-600 text-teal-700 font-bold' }}
|
activeProps={{ className: 'border-teal-600 text-teal-700 font-bold' }}
|
||||||
|
activeOptions={{
|
||||||
|
exact: true,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, Outlet, 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 { useState, useMemo } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
import type { Materia } from '@/types/plan'
|
import type { Asignatura, AsignaturaStatus, TipoAsignatura } 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,45 +33,60 @@ import {
|
|||||||
import { usePlanAsignaturas, usePlanLineas } from '@/data'
|
import { usePlanAsignaturas, usePlanLineas } from '@/data'
|
||||||
|
|
||||||
// --- Configuración de Estilos ---
|
// --- Configuración de Estilos ---
|
||||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
const statusConfig: Record<
|
||||||
|
AsignaturaStatus,
|
||||||
|
{ label: string; className: string }
|
||||||
|
> = {
|
||||||
|
generando: {
|
||||||
|
label: 'Generando',
|
||||||
|
className:
|
||||||
|
'bg-slate-100 text-slate-600 animate-pulse [animation-duration:2s]',
|
||||||
|
},
|
||||||
borrador: { label: 'Borrador', className: 'bg-slate-100 text-slate-600' },
|
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<string, { label: string; className: string }> = {
|
const tipoConfig: Record<TipoAsignatura, { label: string; className: string }> =
|
||||||
obligatoria: { label: 'Obligatoria', className: 'bg-blue-100 text-blue-700' },
|
{
|
||||||
optativa: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' },
|
OBLIGATORIA: {
|
||||||
troncal: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' },
|
label: 'Obligatoria',
|
||||||
|
className: 'bg-blue-100 text-blue-700',
|
||||||
|
},
|
||||||
|
OPTATIVA: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' },
|
||||||
|
TRONCAL: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' },
|
||||||
|
OTRA: { label: 'Otra', className: 'bg-slate-100 text-slate-700' },
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Mapeadores de API ---
|
// --- Mapeadores de API ---
|
||||||
const mapAsignaturas = (asigApi: Array<any> = []): Array<Materia> => {
|
const mapAsignaturas = (
|
||||||
|
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 ?? 0,
|
creditos: asig.creditos,
|
||||||
ciclo: asig.numero_ciclo ?? null,
|
ciclo: asig.numero_ciclo ?? null,
|
||||||
lineaCurricularId: asig.linea_plan_id ?? null,
|
lineaCurricularId: asig.linea_plan_id ?? null,
|
||||||
tipo:
|
tipo: asig.tipo,
|
||||||
asig.tipo?.toLowerCase() === 'obligatoria' ? 'obligatoria' : 'optativa',
|
estado: asig.estado,
|
||||||
estado: 'borrador', // O el campo que venga de tu API
|
hd: asig.horas_academicas ?? 0,
|
||||||
hd: Math.floor((asig.horas_semana ?? 0) / 2),
|
hi: asig.horas_independientes ?? 0,
|
||||||
hi: Math.ceil((asig.horas_semana ?? 0) / 2),
|
prerrequisitos: [],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/planes/$planId/_detalle/asignaturas')({
|
export const Route = createFileRoute('/planes/$planId/_detalle/asignaturas')({
|
||||||
component: MateriasPage,
|
component: AsignaturasPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function MateriasPage() {
|
function AsignaturasPage() {
|
||||||
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: asignaturasApi, isLoading: loadingAsig } =
|
const { data: asignaturaApi, isLoading: loadingAsig } =
|
||||||
usePlanAsignaturas(planId)
|
usePlanAsignaturas(planId)
|
||||||
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
|
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId)
|
||||||
|
|
||||||
@@ -82,13 +97,13 @@ function MateriasPage() {
|
|||||||
const [filterLinea, setFilterLinea] = useState<string>('all')
|
const [filterLinea, setFilterLinea] = useState<string>('all')
|
||||||
|
|
||||||
// 3. Procesamiento de datos
|
// 3. Procesamiento de datos
|
||||||
const materias = useMemo(
|
const asignaturas = useMemo(
|
||||||
() => mapAsignaturas(asignaturasApi),
|
() => mapAsignaturas(asignaturaApi),
|
||||||
[asignaturasApi],
|
[asignaturaApi],
|
||||||
)
|
)
|
||||||
const lineas = useMemo(() => lineasApi || [], [lineasApi])
|
const lineas = useMemo(() => lineasApi || [], [lineasApi])
|
||||||
|
|
||||||
const filteredMaterias = materias.filter((m) => {
|
const filteredAsignaturas = asignaturas.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())
|
||||||
@@ -119,27 +134,34 @@ function MateriasPage() {
|
|||||||
<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">
|
||||||
Materias del Plan
|
Asignaturas del Plan
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
{materias.length} materias en total • {filteredMaterias.length}{' '}
|
{asignaturas.length} asignaturas en total •{' '}
|
||||||
filtradas
|
{filteredAsignaturas.length} filtradas
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm">
|
<Button
|
||||||
<Copy className="mr-2 h-4 w-4" /> Clonar
|
onClick={() => {
|
||||||
</Button>
|
console.log('planId desde asignaturas', planId)
|
||||||
<Button className="bg-emerald-700 hover:bg-emerald-800">
|
|
||||||
<Plus className="mr-2 h-4 w-4" /> Nueva Materia
|
navigate({
|
||||||
|
to: `/planes/${planId}/asignaturas/nueva`,
|
||||||
|
resetScroll: false,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Nueva Asignatura
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Barra de Filtros Avanzada */}
|
{/* Barra de Filtros Avanzada */}
|
||||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border bg-slate-50 p-4">
|
<div className="flex flex-wrap items-center gap-3 rounded-xl border bg-slate-50 p-4">
|
||||||
<div className="relative min-w-[240px] flex-1">
|
<div className="relative min-w-60 flex-1">
|
||||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Buscar por nombre o clave..."
|
placeholder="Buscar por nombre o clave..."
|
||||||
@@ -153,7 +175,7 @@ function MateriasPage() {
|
|||||||
<Filter className="text-muted-foreground mr-1 h-4 w-4" />
|
<Filter className="text-muted-foreground mr-1 h-4 w-4" />
|
||||||
|
|
||||||
<Select value={filterTipo} onValueChange={setFilterTipo}>
|
<Select value={filterTipo} onValueChange={setFilterTipo}>
|
||||||
<SelectTrigger className="w-[140px] bg-white">
|
<SelectTrigger className="w-35 bg-white">
|
||||||
<SelectValue placeholder="Tipo" />
|
<SelectValue placeholder="Tipo" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -164,7 +186,7 @@ function MateriasPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={filterEstado} onValueChange={setFilterEstado}>
|
<Select value={filterEstado} onValueChange={setFilterEstado}>
|
||||||
<SelectTrigger className="w-[140px] bg-white">
|
<SelectTrigger className="w-35 bg-white">
|
||||||
<SelectValue placeholder="Estado" />
|
<SelectValue placeholder="Estado" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -176,7 +198,7 @@ function MateriasPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={filterLinea} onValueChange={setFilterLinea}>
|
<Select value={filterLinea} onValueChange={setFilterLinea}>
|
||||||
<SelectTrigger className="w-[180px] bg-white">
|
<SelectTrigger className="w-45 bg-white">
|
||||||
<SelectValue placeholder="Línea" />
|
<SelectValue placeholder="Línea" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -196,23 +218,23 @@ function MateriasPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-slate-50/50">
|
<TableRow className="bg-slate-50/50">
|
||||||
<TableHead className="w-[120px]">Clave</TableHead>
|
<TableHead className="w-30">Clave</TableHead>
|
||||||
<TableHead>Nombre</TableHead>
|
<TableHead>Nombre</TableHead>
|
||||||
<TableHead className="text-center">Créditos</TableHead>
|
<TableHead className="text-center">Créditos</TableHead>
|
||||||
<TableHead className="text-center">Ciclo</TableHead>
|
<TableHead className="text-center">Ciclo</TableHead>
|
||||||
<TableHead>Línea Curricular</TableHead>
|
<TableHead>Línea Curricular</TableHead>
|
||||||
<TableHead>Tipo</TableHead>
|
<TableHead>Tipo</TableHead>
|
||||||
<TableHead>Estado</TableHead>
|
<TableHead>Estado</TableHead>
|
||||||
<TableHead className="w-[50px]"></TableHead>
|
<TableHead className="w-12.5"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredMaterias.length === 0 ? (
|
{filteredAsignaturas.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 materias</p>
|
<p className="font-medium">No se encontraron asignaturas</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>
|
||||||
@@ -220,59 +242,59 @@ function MateriasPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredMaterias.map((materia) => (
|
filteredAsignaturas.map((asignatura) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={materia.id}
|
key={asignatura.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: materia.id, // 👈 puede ser índice, consecutivo o slug
|
asignaturaId: asignatura.id, // 👈 puede ser índice, consecutivo o slug
|
||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
realId: materia.id, // 👈 ID largo oculto
|
realId: asignatura.id, // 👈 ID largo oculto
|
||||||
asignaturaId: materia.id,
|
asignaturaId: asignatura.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">
|
||||||
{materia.clave}
|
{asignatura.clave}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-semibold text-slate-700">
|
<TableCell className="font-semibold text-slate-700">
|
||||||
{materia.nombre}
|
{asignatura.nombre}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center font-medium">
|
<TableCell className="text-center font-medium">
|
||||||
{materia.creditos}
|
{asignatura.creditos}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{materia.ciclo ? (
|
{asignatura.ciclo ? (
|
||||||
<Badge variant="outline" className="font-normal">
|
<Badge variant="outline" className="font-normal">
|
||||||
Ciclo {materia.ciclo}
|
Ciclo {asignatura.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(materia.lineaCurricularId)}
|
{getLineaNombre(asignatura.lineaCurricularId)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`capitalize shadow-sm ${tipoConfig[materia.tipo]?.className}`}
|
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
|
||||||
>
|
>
|
||||||
{tipoConfig[materia.tipo]?.label}
|
{tipoConfig[asignatura.tipo].label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`capitalize shadow-sm ${statusConfig[materia.estado]?.className}`}
|
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
|
||||||
>
|
>
|
||||||
{statusConfig[materia.estado]?.label}
|
{statusConfig[asignatura.estado].label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -286,6 +308,7 @@ function MateriasPage() {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { createFileRoute } from '@tanstack/react-router'
|
|||||||
import { NuevaAsignaturaModalContainer } from '@/features/asignaturas/nueva/NuevaAsignaturaModalContainer'
|
import { NuevaAsignaturaModalContainer } from '@/features/asignaturas/nueva/NuevaAsignaturaModalContainer'
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
'/planes/$planId/asignaturas/_lista/nueva',
|
'/planes/$planId/_detalle/asignaturas/nueva',
|
||||||
)({
|
)({
|
||||||
component: NuevaAsignaturaModal,
|
component: NuevaAsignaturaModal,
|
||||||
})
|
})
|
||||||
|
|
||||||
function NuevaAsignaturaModal() {
|
function NuevaAsignaturaModal() {
|
||||||
const { planId } = Route.useParams()
|
const { planId } = Route.useParams()
|
||||||
|
console.log('planId desde nueva', planId)
|
||||||
return <NuevaAsignaturaModalContainer planId={planId} />
|
return <NuevaAsignaturaModalContainer planId={planId} />
|
||||||
}
|
}
|
||||||
@@ -12,10 +12,13 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
History,
|
History,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useEffect, 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,
|
||||||
@@ -23,7 +26,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { usePlanHistorial } from '@/data/hooks/usePlans'
|
import { usePlan, 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,
|
||||||
@@ -57,12 +60,23 @@ const getEventConfig = (tipo: string, campo: string) => {
|
|||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { planId } = Route.useParams()
|
const { planId } = Route.useParams()
|
||||||
const { data: rawData, isLoading } = usePlanHistorial(planId)
|
const [page, setPage] = useState(0)
|
||||||
|
const pageSize = 4
|
||||||
// ESTADOS PARA EL MODAL
|
const { data: response, isLoading } = usePlanHistorial(planId, page)
|
||||||
|
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) => {
|
||||||
@@ -77,10 +91,13 @@ 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 ${item.campo}`,
|
: `Se modificó el campo ${
|
||||||
|
structure?.[item.campo]?.title ?? item.campo
|
||||||
|
}`,
|
||||||
date: parseISO(item.cambiado_en),
|
date: parseISO(item.cambiado_en),
|
||||||
icon: config.icon,
|
icon: config.icon,
|
||||||
campo: item.campo,
|
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,
|
||||||
@@ -215,6 +232,46 @@ 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 */}
|
||||||
@@ -242,6 +299,8 @@ 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" />
|
||||||
@@ -250,9 +309,10 @@ 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">
|
||||||
@@ -272,6 +332,11 @@ 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>
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
|
/* 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 { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
||||||
|
|
||||||
|
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 = [
|
||||||
@@ -52,72 +67,284 @@ interface SelectedField {
|
|||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
interface EstructuraDefinicion {
|
||||||
const formatLabel = (key: string) => {
|
properties?: {
|
||||||
const result = key.replace(/_/g, ' ')
|
[key: string]: {
|
||||||
return result.charAt(0).toUpperCase() + result.slice(1)
|
title: string
|
||||||
|
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()
|
||||||
// Usamos el ID dinámico del plan o el hardcoded según tu necesidad
|
const { data } = usePlan(planId)
|
||||||
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()
|
||||||
|
|
||||||
// ESTADOS PRINCIPALES
|
const [activeChatId, setActiveChatId] = useState<string | undefined>(
|
||||||
const [messages, setMessages] = useState<Array<any>>([
|
undefined,
|
||||||
{
|
)
|
||||||
id: '1',
|
const { data: lastConversation, isLoading: isLoadingConv } =
|
||||||
role: 'assistant',
|
useConversationByPlan(planId)
|
||||||
content:
|
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
|
||||||
'¡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)
|
||||||
// 1. Transformar datos de la API para el menú de selección
|
const [editingChatId, setEditingChatId] = useState<string | null>(null)
|
||||||
|
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(() => {
|
||||||
if (!data?.datos) return []
|
const definicion = data?.estructuras_plan
|
||||||
return Object.entries(data.datos).map(([key, value]) => ({
|
?.definicion as EstructuraDefinicion
|
||||||
|
|
||||||
|
// Encadenamiento opcional para evitar errores si data es null
|
||||||
|
if (!definicion.properties) return []
|
||||||
|
|
||||||
|
return Object.entries(definicion.properties).map(([key, value]) => ({
|
||||||
key,
|
key,
|
||||||
label: formatLabel(key),
|
label: value.title,
|
||||||
value: String(value || ''),
|
value: String(value.description || ''),
|
||||||
}))
|
}))
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
// 2. Manejar el estado inicial si viene de "Datos Generales"
|
const filteredFields = useMemo(() => {
|
||||||
|
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?.prefill && availableFields.length > 0) {
|
if (!state?.campo_edit || availableFields.length === 0) return
|
||||||
// Intentamos encontrar qué campo es por su valor o si mandaste el fieldKey
|
|
||||||
const field = availableFields.find(
|
const field = availableFields.find(
|
||||||
(f) => f.value === state.prefill || f.key === state.fieldKey,
|
(f) =>
|
||||||
|
f.value === state.campo_edit.label || f.key === state.campo_edit.clave,
|
||||||
)
|
)
|
||||||
if (field && !selectedFields.find((sf) => sf.key === field.key)) {
|
if (!field) return
|
||||||
setSelectedFields([field])
|
setSelectedFields([field])
|
||||||
}
|
setInput((prev) =>
|
||||||
setInput(`Mejora este campo: ${field?.label} `)
|
injectFieldsIntoInput(prev || 'Mejora este campo:', [field]),
|
||||||
}
|
)
|
||||||
}, [availableFields])
|
}, [availableFields])
|
||||||
|
|
||||||
// 3. Lógica para el disparador ":"
|
const createNewChat = () => {
|
||||||
|
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('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,91 +352,259 @@ function RouteComponent() {
|
|||||||
input: string,
|
input: string,
|
||||||
fields: Array<SelectedField>,
|
fields: Array<SelectedField>,
|
||||||
) => {
|
) => {
|
||||||
const baseText = input.replace(/\[[^\]]+]/g, '').trim()
|
const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim()
|
||||||
|
|
||||||
const tags = fields.map((f) => `[${f.label}]`).join(' ')
|
if (fields.length === 0) return cleaned
|
||||||
|
|
||||||
return `${baseText} ${tags}`.trim()
|
const fieldLabels = fields.map((f) => f.label).join(', ')
|
||||||
|
|
||||||
|
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) => {
|
||||||
let nextFields
|
const isSelected = prev.find((f) => f.key === field.key)
|
||||||
|
return isSelected ? prev : [...prev, field]
|
||||||
if (prev.find((f) => f.key === field.key)) {
|
|
||||||
nextFields = prev.filter((f) => f.key !== field.key)
|
|
||||||
} else {
|
|
||||||
nextFields = [...prev, field]
|
|
||||||
}
|
|
||||||
|
|
||||||
setInput((prevInput) =>
|
|
||||||
injectFieldsIntoInput(prevInput || 'Mejora este campo:', nextFields),
|
|
||||||
)
|
|
||||||
|
|
||||||
return nextFields
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 2. Insertamos el nombre del campo en el texto exactamente donde estaba el ":"
|
||||||
|
setInput((prev) => {
|
||||||
|
// Reemplaza el último ":" y cualquier texto de filtro por el label del campo
|
||||||
|
const nuevoTexto = prev.replace(/:(\w*)$/, field.label)
|
||||||
|
return nuevoTexto + ' ' // Añadimos un espacio para que el usuario siga escribiendo
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Limpiamos estados de búsqueda
|
||||||
setShowSuggestions(false)
|
setShowSuggestions(false)
|
||||||
|
setFilterQuery('')
|
||||||
}
|
}
|
||||||
const buildPrompt = (userInput: string) => {
|
|
||||||
if (selectedFields.length === 0) return userInput
|
|
||||||
|
|
||||||
const fieldsText = selectedFields
|
const buildPrompt = (userInput: string, fields: Array<SelectedField>) => {
|
||||||
.map((f) => `- ${f.label}: ${f.value || '(sin contenido)'}`)
|
if (fields.length === 0) return userInput
|
||||||
.join('\n')
|
|
||||||
|
|
||||||
return `
|
return ` ${userInput}`
|
||||||
${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 finalPrompt = buildPrompt(rawText)
|
const currentFields = [...selectedFields]
|
||||||
|
const finalPrompt = buildPrompt(rawText, currentFields)
|
||||||
const userMsg = {
|
setIsSending(true)
|
||||||
id: Date.now().toString(),
|
setOptimisticMessage(rawText)
|
||||||
role: 'user',
|
|
||||||
content: finalPrompt,
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessages((prev) => [...prev, userMsg])
|
|
||||||
setInput('')
|
setInput('')
|
||||||
setIsLoading(true)
|
setSelectedArchivoIds([])
|
||||||
|
setSelectedRepositorioIds([])
|
||||||
setTimeout(() => {
|
setUploadedFiles([])
|
||||||
const mockText =
|
try {
|
||||||
'Sugerencia generada basada en los campos seleccionados...'
|
const payload: any = {
|
||||||
|
planId: planId,
|
||||||
setMessages((prev) => [
|
content: finalPrompt,
|
||||||
...prev,
|
conversacionId: activeChatId || undefined,
|
||||||
{
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentFields.length > 0) {
|
||||||
|
payload.campos = currentFields.map((f) => f.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 flex-wrap items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<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>
|
||||||
|
|
||||||
@@ -217,43 +612,98 @@ ${fieldsText}
|
|||||||
<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) => (
|
{!activeChatId &&
|
||||||
|
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 ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`}
|
className={`flex max-w-[85%] flex-col ${
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
className={`h-8 w-8 shrink-0 border ${msg.role === 'assistant' ? 'bg-teal-50' : 'bg-slate-200'}`}
|
|
||||||
>
|
|
||||||
<AvatarFallback className="text-[10px]">
|
|
||||||
{msg.role === 'assistant' ? (
|
|
||||||
<Sparkles size={14} className="text-teal-600" />
|
|
||||||
) : (
|
|
||||||
<UserCheck size={14} />
|
|
||||||
)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div
|
|
||||||
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm ${
|
|
||||||
msg.role === 'user'
|
msg.role === 'user'
|
||||||
? 'rounded-tr-none bg-teal-600 text-white'
|
? 'ml-auto items-end'
|
||||||
: 'rounded-tl-none border bg-white text-slate-700'
|
: 'items-start'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{msg.content}
|
<div
|
||||||
|
className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'rounded-tr-none bg-teal-600 text-white'
|
||||||
|
: `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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
))}
|
))}
|
||||||
{isLoading && (
|
|
||||||
<div className="flex gap-2 p-4">
|
{optimisticMessage && (
|
||||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400" />
|
<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 [animation-delay:0.2s]" />
|
<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.4s]" />
|
{optimisticMessage}
|
||||||
</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>
|
||||||
@@ -279,38 +729,47 @@ ${fieldsText}
|
|||||||
)}
|
)}
|
||||||
</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 (Se mantiene igual) */}
|
{/* MENÚ DE SUGERENCIAS FLOTANTE */}
|
||||||
{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 mb-2 w-full 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 text-slate-500 uppercase">
|
||||||
Seleccionar campo para IA
|
Resultados para "{filterQuery}"
|
||||||
</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) => (
|
{filteredFields.length > 0 ? (
|
||||||
|
filteredFields.map((field, index) => (
|
||||||
<button
|
<button
|
||||||
key={field.key}
|
key={field.key}
|
||||||
onClick={() => toggleField(field)}
|
onClick={() => toggleField(field)}
|
||||||
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50"
|
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
index === 0
|
||||||
|
? 'bg-teal-50 text-teal-700 ring-1 ring-teal-200 ring-inset'
|
||||||
|
: 'hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-slate-700 group-hover:text-teal-700">
|
<span>{field.label}</span>
|
||||||
{field.label}
|
{index === 0 && (
|
||||||
|
<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 (Tags) */}
|
{/* 1. Visualización de campos dentro del input ) */}
|
||||||
{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) => (
|
||||||
@@ -336,9 +795,32 @@ ${fieldsText}
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (showSuggestions) {
|
||||||
|
if (e.key === 'Tab' || e.key === 'Enter') {
|
||||||
|
if (filteredFields.length > 0) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSend()
|
toggleField(filteredFields[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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={
|
||||||
@@ -346,25 +828,28 @@ ${fieldsText}
|
|||||||
? '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={
|
||||||
(!input.trim() && selectedFields.length === 0) || isLoading
|
isSending || (!input.trim() && selectedFields.length === 0)
|
||||||
}
|
}
|
||||||
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"
|
||||||
>
|
>
|
||||||
<Send size={16} className="text-white" />
|
{isSending ? (
|
||||||
|
<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
|
||||||
@@ -386,6 +871,44 @@ ${fieldsText}
|
|||||||
))}
|
))}
|
||||||
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { usePlan } from '@/data'
|
import { usePlan, useUpdatePlanFields } 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/datos')({
|
export const Route = createFileRoute('/planes/$planId/_detalle/')({
|
||||||
component: DatosGeneralesPage,
|
component: DatosGeneralesPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -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) {
|
||||||
@@ -50,27 +50,49 @@ function DatosGeneralesPage() {
|
|||||||
|
|
||||||
// Efecto para transformar data?.datos en el arreglo de campos
|
// Efecto para transformar data?.datos en el arreglo de campos
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const properties = data?.estructuras_plan?.definicion?.properties
|
const definicion = data?.estructuras_plan?.definicion as any
|
||||||
|
const properties = definicion?.properties
|
||||||
|
const requiredOrder = definicion?.required as Array<string> | undefined
|
||||||
|
|
||||||
const valores = data?.datos as Record<string, unknown>
|
const valores = (data?.datos as Record<string, unknown>) || {}
|
||||||
|
|
||||||
if (properties && typeof properties === 'object') {
|
if (properties && typeof properties === 'object') {
|
||||||
const datosTransformados: Array<DatosGeneralesField> = Object.entries(
|
let keys = Object.keys(properties)
|
||||||
properties,
|
|
||||||
).map(([key, schema], index) => {
|
// Ordenar llaves basado en la lista "required" si existe
|
||||||
|
if (Array.isArray(requiredOrder)) {
|
||||||
|
keys = keys.sort((a, b) => {
|
||||||
|
const indexA = requiredOrder.indexOf(a)
|
||||||
|
const indexB = requiredOrder.indexOf(b)
|
||||||
|
// Si 'a' está en la lista y 'b' no -> 'a' primero (-1)
|
||||||
|
if (indexA !== -1 && indexB === -1) return -1
|
||||||
|
// Si 'b' está en la lista y 'a' no -> 'b' primero (1)
|
||||||
|
if (indexA === -1 && indexB !== -1) return 1
|
||||||
|
// Si ambos están, comparar índices
|
||||||
|
if (indexA !== -1 && indexB !== -1) return indexA - indexB
|
||||||
|
// Ninguno en la lista, mantener orden relativo
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const datosTransformados: Array<DatosGeneralesField> = keys.map(
|
||||||
|
(key, index) => {
|
||||||
|
const schema = properties[key]
|
||||||
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 || '',
|
||||||
holder: schema?.examples || '',
|
holder: schema?.examples || '',
|
||||||
value:
|
value:
|
||||||
rawValue !== undefined && rawValue !== null ? String(rawValue) : '',
|
rawValue !== undefined && rawValue !== null
|
||||||
|
? String(rawValue)
|
||||||
|
: '',
|
||||||
|
|
||||||
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'
|
||||||
@@ -79,43 +101,130 @@ function DatosGeneralesPage() {
|
|||||||
|
|
||||||
opciones: schema?.enum || [],
|
opciones: schema?.enum || [],
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
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 = (campo: DatosGeneralesField) => {
|
const handleEdit = (nuevoCampo: DatosGeneralesField) => {
|
||||||
setEditingId(campo.id)
|
// 1. SI YA ESTÁBAMOS EDITANDO OTRO CAMPO, GUARDAMOS EL ANTERIOR PRIMERO
|
||||||
setEditValue(campo.value)
|
if (editingId && editingId !== nuevoCampo.id) {
|
||||||
|
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
|
||||||
|
|
||||||
const handleSave = (id: string) => {
|
if (
|
||||||
// Actualizamos el estado local de la lista
|
typeof currentValue === 'object' &&
|
||||||
setCampos((prev) =>
|
currentValue !== null &&
|
||||||
prev.map((c) => (c.id === id ? { ...c, value: editValue } : c)),
|
'description' in currentValue
|
||||||
)
|
) {
|
||||||
setEditingId(null)
|
newValue = { ...currentValue, description: valor }
|
||||||
setEditValue('')
|
} else {
|
||||||
// toast.success('Cambios guardados localmente')
|
newValue = valor
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleIARequest = (descripcion: string) => {
|
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) =>
|
||||||
|
prev.map((c) => (c.id === campo.id ? { ...c, value: valor } : 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)
|
||||||
|
setEditValue('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIARequest = (clave: 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: {
|
||||||
prefill: descripcion,
|
campo_edit: clave,
|
||||||
} as any,
|
} as any,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -171,7 +280,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.value)}
|
onClick={() => handleIARequest(campo)}
|
||||||
>
|
>
|
||||||
<Sparkles size={14} />
|
<Sparkles size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -204,8 +313,8 @@ function DatosGeneralesPage() {
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={editValue}
|
value={editValue}
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
className="min-h-[120px]"
|
className="placeholder:text-muted-foreground/70 min-h-30 not-italic placeholder:italic"
|
||||||
placeholder={campo.holder}
|
placeholder={`Ej. ${campo.holder[0]}`}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -218,14 +327,14 @@ 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.id)}
|
onClick={() => handleSave(campo)}
|
||||||
>
|
>
|
||||||
<Check size={14} className="mr-1" /> Guardar
|
<Check size={14} className="mr-1" /> Guardar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="min-h-[100px]">
|
<div className="min-h-25">
|
||||||
{campo.value ? (
|
{campo.value ? (
|
||||||
<div className="text-sm leading-relaxed text-slate-600">
|
<div className="text-sm leading-relaxed text-slate-600">
|
||||||
{campo.tipo === 'lista' ? (
|
{campo.tipo === 'lista' ? (
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* 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 {
|
||||||
@@ -6,10 +7,13 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useMemo, useState, useEffect } from 'react'
|
import { useMemo, useState, useEffect, Fragment } from 'react'
|
||||||
|
|
||||||
import type { Materia, LineaCurricular } from '@/types/plan'
|
import type { TipoAsignatura } from '@/data'
|
||||||
|
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'
|
||||||
@@ -33,7 +37,15 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { usePlanAsignaturas, usePlanLineas } from '@/data'
|
import {
|
||||||
|
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 = (
|
||||||
@@ -47,21 +59,26 @@ const mapLineasToLineaCurricular = (
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapAsignaturasToMaterias = (asigApi: Array<any> = []): Array<Materia> => {
|
const mapAsignaturasToAsignaturas = (
|
||||||
return asigApi.map((asig) => ({
|
asigApi: Array<any> = [],
|
||||||
|
): 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 === 'OBLIGATORIA' ? 'obligatoria' : 'optativa',
|
tipo: asig.tipo,
|
||||||
estado: 'borrador',
|
estado: 'borrador',
|
||||||
orden: asig.orden_celda ?? 0,
|
orden: asig.orden_celda ?? 0,
|
||||||
hd: Math.floor((asig.horas_semana ?? 0) / 2),
|
// Mapeo directo de los nuevos campos de la API
|
||||||
hi: Math.ceil((asig.horas_semana ?? 0) / 2),
|
hd: asig.horas_academicas ?? 0,
|
||||||
|
hi: asig.horas_independientes ?? 0,
|
||||||
prerrequisitos: [],
|
prerrequisitos: [],
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const lineColors = [
|
const lineColors = [
|
||||||
@@ -104,13 +121,13 @@ function StatItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MateriaCardItem({
|
function AsignaturaCardItem({
|
||||||
materia,
|
asignatura,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
isDragging,
|
isDragging,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
materia: Materia
|
asignatura: Asignatura
|
||||||
onDragStart: (e: React.DragEvent, id: string) => void
|
onDragStart: (e: React.DragEvent, id: string) => void
|
||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
@@ -118,7 +135,7 @@ function MateriaCardItem({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => onDragStart(e, materia.id)}
|
onDragStart={(e) => onDragStart(e, asignatura.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
|
||||||
@@ -128,21 +145,21 @@ function MateriaCardItem({
|
|||||||
>
|
>
|
||||||
<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">
|
||||||
{materia.clave}
|
{asignatura.clave}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[materia.estado] || ''}`}
|
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[asignatura.estado] || ''}`}
|
||||||
>
|
>
|
||||||
{materia.estado}
|
{asignatura.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">
|
||||||
{materia.nombre}
|
{asignatura.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">
|
||||||
{materia.creditos} CR • HD:{materia.hd} • HI:{materia.hi}
|
{asignatura.creditos} CR • HD:{asignatura.hd} • HI:{asignatura.hi}
|
||||||
</span>
|
</span>
|
||||||
<GripVertical
|
<GripVertical
|
||||||
size={12}
|
size={12}
|
||||||
@@ -159,73 +176,107 @@ 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)
|
||||||
// 1. Fetch de Datos
|
const [ciclo, setCiclo] = useState(0)
|
||||||
const { data: asignaturasApi, isLoading: loadingAsig } =
|
const [editingLineaId, setEditingLineaId] = useState<string | null>(null)
|
||||||
|
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>>([])
|
||||||
// 2. Estado Local (Para interactividad)
|
|
||||||
const [materias, setMaterias] = useState<Array<Materia>>([])
|
|
||||||
const [lineas, setLineas] = useState<Array<LineaCurricular>>([])
|
const [lineas, setLineas] = useState<Array<LineaCurricular>>([])
|
||||||
const [draggedMateria, setDraggedMateria] = useState<string | null>(null)
|
const [draggedAsignatura, setDraggedAsignatura] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
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 [nombreNuevaLinea, setNombreNuevaLinea] = useState('') // Para el input de nombre personalizado
|
||||||
|
const { mutate: updateAsignatura } = useUpdateAsignatura()
|
||||||
|
const [seriacionValue, setSeriacionValue] = useState<string>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.numero_ciclos) {
|
||||||
|
setCiclo(data.numero_ciclos)
|
||||||
|
}
|
||||||
|
}, [data])
|
||||||
|
|
||||||
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 lineaNombreBase = l.nombre
|
const lineaExistente = l.nombre
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.normalize('NFD')
|
.normalize('NFD')
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
return lineaNombreBase === nombreParaComparar
|
return lineaExistente === nombreBusqueda
|
||||||
})
|
})
|
||||||
|
|
||||||
if (yaExiste) {
|
if (yaExiste) {
|
||||||
alert(`La línea "${nombreNormalizado}" ya existe.`)
|
alert(`La línea "${nombreNormalizado}" ya existe en este plan.`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const maxOrden = lineas.reduce((max, l) => Math.max(max, l.orden || 0), 0)
|
||||||
// 3. Validar Área Común (usando tu lógica previa)
|
createLinea(
|
||||||
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,
|
||||||
orden: lineas.length + 1,
|
plan_estudio_id: planId,
|
||||||
|
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()
|
||||||
|
|
||||||
setLineas([...lineas, nueva])
|
if (!nombreAFijar) {
|
||||||
|
setEditingLineaId(null)
|
||||||
if (esAreaComun) {
|
return
|
||||||
setHasAreaComun(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setNombreNuevaLinea('') // Limpiar input
|
updateLineaApi(
|
||||||
|
{
|
||||||
|
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) =>
|
||||||
@@ -234,57 +285,118 @@ function MapaCurricularPage() {
|
|||||||
)
|
)
|
||||||
}, [lineas])
|
}, [lineas])
|
||||||
|
|
||||||
// 3. Sincronizar API -> Estado Local
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (asignaturasApi) setMaterias(mapAsignaturasToMaterias(asignaturasApi))
|
if (asignaturaApi)
|
||||||
}, [asignaturasApi])
|
setAsignaturas(mapAsignaturasToAsignaturas(asignaturaApi))
|
||||||
|
}, [asignaturaApi])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi))
|
if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi))
|
||||||
}, [lineasApi])
|
}, [lineasApi])
|
||||||
|
|
||||||
const ciclosTotales = 9
|
const ciclosTotales = Number(ciclo)
|
||||||
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
|
||||||
|
|
||||||
// Nuevo estado para controlar los datos temporales del modal de edición
|
// Solo números, máximo 3 cifras
|
||||||
const [editingData, setEditingData] = useState<Materia | null>(null)
|
const regex = /^\d{1,3}$/
|
||||||
|
|
||||||
// 1. FUNCION DE GUARDAR MODAL
|
if (!regex.test(value)) return null
|
||||||
|
|
||||||
|
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
|
||||||
console.log(materias)
|
setAsignaturas((prev) =>
|
||||||
|
|
||||||
setMaterias((prev) =>
|
|
||||||
prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)),
|
prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)),
|
||||||
)
|
)
|
||||||
|
type AsignaturaPatch = {
|
||||||
|
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(
|
||||||
|
{ asignaturaId: editingData.id, patch: patch as any },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
setIsEditModalOpen(false)
|
setIsEditModalOpen(false)
|
||||||
|
// Opcional: Mostrar un toast de éxito
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Error al guardar:', error)
|
||||||
|
alert('Hubo un error al guardar los cambios.')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
const unassignedAsignaturas = asignaturas.filter(
|
||||||
// 2. MODIFICACIÓN: Zona de soltado siempre visible
|
(m) => m.ciclo === null || m.lineaCurricularId === null,
|
||||||
// Cambiamos la condición: Mostramos la sección si hay materias sin asignar
|
)
|
||||||
// O si simplemente queremos tener el "depósito" disponible.
|
|
||||||
const unassignedMaterias = materias.filter((m) => m.ciclo === null)
|
|
||||||
|
|
||||||
// --- Lógica de Gestión ---
|
|
||||||
const agregarLinea = (nombre: string) => {
|
|
||||||
const nueva = { id: crypto.randomUUID(), nombre, orden: lineas.length + 1 }
|
|
||||||
setLineas([...lineas, nueva])
|
|
||||||
}
|
|
||||||
|
|
||||||
const borrarLinea = (id: string) => {
|
const borrarLinea = (id: string) => {
|
||||||
setMaterias((prev) =>
|
if (
|
||||||
prev.map((m) =>
|
!confirm(
|
||||||
m.lineaCurricularId === id
|
'¿Estás seguro de eliminar esta línea? Las materias asignadas volverán a la bandeja de entrada.',
|
||||||
? { ...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 materias
|
return asignaturas
|
||||||
.filter((m) => m.ciclo === ciclo)
|
.filter((m) => m.ciclo === ciclo)
|
||||||
.reduce(
|
.reduce(
|
||||||
(acc, m) => ({
|
(acc, m) => ({
|
||||||
@@ -297,8 +409,8 @@ function MapaCurricularPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getSubtotalLinea = (lineaId: string) => {
|
const getSubtotalLinea = (lineaId: string) => {
|
||||||
return materias
|
return asignaturas
|
||||||
.filter((m) => m.lineaCurricularId === lineaId && m.ciclo !== null)
|
.filter((m) => m.lineaCurricularId === lineaId && m.ciclo !== null) // Aseguramos que pertenezca a la línea Y tenga ciclo
|
||||||
.reduce(
|
.reduce(
|
||||||
(acc, m) => ({
|
(acc, m) => ({
|
||||||
cr: acc.cr + (m.creditos || 0),
|
cr: acc.cr + (m.creditos || 0),
|
||||||
@@ -310,7 +422,7 @@ function MapaCurricularPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDragStart = (e: React.DragEvent, id: string) => {
|
const handleDragStart = (e: React.DragEvent, id: string) => {
|
||||||
setDraggedMateria(id)
|
setDraggedAsignatura(id)
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
}
|
}
|
||||||
const handleDragOver = (e: React.DragEvent) => e.preventDefault()
|
const handleDragOver = (e: React.DragEvent) => e.preventDefault()
|
||||||
@@ -320,21 +432,37 @@ function MapaCurricularPage() {
|
|||||||
lineaId: string | null,
|
lineaId: string | null,
|
||||||
) => {
|
) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (draggedMateria) {
|
if (draggedAsignatura) {
|
||||||
setMaterias((prev) =>
|
// 1. Actualización optimista del UI
|
||||||
|
setAsignaturas((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === draggedMateria
|
m.id === draggedAsignatura
|
||||||
? { ...m, ciclo, lineaCurricularId: lineaId }
|
? { ...m, ciclo, lineaCurricularId: lineaId }
|
||||||
: m,
|
: m,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
setDraggedMateria(null)
|
const patch = {
|
||||||
|
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(
|
||||||
() =>
|
() =>
|
||||||
materias.reduce(
|
asignaturas.reduce(
|
||||||
(acc, m) => {
|
(acc, m) => {
|
||||||
if (m.ciclo !== null) {
|
if (m.ciclo !== null) {
|
||||||
acc.cr += m.creditos || 0
|
acc.cr += m.creditos || 0
|
||||||
@@ -345,9 +473,36 @@ function MapaCurricularPage() {
|
|||||||
},
|
},
|
||||||
{ cr: 0, hd: 0, hi: 0 },
|
{ cr: 0, hd: 0, hi: 0 },
|
||||||
),
|
),
|
||||||
[materias],
|
[asignaturas],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
@@ -358,14 +513,22 @@ 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 materias de la petición por línea y ciclo
|
Organiza las asignaturas 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">
|
||||||
{materias.filter((m) => !m.ciclo).length > 0 && (
|
<Button className="bg-teal-700 text-white hover:bg-teal-800">
|
||||||
|
<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>
|
||||||
@@ -425,65 +588,94 @@ 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="mb-4 grid gap-3"
|
className="grid gap-3"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
|
gridTemplateColumns: `220px repeat(${ciclosTotales}, minmax(auto, 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={n}
|
key={`header-${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
|
||||||
key={linea.id}
|
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${
|
||||||
className="mb-3 grid gap-3"
|
lineColors[idx % lineColors.length]
|
||||||
style={{
|
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
|
||||||
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'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
{linea.nombre}
|
||||||
className={`flex items-center justify-between rounded-xl border-l-4 p-4 ${lineColors[idx % lineColors.length]}`}
|
</span>
|
||||||
|
</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
|
||||||
size={14}
|
|
||||||
className="cursor-pointer text-slate-400 hover:text-red-500"
|
|
||||||
onClick={() => borrarLinea(linea.id)}
|
onClick={() => borrarLinea(linea.id)}
|
||||||
|
className="..."
|
||||||
|
size={14}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{ciclosArray.map((ciclo) => (
|
{ciclosArray.map((ciclo) => (
|
||||||
<div
|
<div
|
||||||
key={ciclo}
|
key={`${linea.id}-${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"
|
||||||
>
|
>
|
||||||
{materias
|
{asignaturas
|
||||||
.filter(
|
.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.ciclo === ciclo && m.lineaCurricularId === linea.id,
|
m.ciclo === ciclo &&
|
||||||
|
m.lineaCurricularId === linea.id,
|
||||||
)
|
)
|
||||||
.map((m) => (
|
.map((m) => (
|
||||||
<MateriaCardItem
|
<AsignaturaCardItem
|
||||||
key={m.id}
|
key={m.id}
|
||||||
materia={m}
|
asignatura={m}
|
||||||
isDragging={draggedMateria === m.id}
|
isDragging={draggedAsignatura === m.id}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingData(m)
|
setEditingData(m)
|
||||||
@@ -499,24 +691,21 @@ function MapaCurricularPage() {
|
|||||||
<div>HD: {sub.hd}</div>
|
<div>HD: {sub.hd}</div>
|
||||||
<div>HI: {sub.hi}</div>
|
<div>HI: {sub.hi}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div
|
<div className="col-span-full my-2 border-t border-slate-200"></div>
|
||||||
className="mt-6 grid gap-3 border-t pt-4"
|
|
||||||
style={{
|
<div className="self-center p-2 font-bold text-slate-600">
|
||||||
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={ciclo}
|
key={`footer-${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>
|
||||||
@@ -526,6 +715,7 @@ 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>
|
||||||
@@ -534,35 +724,34 @@ function MapaCurricularPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Materias Sin Asignar */}
|
{/* Asignaturas 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 / Materias sin asignar
|
Bandeja de Entrada / Asignaturas sin asignar
|
||||||
</h3>
|
</h3>
|
||||||
<Badge variant="secondary">{unassignedMaterias.length}</Badge>
|
<Badge variant="secondary">{unassignedAsignaturas.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400">
|
<p className="text-xs text-slate-400">
|
||||||
Arrastra una materia aquí para quitarla del mapa
|
Arrastra una asignatura 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 ${
|
||||||
draggedMateria
|
draggedAsignatura
|
||||||
? '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
|
||||||
>
|
>
|
||||||
{unassignedMaterias.map((m) => (
|
{unassignedAsignaturas.map((m) => (
|
||||||
<div key={m.id} className="w-[200px]">
|
<div key={m.id} className="w-[200px]">
|
||||||
<MateriaCardItem
|
<AsignaturaCardItem
|
||||||
materia={m}
|
asignatura={m}
|
||||||
isDragging={draggedMateria === m.id}
|
isDragging={draggedAsignatura === 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
|
||||||
@@ -571,9 +760,9 @@ function MapaCurricularPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{unassignedMaterias.length === 0 && (
|
{unassignedAsignaturas.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 materias pendientes. Arrastra una materia aquí para
|
No hay asignaturas pendientes. Arrastra una asignatura aquí para
|
||||||
desasignarla.
|
desasignarla.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -582,10 +771,13 @@ function MapaCurricularPage() {
|
|||||||
|
|
||||||
{/* Modal de Edición */}
|
{/* Modal de Edición */}
|
||||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||||
<DialogContent className="sm:max-w-[550px]">
|
<DialogContent
|
||||||
|
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 Materia
|
Editar Asignatura
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -599,6 +791,7 @@ 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 })
|
||||||
@@ -610,6 +803,7 @@ 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 })
|
||||||
@@ -626,13 +820,17 @@ 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: Number(e.target.value),
|
creditos: val === '' ? 0 : Number(val),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -642,12 +840,15 @@ 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">
|
||||||
@@ -657,12 +858,15 @@ 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>
|
||||||
@@ -728,32 +932,65 @@ function MapaCurricularPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fila 4: Seriación (Igual a tu imagen) */}
|
{/* Fila 4: Seriación (Prerrequisitos) */}
|
||||||
<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 materia..." />
|
<SelectValue placeholder="Seleccionar asignatura..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{materias.map((m) => (
|
<SelectItem value="none">-- Sin Seriación --</SelectItem>
|
||||||
<SelectItem key={m.id} value={m.clave}>
|
|
||||||
{m.nombre}
|
{asignaturas
|
||||||
|
.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">
|
|
||||||
{/* Aquí usamos el array vacío que inicializamos en el mapeador */}
|
{/* Visualización de los prerrequisitos seleccionados */}
|
||||||
|
<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} <span className="ml-1 cursor-pointer">×</span>
|
{pre}
|
||||||
|
<button
|
||||||
|
className="ml-1 hover:text-red-500"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingData({
|
||||||
|
...editingData,
|
||||||
|
prerrequisitos: editingData.prerrequisitos.filter(
|
||||||
|
(p) => p !== pre,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -766,7 +1003,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 })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -774,8 +1011,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>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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 />
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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 />
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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 />
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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 />
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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 />
|
||||||
|
}
|
||||||
@@ -1,18 +1,273 @@
|
|||||||
import MateriaDetailPage from '@/components/asignaturas/detalle/MateriaDetailPage'
|
import {
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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(
|
export const Route = createFileRoute(
|
||||||
'/planes/$planId/asignaturas/$asignaturaId'
|
'/planes/$planId/asignaturas/$asignaturaId',
|
||||||
)({
|
)({
|
||||||
component: RouteComponent,
|
component: AsignaturaLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
function RouteComponent() {
|
function EditableHeaderField({
|
||||||
//const { planId, asignaturaId } = Route.useParams()
|
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 (
|
return (
|
||||||
<div>
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||||
<MateriaDetailPage></MateriaDetailPage>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/planes/$planId/asignaturas/_lista')({
|
|
||||||
component: RouteComponent,
|
|
||||||
})
|
|
||||||
|
|
||||||
function RouteComponent() {
|
|
||||||
return (
|
|
||||||
<main className="bg-background min-h-screen w-full">
|
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 md:px-6 lg:px-8">
|
|
||||||
<h1 className="text-foreground text-2xl font-semibold">Asignaturas</h1>
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/planes/$planId/asignaturas')({
|
|
||||||
component: AsignaturasLayout,
|
|
||||||
})
|
|
||||||
|
|
||||||
function AsignaturasLayout() {
|
|
||||||
return <Outlet />
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/planes/$planId/')({
|
|
||||||
beforeLoad: ({ params }) => {
|
|
||||||
throw redirect({
|
|
||||||
to: '/planes/$planId/datos',
|
|
||||||
params,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -125,7 +125,11 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate({ to: '/planes/nuevo' })}
|
onClick={() => {
|
||||||
|
console.log('planId')
|
||||||
|
|
||||||
|
navigate({ to: '/planes/nuevo', resetScroll: false })
|
||||||
|
}}
|
||||||
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
<Icons.Plus /> Nuevo plan de estudios
|
<Icons.Plus /> Nuevo plan de estudios
|
||||||
119
src/types/asignatura.ts
Normal file
119
src/types/asignatura.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Tables } from './supabase'
|
||||||
|
|
||||||
export type PlanStatus =
|
export type PlanStatus =
|
||||||
| 'borrador'
|
| 'borrador'
|
||||||
| 'revision'
|
| 'revision'
|
||||||
@@ -12,9 +14,9 @@ export type TipoPlan =
|
|||||||
| 'Doctorado'
|
| 'Doctorado'
|
||||||
| 'Especialidad'
|
| 'Especialidad'
|
||||||
|
|
||||||
export type TipoMateria = 'obligatoria' | 'optativa' | 'troncal'
|
export type TipoAsignatura = Tables<'asignaturas'>['tipo']
|
||||||
|
|
||||||
export type MateriaStatus = 'borrador' | 'revisada' | 'aprobada'
|
export type AsignaturaStatus = Tables<'asignaturas'>['estado']
|
||||||
|
|
||||||
export interface Facultad {
|
export interface Facultad {
|
||||||
id: string
|
id: string
|
||||||
@@ -36,15 +38,15 @@ export interface LineaCurricular {
|
|||||||
color?: string
|
color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Materia {
|
export interface Asignatura {
|
||||||
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: TipoMateria
|
tipo: TipoAsignatura
|
||||||
estado: MateriaStatus
|
estado: AsignaturaStatus
|
||||||
orden?: number
|
orden?: number
|
||||||
hd: number // <--- Añadir
|
hd: number // <--- Añadir
|
||||||
hi: number // <--- Añadir
|
hi: number // <--- Añadir
|
||||||
@@ -103,7 +105,7 @@ export interface DocumentoPlan {
|
|||||||
export type PlanTab =
|
export type PlanTab =
|
||||||
| 'datos-generales'
|
| 'datos-generales'
|
||||||
| 'mapa-curricular'
|
| 'mapa-curricular'
|
||||||
| 'materias'
|
| 'asignaturas'
|
||||||
| 'flujo'
|
| 'flujo'
|
||||||
| 'ia'
|
| 'ia'
|
||||||
| 'documento'
|
| 'documento'
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
|||||||
v2.67.1
|
v2.75.0
|
||||||
Reference in New Issue
Block a user