Compare commits
56 Commits
da218b1f92
...
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 |
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
|
||||||
14
bun.lock
14
bun.lock
@@ -20,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",
|
||||||
@@ -441,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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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
|
||||||
|
|||||||
@@ -1,30 +1,11 @@
|
|||||||
import {
|
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
||||||
createFileRoute,
|
import { Pencil, Sparkles } from 'lucide-react'
|
||||||
Link,
|
import { useState, useEffect } from 'react'
|
||||||
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 type { AsignaturaDetail } from '@/data'
|
||||||
import { ContenidoTematico } from './ContenidoTematico'
|
|
||||||
import { DocumentoSEPTab } from './DocumentoSEPTab'
|
|
||||||
import { HistorialTab } from './HistorialTab'
|
|
||||||
import { IAAsignaturaTab } from './IAAsignaturaTab'
|
|
||||||
|
|
||||||
import type {
|
|
||||||
CampoEstructura,
|
|
||||||
IAMessage,
|
|
||||||
IASugerencia,
|
|
||||||
} from '@/types/asignatura'
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
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 { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -32,12 +13,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { useSubject } from '@/data/hooks/useSubjects'
|
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
|
||||||
import {
|
|
||||||
mockAsignatura,
|
|
||||||
mockEstructura,
|
|
||||||
mockDocumentoSep,
|
|
||||||
} from '@/data/mockAsignaturaData'
|
|
||||||
|
|
||||||
export interface BibliografiaEntry {
|
export interface BibliografiaEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -61,45 +37,54 @@ export interface AsignaturaResponse {
|
|||||||
datos: AsignaturaDatos
|
datos: AsignaturaDatos
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditableHeaderField({
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
value,
|
return typeof value === 'object' && value !== null && !Array.isArray(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>) => {
|
function parseContenidoTematicoToPlainText(value: unknown): string {
|
||||||
if (e.key === 'Enter') {
|
if (!Array.isArray(value)) return ''
|
||||||
e.preventDefault()
|
|
||||||
e.currentTarget.blur() // Forzamos el guardado al presionar Enter
|
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 (
|
return blocks.join('\n\n').trimEnd()
|
||||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
}
|
||||||
<span
|
|
||||||
contentEditable
|
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
|
||||||
suppressContentEditableWarning={true} // Evita el warning de React por tener hijos y contentEditable
|
contenido_tematico: parseContenidoTematicoToPlainText,
|
||||||
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(
|
export const Route = createFileRoute(
|
||||||
@@ -109,299 +94,67 @@ export const Route = createFileRoute(
|
|||||||
})
|
})
|
||||||
|
|
||||||
export default function AsignaturaDetailPage() {
|
export default function AsignaturaDetailPage() {
|
||||||
const routerState = useRouterState()
|
|
||||||
const state = routerState.location.state as any
|
|
||||||
const { asignaturaId } = useParams({
|
const { asignaturaId } = useParams({
|
||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
})
|
})
|
||||||
const { planId } = useParams({
|
const { data: asignaturaApi } = useSubject(asignaturaId)
|
||||||
from: '/planes/$planId/asignaturas/$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,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
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 AsignaturaDetailPage
|
|
||||||
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 ---------- */
|
/* ---------- sincronizar API ---------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (asignaturasApi?.datos) {
|
if (asignaturaApi) setAsignatura(asignaturaApi)
|
||||||
setDatosGenerales(asignaturasApi)
|
}, [asignaturaApi])
|
||||||
}
|
|
||||||
}, [asignaturasApi])
|
|
||||||
|
|
||||||
// 2. Funciones de manejo para la IA
|
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
|
||||||
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" />
|
|
||||||
<span className="text-blue-100">
|
|
||||||
{asignaturasApi?.planes_estudio?.datos?.nombre || ''}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<span className="text-blue-100">
|
|
||||||
{asignaturasApi?.planes_estudio?.carreras?.facultades
|
|
||||||
?.nombre || ''}
|
|
||||||
</span>
|
|
||||||
</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 asignatura</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">
|
|
||||||
<IAAsignaturaTab
|
|
||||||
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}
|
|
||||||
asignatura={mockAsignatura}
|
|
||||||
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({
|
function DatosGenerales({
|
||||||
data,
|
onPersistDato,
|
||||||
isLoading,
|
}: {
|
||||||
asignaturaId,
|
onPersistDato: (clave: string, value: string) => void
|
||||||
}: DatosGeneralesProps) {
|
}) {
|
||||||
const formatTitle = (key: string): string =>
|
const { asignaturaId } = useParams({
|
||||||
key.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase())
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
|
||||||
|
|
||||||
// 1. Extraemos la definición de la estructura (los metadatos)
|
// 1. Extraemos la definición de la estructura (los metadatos)
|
||||||
const structureProps =
|
const definicionRaw = data?.estructuras_asignatura?.definicion
|
||||||
data?.estructuras_asignatura?.definicion?.properties || {}
|
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)
|
// 2. Extraemos los valores reales (el contenido redactado)
|
||||||
const valoresActuales = data?.datos || {}
|
const datosRaw = data?.datos
|
||||||
|
const valoresActuales = isRecord(datosRaw)
|
||||||
|
? (datosRaw as Record<string, any>)
|
||||||
|
: {}
|
||||||
|
if (isLoading) return <p>Cargando información...</p>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
|
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
|
||||||
@@ -421,15 +174,16 @@ function DatosGenerales({
|
|||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
{/* Columna Principal (Más ancha) */}
|
{/* Columna Principal (Más ancha) */}
|
||||||
<div className="space-y-6 md:col-span-2">
|
<div className="space-y-6 md:col-span-2">
|
||||||
{isLoading && <p>Cargando información...</p>}
|
{Object.entries(structureProps).map(
|
||||||
|
|
||||||
{!isLoading &&
|
|
||||||
Object.entries(structureProps).map(
|
|
||||||
([key, config]: [string, any]) => {
|
([key, config]: [string, any]) => {
|
||||||
// 1. METADATOS (Vienen de structureProps -> config)
|
|
||||||
const cardTitle = config.title || key
|
const cardTitle = config.title || key
|
||||||
const description = config.description || ''
|
const description = config.description || ''
|
||||||
|
|
||||||
|
const xColumn =
|
||||||
|
typeof config?.['x-column'] === 'string'
|
||||||
|
? config['x-column']
|
||||||
|
: undefined
|
||||||
|
|
||||||
// Obtenemos el placeholder del arreglo 'examples' de la estructura
|
// Obtenemos el placeholder del arreglo 'examples' de la estructura
|
||||||
const placeholder =
|
const placeholder =
|
||||||
config.examples && config.examples.length > 0
|
config.examples && config.examples.length > 0
|
||||||
@@ -438,11 +192,15 @@ function DatosGenerales({
|
|||||||
|
|
||||||
const valActual = valoresActuales[key]
|
const valActual = valoresActuales[key]
|
||||||
|
|
||||||
const isContentEmpty =
|
let currentContent = valActual ?? ''
|
||||||
!valActual?.description ||
|
|
||||||
valActual.description === config.description
|
|
||||||
|
|
||||||
const currentContent = valActual ?? ''
|
if (xColumn) {
|
||||||
|
const rawValue = (data as any)?.[xColumn]
|
||||||
|
const parser = columnParsers[xColumn]
|
||||||
|
currentContent = parser
|
||||||
|
? parser(rawValue)
|
||||||
|
: String(rawValue ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InfoCard
|
<InfoCard
|
||||||
@@ -450,10 +208,11 @@ function DatosGenerales({
|
|||||||
key={key}
|
key={key}
|
||||||
clave={key}
|
clave={key}
|
||||||
title={cardTitle}
|
title={cardTitle}
|
||||||
initialContent={currentContent} // Si es igual a la descripción de la SEP, pasamos vacío
|
initialContent={currentContent}
|
||||||
placeholder={placeholder} // Aquí irá "Primer semestre", "MAT-101", etc.
|
xColumn={xColumn}
|
||||||
description={description} // El texto largo de "Indicar el ciclo..."
|
placeholder={placeholder}
|
||||||
onEnhanceAI={(contenido) => console.log(contenido)}
|
description={description}
|
||||||
|
onPersist={(clave, value) => onPersistDato(clave, value)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -506,9 +265,11 @@ interface InfoCardProps {
|
|||||||
initialContent: any
|
initialContent: any
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
description?: string
|
description?: string
|
||||||
|
xColumn?: string
|
||||||
required?: boolean // Nueva prop para el asterisco
|
required?: boolean // Nueva prop para el asterisco
|
||||||
type?: 'text' | 'requirements' | 'evaluation'
|
type?: 'text' | 'requirements' | 'evaluation'
|
||||||
onEnhanceAI?: (content: any) => void
|
onEnhanceAI?: (content: any) => void
|
||||||
|
onPersist?: (clave: string, value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoCard({
|
function InfoCard({
|
||||||
@@ -518,13 +279,18 @@ function InfoCard({
|
|||||||
initialContent,
|
initialContent,
|
||||||
placeholder,
|
placeholder,
|
||||||
description,
|
description,
|
||||||
|
xColumn,
|
||||||
required,
|
required,
|
||||||
type = 'text',
|
type = 'text',
|
||||||
|
onPersist,
|
||||||
}: InfoCardProps) {
|
}: InfoCardProps) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [data, setData] = useState(initialContent)
|
const [data, setData] = useState(initialContent)
|
||||||
const [tempText, setTempText] = useState(initialContent)
|
const [tempText, setTempText] = useState(initialContent)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { planId } = useParams({
|
||||||
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setData(initialContent)
|
setData(initialContent)
|
||||||
@@ -532,21 +298,29 @@ function InfoCard({
|
|||||||
}, [initialContent])
|
}, [initialContent])
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
console.log('clave, valor:', clave, String(tempText ?? ''))
|
||||||
|
|
||||||
setData(tempText)
|
setData(tempText)
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
// Aquí iría tu lógica de guardado a la DB
|
|
||||||
|
if (type === 'text' && clave && onPersist) {
|
||||||
|
onPersist(clave, String(tempText ?? ''))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleIARequest = (campoClave: string) => {
|
const handleIARequest = (campoClave: string) => {
|
||||||
console.log(placeholder)
|
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({
|
navigate({
|
||||||
to: '/planes/$planId/asignaturas/$asignaturaId',
|
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
|
||||||
params: { asignaturaId: asignaturaId! },
|
params: { planId, asignaturaId: asignaturaId! },
|
||||||
state: {
|
state: {
|
||||||
activeTab: 'ia',
|
activeTab: 'ia',
|
||||||
prefillCampo: campoClave,
|
prefillCampo: campoClave,
|
||||||
prefillContenido: data,
|
prefillContenido: data,
|
||||||
|
_ts: Date.now(),
|
||||||
} as any,
|
} as any,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -586,7 +360,7 @@ function InfoCard({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
|
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
|
||||||
onClick={() => handleIARequest(clave)}
|
onClick={() => clave && handleIARequest(clave)}
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -600,7 +374,21 @@ function InfoCard({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-slate-400"
|
className="h-8 w-8 text-slate-400"
|
||||||
onClick={() => setIsEditing(true)}
|
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" />
|
<Pencil className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -620,7 +408,7 @@ function InfoCard({
|
|||||||
value={tempText}
|
value={tempText}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={(e) => setTempText(e.target.value)}
|
onChange={(e) => setTempText(e.target.value)}
|
||||||
className="min-h-[120px] text-sm leading-relaxed"
|
className="min-h-30 text-sm leading-relaxed"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||||
|
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||||
|
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||||
|
import { useParams } from '@tanstack/react-router'
|
||||||
import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
|
import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -30,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/mockAsignaturaData';
|
|
||||||
|
|
||||||
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 {
|
||||||
@@ -76,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: loadinasignatura } =
|
// --- 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)
|
||||||
@@ -102,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 = (
|
||||||
@@ -133,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">
|
||||||
@@ -176,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>
|
||||||
@@ -257,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>
|
||||||
@@ -416,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">
|
||||||
@@ -451,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'
|
||||||
DocumentoAsignatura,
|
import { Card } from '@/components/ui/card'
|
||||||
Asignatura,
|
|
||||||
AsignaturaStructure,
|
|
||||||
} from '@/types/asignatura'
|
|
||||||
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: DocumentoAsignatura | null
|
pdfUrl: string | null
|
||||||
asignatura: Asignatura
|
isLoading: boolean
|
||||||
estructura: AsignaturaStructure
|
onDownload: () => void
|
||||||
datosGenerales: Record<string, any>
|
|
||||||
onRegenerate: () => void
|
onRegenerate: () => void
|
||||||
isRegenerating: boolean
|
isRegenerating: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentoSEPTab({
|
export function DocumentoSEPTab({
|
||||||
documento,
|
pdfUrl,
|
||||||
asignatura,
|
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 asignatura. 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,308 +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 - {asignatura.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">
|
|
||||||
{asignatura.nombre}
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Clave: {asignatura.clave} | Créditos:{' '}
|
|
||||||
{asignatura.creditos || 'N/A'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Datos de la institución */}
|
|
||||||
<div className="space-y-1 text-sm">
|
|
||||||
<p>
|
|
||||||
<strong>Carrera:</strong> {asignatura.carrera}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Facultad:</strong> {asignatura.facultad}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Plan de estudios:</strong>{' '}
|
|
||||||
{asignatura.planNombre}
|
|
||||||
</p>
|
|
||||||
{asignatura.ciclo && (
|
|
||||||
<p>
|
|
||||||
<strong>Ciclo:</strong> {asignatura.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,17 +13,14 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
|
||||||
import type {
|
import type { IAMessage, IASugerencia } from '@/types/asignatura'
|
||||||
IAMessage,
|
|
||||||
IASugerencia,
|
|
||||||
CampoEstructura,
|
|
||||||
} 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 asignatura
|
// Tipos importados de tu archivo de asignatura
|
||||||
@@ -62,8 +59,7 @@ interface SelectedField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface IAAsignaturaTabProps {
|
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
|
||||||
@@ -71,15 +67,18 @@ interface IAAsignaturaTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function IAAsignaturaTab({
|
export function IAAsignaturaTab({
|
||||||
campos,
|
|
||||||
datosGenerales,
|
|
||||||
messages,
|
messages,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAcceptSuggestion,
|
onAcceptSuggestion,
|
||||||
onRejectSuggestion,
|
onRejectSuggestion,
|
||||||
}: IAAsignaturaTabProps) {
|
}: 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>>([])
|
||||||
@@ -89,25 +88,25 @@ export function IAAsignaturaTab({
|
|||||||
|
|
||||||
// 1. Transformar datos de la asignatura 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 Asignatura" (Prefill)
|
// 2. Manejar el estado inicial si viene de "Datos de Asignatura" (Prefill)
|
||||||
|
|
||||||
@@ -218,7 +217,7 @@ export function IAAsignaturaTab({
|
|||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative min-h-0 flex-1">
|
||||||
<ScrollArea ref={scrollRef} className="h-full w-full">
|
<ScrollArea ref={scrollRef} className="h-full w-full">
|
||||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||||
{messages.map((msg) => (
|
{messages?.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`}
|
className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function PasoSugerenciasForm({
|
|||||||
onChange: Dispatch<SetStateAction<NewSubjectWizardState>>
|
onChange: Dispatch<SetStateAction<NewSubjectWizardState>>
|
||||||
}) {
|
}) {
|
||||||
const enfoque = wizard.iaMultiple?.enfoque ?? ''
|
const enfoque = wizard.iaMultiple?.enfoque ?? ''
|
||||||
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
|
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 5
|
||||||
const isLoading = wizard.iaMultiple?.isLoading ?? false
|
const isLoading = wizard.iaMultiple?.isLoading ?? false
|
||||||
|
|
||||||
const [showConservacionTooltip, setShowConservacionTooltip] = useState(false)
|
const [showConservacionTooltip, setShowConservacionTooltip] = useState(false)
|
||||||
@@ -163,7 +163,7 @@ export default function PasoSugerenciasForm({
|
|||||||
Cantidad de sugerencias
|
Cantidad de sugerencias
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Ej. 10"
|
placeholder="Ej. 5"
|
||||||
value={cantidadDeSugerencias}
|
value={cantidadDeSugerencias}
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import type { AIGenerateSubjectInput, AIGenerateSubjectJsonInput } from '@/data'
|
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 { TablesInsert } from '@/types/supabase'
|
||||||
|
import type { RealtimeChannel } from '@supabase/supabase-js'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
useGenerateSubjectAI,
|
useGenerateSubjectAI,
|
||||||
qk,
|
qk,
|
||||||
useCreateSubjectManual,
|
useCreateSubjectManual,
|
||||||
|
subjects_get_maybe,
|
||||||
} from '@/data'
|
} from '@/data'
|
||||||
|
|
||||||
export function WizardControls({
|
export function WizardControls({
|
||||||
@@ -41,6 +43,154 @@ export function WizardControls({
|
|||||||
const generateSubjectAI = useGenerateSubjectAI()
|
const generateSubjectAI = useGenerateSubjectAI()
|
||||||
const createSubjectManual = useCreateSubjectManual()
|
const createSubjectManual = useCreateSubjectManual()
|
||||||
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
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 () => {
|
const handleCreate = async () => {
|
||||||
setWizard((w) => ({
|
setWizard((w) => ({
|
||||||
...w,
|
...w,
|
||||||
@@ -48,48 +198,99 @@ export function WizardControls({
|
|||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
let startedWaiting = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
||||||
const aiInput: AIGenerateSubjectInput = {
|
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,
|
plan_estudio_id: wizard.plan_estudio_id,
|
||||||
datosBasicos: {
|
estructura_id: wizard.datosBasicos.estructuraId,
|
||||||
nombre: wizard.datosBasicos.nombre,
|
nombre: wizard.datosBasicos.nombre,
|
||||||
codigo: wizard.datosBasicos.codigo,
|
codigo: wizard.datosBasicos.codigo ?? null,
|
||||||
tipo: wizard.datosBasicos.tipo!,
|
tipo: wizard.datosBasicos.tipo ?? undefined,
|
||||||
creditos: wizard.datosBasicos.creditos!,
|
creditos: wizard.datosBasicos.creditos,
|
||||||
horasIndependientes: wizard.datosBasicos.horasIndependientes,
|
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
|
||||||
horasAcademicas: wizard.datosBasicos.horasAcademicas,
|
horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
|
||||||
estructuraId: wizard.datosBasicos.estructuraId!,
|
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: {
|
iaConfig: {
|
||||||
descripcionEnfoqueAcademico:
|
descripcionEnfoqueAcademico:
|
||||||
wizard.iaConfig!.descripcionEnfoqueAcademico,
|
wizard.iaConfig?.descripcionEnfoqueAcademico ?? undefined,
|
||||||
instruccionesAdicionalesIA:
|
instruccionesAdicionalesIA:
|
||||||
wizard.iaConfig!.instruccionesAdicionalesIA,
|
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
|
||||||
archivosReferencia: wizard.iaConfig!.archivosReferencia,
|
archivosAdjuntos,
|
||||||
repositoriosReferencia:
|
|
||||||
wizard.iaConfig!.repositoriosReferencia || [],
|
|
||||||
archivosAdjuntos: wizard.iaConfig!.archivosAdjuntos || [],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`${new Date().toISOString()} - Enviando a generar asignatura con IA`,
|
`${new Date().toISOString()} - Disparando Edge IA asignatura (unified)`,
|
||||||
)
|
)
|
||||||
|
|
||||||
setIsSpinningIA(true)
|
await generateSubjectAI.mutateAsync(payload as any)
|
||||||
const asignatura = await generateSubjectAI.mutateAsync(aiInput)
|
|
||||||
// await new Promise((resolve) => setTimeout(resolve, 20000)) // debug
|
|
||||||
setIsSpinningIA(false)
|
|
||||||
// console.log(
|
|
||||||
// `${new Date().toISOString()} - Asignatura IA generada`,
|
|
||||||
// asignatura,
|
|
||||||
// )
|
|
||||||
|
|
||||||
navigate({
|
// Fallback: una lectura puntual por si el UPDATE llegó antes de suscribir.
|
||||||
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
|
const latest = await subjects_get_maybe(subjectId)
|
||||||
state: { showConfetti: true },
|
if (latest) {
|
||||||
|
handleSubjectReady({
|
||||||
|
id: latest.id as any,
|
||||||
|
plan_estudio_id: latest.plan_estudio_id as any,
|
||||||
|
estado: (latest as any).estado,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +309,15 @@ export function WizardControls({
|
|||||||
|
|
||||||
const supabase = supabaseBrowser()
|
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(
|
const placeholders: Array<TablesInsert<'asignaturas'>> = selected.map(
|
||||||
(s): TablesInsert<'asignaturas'> => ({
|
(s): TablesInsert<'asignaturas'> => ({
|
||||||
plan_estudio_id: wizard.plan_estudio_id,
|
plan_estudio_id: wizard.plan_estudio_id,
|
||||||
@@ -141,16 +351,33 @@ export function WizardControls({
|
|||||||
// Disparar generación en paralelo (no bloquear navegación)
|
// Disparar generación en paralelo (no bloquear navegación)
|
||||||
insertedIds.forEach((id, idx) => {
|
insertedIds.forEach((id, idx) => {
|
||||||
const s = selected[idx]
|
const s = selected[idx]
|
||||||
const payload: AIGenerateSubjectJsonInput = {
|
const creditosForEdge =
|
||||||
|
typeof s.creditos === 'number' && s.creditos > 0
|
||||||
|
? s.creditos
|
||||||
|
: undefined
|
||||||
|
const payload: AISubjectUnifiedInput = {
|
||||||
|
datosUpdate: {
|
||||||
id,
|
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,
|
descripcionEnfoqueAcademico: s.descripcion,
|
||||||
// (opcionales) parches directos si el edge los usa
|
instruccionesAdicionalesIA:
|
||||||
estructura_id: wizard.estructuraId,
|
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
|
||||||
linea_plan_id: s.linea_plan_id,
|
archivosAdjuntos,
|
||||||
numero_ciclo: s.numero_ciclo,
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
void generateSubjectAI.mutateAsync(payload).catch((e) => {
|
void generateSubjectAI.mutateAsync(payload as any).catch((e) => {
|
||||||
console.error('Error generando asignatura IA (multiple):', e)
|
console.error('Error generando asignatura IA (multiple):', e)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -166,6 +393,8 @@ export function WizardControls({
|
|||||||
resetScroll: false,
|
resetScroll: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setIsSpinningIA(false)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,16 +424,19 @@ export function WizardControls({
|
|||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setIsSpinningIA(false)
|
setIsSpinningIA(false)
|
||||||
|
stopSubjectWatch()
|
||||||
setWizard((w) => ({
|
setWizard((w) => ({
|
||||||
...w,
|
...w,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
errorMessage: err?.message ?? 'Error creando la asignatura',
|
errorMessage: err?.message ?? 'Error creando la asignatura',
|
||||||
}))
|
}))
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!startedWaiting) {
|
||||||
setIsSpinningIA(false)
|
setIsSpinningIA(false)
|
||||||
setWizard((w) => ({ ...w, isLoading: false }))
|
setWizard((w) => ({ ...w, isLoading: false }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex grow items-center justify-between">
|
<div className="flex grow items-center justify-between">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import type { AIGeneratePlanInput } from '@/data'
|
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,
|
||||||
@@ -35,9 +41,152 @@ export function WizardControls({
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const generatePlanAI = useGeneratePlanAI()
|
const generatePlanAI = useGeneratePlanAI()
|
||||||
const createPlanManual = useCreatePlanManual()
|
const createPlanManual = useCreatePlanManual()
|
||||||
|
const deletePlan = useDeletePlanEstudio()
|
||||||
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
||||||
// const supabaseClient = supabaseBrowser()
|
const cancelledRef = useRef(false)
|
||||||
// const persistPlanFromAI = usePersistPlanFromAI()
|
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
|
||||||
@@ -82,14 +231,16 @@ export function WizardControls({
|
|||||||
console.log(`${new Date().toISOString()} - Enviando a generar plan IA`)
|
console.log(`${new Date().toISOString()} - Enviando a generar plan IA`)
|
||||||
|
|
||||||
setIsSpinningIA(true)
|
setIsSpinningIA(true)
|
||||||
const plan = await generatePlanAI.mutateAsync(aiInput as any)
|
const resp: any = await generatePlanAI.mutateAsync(aiInput as any)
|
||||||
setIsSpinningIA(false)
|
const planId = resp?.plan?.id ?? resp?.id
|
||||||
console.log(`${new Date().toISOString()} - Plan IA generado`, plan)
|
console.log(`${new Date().toISOString()} - Plan IA generado`, resp)
|
||||||
|
|
||||||
navigate({
|
if (!planId) {
|
||||||
to: `/planes/${plan.id}`,
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,14 +265,14 @@ export function WizardControls({
|
|||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setIsSpinningIA(false)
|
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 {
|
||||||
setIsSpinningIA(false)
|
// Si entramos en watch realtime, el loading se corta desde checkPlanStateAndAct.
|
||||||
setWizard((w) => ({ ...w, isLoading: false }))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,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>> {
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ 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 { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
|
||||||
import type {
|
import type {
|
||||||
AsignaturaSugerida,
|
AsignaturaSugerida,
|
||||||
DataAsignaturaSugerida,
|
DataAsignaturaSugerida,
|
||||||
@@ -27,14 +30,82 @@ const EDGE = {
|
|||||||
subjects_import_from_file: 'subjects_import_from_file',
|
subjects_import_from_file: 'subjects_import_from_file',
|
||||||
|
|
||||||
subjects_update_fields: 'subjects_update_fields',
|
subjects_update_fields: 'subjects_update_fields',
|
||||||
subjects_update_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 =
|
||||||
|
| 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 supabase = supabaseBrowser()
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@@ -53,7 +124,10 @@ export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
|
|||||||
.single()
|
.single()
|
||||||
|
|
||||||
throwIfError(error)
|
throwIfError(error)
|
||||||
return requireData(data, 'Asignatura no encontrada.')
|
return requireData(
|
||||||
|
data,
|
||||||
|
'Asignatura no encontrada.',
|
||||||
|
) as unknown as AsignaturaDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function subjects_history(
|
export async function subjects_history(
|
||||||
@@ -103,54 +177,49 @@ export async function subjects_create_manual(
|
|||||||
return requireData(data, 'No se pudo crear la asignatura.')
|
return requireData(data, 'No se pudo crear la asignatura.')
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AIGenerateSubjectInput = {
|
/**
|
||||||
plan_estudio_id: Asignatura['plan_estudio_id']
|
* Nuevo payload unificado (JSON) para la Edge `ai_generate_subject`.
|
||||||
datosBasicos: {
|
* - Siempre incluye `datosUpdate.plan_estudio_id`.
|
||||||
nombre: Asignatura['nombre']
|
* - `datosUpdate.id` es opcional (si no existe, la Edge puede crear).
|
||||||
codigo?: Asignatura['codigo']
|
* En el frontend, insertamos primero y usamos `id` para actualizar.
|
||||||
tipo: Asignatura['tipo'] | null
|
*/
|
||||||
creditos: Asignatura['creditos'] | null
|
export type AISubjectUnifiedInput = {
|
||||||
horasAcademicas?: Asignatura['horas_academicas'] | null
|
datosUpdate: Partial<{
|
||||||
horasIndependientes?: Asignatura['horas_independientes'] | null
|
id: string
|
||||||
estructuraId: Asignatura['estructura_id'] | null
|
plan_estudio_id: string
|
||||||
|
estructura_id: string
|
||||||
|
nombre: string
|
||||||
|
codigo: string | null
|
||||||
|
tipo: string | null
|
||||||
|
creditos: number
|
||||||
|
horas_academicas: number | null
|
||||||
|
horas_independientes: number | null
|
||||||
|
numero_ciclo: number | null
|
||||||
|
linea_plan_id: string | null
|
||||||
|
orden_celda: number | null
|
||||||
|
}> & {
|
||||||
|
plan_estudio_id: string
|
||||||
}
|
}
|
||||||
// clonInterno?: {
|
|
||||||
// facultadId?: string
|
|
||||||
// carreraId?: string
|
|
||||||
// planOrigenId?: string
|
|
||||||
// asignaturaOrigenId?: string | null
|
|
||||||
// }
|
|
||||||
// clonTradicional?: {
|
|
||||||
// archivoWordAsignaturaId: string | null
|
|
||||||
// archivosAdicionalesIds: Array<string>
|
|
||||||
// }
|
|
||||||
iaConfig?: {
|
iaConfig?: {
|
||||||
descripcionEnfoqueAcademico: string
|
descripcionEnfoqueAcademico?: string
|
||||||
instruccionesAdicionalesIA: string
|
instruccionesAdicionalesIA?: string
|
||||||
archivosReferencia: Array<string>
|
archivosAdjuntos?: Array<string>
|
||||||
repositoriosReferencia?: Array<string>
|
|
||||||
archivosAdjuntos?: Array<UploadedFile>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function subjects_get_maybe(
|
||||||
* Edge (JSON): actualizar/llenar una asignatura existente por id.
|
subjectId: UUID,
|
||||||
* Nota: este flujo NO acepta `instruccionesAdicionalesIA` (solo FormData lo usa).
|
): Promise<Asignatura | null> {
|
||||||
*/
|
const supabase = supabaseBrowser()
|
||||||
export type AIGenerateSubjectJsonInput = Partial<{
|
|
||||||
plan_estudio_id: Asignatura['plan_estudio_id']
|
const { data, error } = await supabase
|
||||||
nombre: Asignatura['nombre']
|
.from('asignaturas')
|
||||||
codigo: Asignatura['codigo']
|
.select('id,plan_estudio_id,estado')
|
||||||
tipo: Asignatura['tipo'] | null
|
.eq('id', subjectId)
|
||||||
creditos: Asignatura['creditos']
|
.maybeSingle()
|
||||||
horas_academicas: Asignatura['horas_academicas'] | null
|
|
||||||
horas_independientes: Asignatura['horas_independientes'] | null
|
throwIfError(error)
|
||||||
estructura_id: Asignatura['estructura_id'] | null
|
return (data ?? null) as unknown as Asignatura | null
|
||||||
linea_plan_id: Asignatura['linea_plan_id'] | null
|
|
||||||
numero_ciclo: Asignatura['numero_ciclo'] | null
|
|
||||||
descripcionEnfoqueAcademico: string
|
|
||||||
}> & {
|
|
||||||
id: Asignatura['id']
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GenerateSubjectSuggestionsInput = {
|
export type GenerateSubjectSuggestionsInput = {
|
||||||
@@ -188,30 +257,8 @@ export async function generate_subject_suggestions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function ai_generate_subject(
|
export async function ai_generate_subject(
|
||||||
input: AIGenerateSubjectInput | AIGenerateSubjectJsonInput,
|
input: AISubjectUnifiedInput,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
if ('datosBasicos' in input) {
|
|
||||||
const edgeFunctionBody = new FormData()
|
|
||||||
edgeFunctionBody.append('plan_estudio_id', input.plan_estudio_id)
|
|
||||||
edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos))
|
|
||||||
edgeFunctionBody.append(
|
|
||||||
'iaConfig',
|
|
||||||
JSON.stringify({
|
|
||||||
...input.iaConfig,
|
|
||||||
archivosAdjuntos: undefined, // los manejamos aparte
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
input.iaConfig?.archivosAdjuntos?.forEach((file) => {
|
|
||||||
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
|
|
||||||
})
|
|
||||||
return invokeEdge<any>(
|
|
||||||
EDGE.ai_generate_subject,
|
|
||||||
edgeFunctionBody,
|
|
||||||
undefined,
|
|
||||||
supabaseBrowser(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return invokeEdge<any>(EDGE.ai_generate_subject, input, {
|
return invokeEdge<any>(EDGE.ai_generate_subject, input, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
@@ -271,12 +318,24 @@ export async function subjects_update_fields(
|
|||||||
|
|
||||||
export async function subjects_update_contenido(
|
export async function subjects_update_contenido(
|
||||||
subjectId: UUID,
|
subjectId: UUID,
|
||||||
unidades: Array<any>,
|
unidades: Array<ContenidoApi>,
|
||||||
): Promise<Asignatura> {
|
): Promise<Asignatura> {
|
||||||
return invokeEdge<Asignatura>(EDGE.subjects_update_contenido, {
|
const supabase = supabaseBrowser()
|
||||||
subjectId,
|
|
||||||
unidades,
|
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<{
|
||||||
@@ -403,3 +462,51 @@ export async function lineas_delete(lineaId: string) {
|
|||||||
if (error) throw error
|
if (error) throw error
|
||||||
return lineaId
|
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,
|
||||||
@@ -25,6 +27,7 @@ import {
|
|||||||
} from '../api/plans.api'
|
} from '../api/plans.api'
|
||||||
import { lineas_delete } from '../api/subjects.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,
|
||||||
@@ -71,23 +74,79 @@ 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),
|
||||||
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
const data = query.state.data
|
|
||||||
if (!Array.isArray(data)) return false
|
|
||||||
const hayGenerando = data.some(
|
|
||||||
(a: any) => (a as { estado?: unknown }).estado === 'generando',
|
|
||||||
)
|
|
||||||
return hayGenerando ? 500 : false
|
|
||||||
},
|
|
||||||
refetchIntervalInBackground: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!planId) return
|
||||||
|
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const channel = supabase.channel(`plan-asignaturas-${planId}`)
|
||||||
|
|
||||||
|
channel.on(
|
||||||
|
'postgres_changes',
|
||||||
|
{
|
||||||
|
event: '*',
|
||||||
|
schema: 'public',
|
||||||
|
table: 'asignaturas',
|
||||||
|
filter: `plan_estudio_id=eq.${planId}`,
|
||||||
|
},
|
||||||
|
(payload: {
|
||||||
|
eventType?: 'INSERT' | 'UPDATE' | 'DELETE'
|
||||||
|
new?: any
|
||||||
|
old?: any
|
||||||
|
}) => {
|
||||||
|
const eventType = payload.eventType
|
||||||
|
|
||||||
|
if (eventType === 'DELETE') {
|
||||||
|
const oldRow: any = payload.old
|
||||||
|
const deletedId = oldRow?.id
|
||||||
|
if (!deletedId) return
|
||||||
|
|
||||||
|
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
|
||||||
|
if (!Array.isArray(prev)) return prev
|
||||||
|
return prev.filter((a: any) => String(a?.id) !== String(deletedId))
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRow: any = payload.new
|
||||||
|
if (!newRow?.id) return
|
||||||
|
|
||||||
|
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
|
||||||
|
if (!Array.isArray(prev)) return prev
|
||||||
|
|
||||||
|
const idx = prev.findIndex(
|
||||||
|
(a: any) => String(a?.id) === String(newRow.id),
|
||||||
|
)
|
||||||
|
if (idx === -1) return [...prev, newRow]
|
||||||
|
|
||||||
|
const next = [...prev]
|
||||||
|
next[idx] = { ...prev[idx], ...newRow }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
channel.subscribe()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
supabase.removeChannel(channel)
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [planId, qc])
|
||||||
|
|
||||||
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePlanHistorial(
|
export function usePlanHistorial(
|
||||||
@@ -263,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()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||||||
import {
|
import {
|
||||||
ai_generate_subject,
|
ai_generate_subject,
|
||||||
asignaturas_update,
|
asignaturas_update,
|
||||||
|
bibliografia_delete,
|
||||||
|
bibliografia_insert,
|
||||||
|
bibliografia_update,
|
||||||
lineas_insert,
|
lineas_insert,
|
||||||
lineas_update,
|
lineas_update,
|
||||||
subjects_bibliografia_list,
|
subjects_bibliografia_list,
|
||||||
@@ -23,6 +26,7 @@ import { qk } from '../query/keys'
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
BibliografiaUpsertInput,
|
BibliografiaUpsertInput,
|
||||||
|
ContenidoApi,
|
||||||
SubjectsUpdateFieldsPatch,
|
SubjectsUpdateFieldsPatch,
|
||||||
} from '../api/subjects.api'
|
} from '../api/subjects.api'
|
||||||
import type { UUID } from '../types/domain'
|
import type { UUID } from '../types/domain'
|
||||||
@@ -97,7 +101,6 @@ export function useCreateSubjectManual() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useGenerateSubjectAI() {
|
export function useGenerateSubjectAI() {
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ai_generate_subject,
|
mutationFn: ai_generate_subject,
|
||||||
})
|
})
|
||||||
@@ -162,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),
|
||||||
})
|
})
|
||||||
@@ -175,10 +180,19 @@ export function useUpdateSubjectContenido() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (vars: { subjectId: UUID; unidades: Array<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) })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -221,17 +235,22 @@ export function useUpdateAsignatura() {
|
|||||||
}) => asignaturas_update(vars.asignaturaId, vars.patch),
|
}) => asignaturas_update(vars.asignaturaId, vars.patch),
|
||||||
|
|
||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
// 1. Actualizamos la materia específica en la caché si tienes un query de "detalle"
|
// ✅ Mantener consistencia con las query keys centralizadas (qk)
|
||||||
qc.setQueryData(['asignatura', updated.id], updated)
|
// 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. IMPORTANTÍSIMO: Invalidamos la lista de materias del plan
|
// 2) Refresca vistas derivadas del plan
|
||||||
// para que el mapa curricular vea los cambios (créditos, horas, nombre, etc.)
|
|
||||||
qc.invalidateQueries({
|
qc.invalidateQueries({
|
||||||
queryKey: ['plan_asignaturas', updated.plan_estudio_id],
|
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
|
||||||
|
})
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: qk.planHistorial(updated.plan_estudio_id),
|
||||||
})
|
})
|
||||||
|
|
||||||
// 3. Si tienes una lista general de asignaturas, también la invalidamos
|
// 3) Refresca historial de la asignatura si existe
|
||||||
qc.invalidateQueries({ queryKey: ['asignaturas', 'list'] })
|
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -260,3 +279,41 @@ export function useUpdateLinea() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type {
|
|||||||
BibliografiaEntry,
|
BibliografiaEntry,
|
||||||
CambioAsignatura,
|
CambioAsignatura,
|
||||||
DocumentoAsignatura,
|
DocumentoAsignatura,
|
||||||
LibraryResource,
|
|
||||||
} from '@/types/asignatura'
|
} from '@/types/asignatura'
|
||||||
|
|
||||||
export const mockAsignatura: Asignatura = {
|
export const mockAsignatura: Asignatura = {
|
||||||
@@ -310,67 +309,3 @@ export const mockDocumentoSep: DocumentoAsignatura = {
|
|||||||
fechaGeneracion: new Date('2024-12-06T11:30:00'),
|
fechaGeneracion: new Date('2024-12-06T11:30:00'),
|
||||||
estado: 'listo',
|
estado: 'listo',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mockLibraryResources: Array<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,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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) =>
|
||||||
@@ -13,6 +14,7 @@ export const qk = {
|
|||||||
|
|
||||||
planesList: (filters: unknown) => ['planes', 'list', filters] as const,
|
planesList: (filters: unknown) => ['planes', 'list', filters] as const,
|
||||||
plan: (planId: string) => ['planes', 'detail', planId] as const,
|
plan: (planId: string) => ['planes', 'detail', planId] as const,
|
||||||
|
planMaybe: (planId: string) => ['planes', 'detail-maybe', planId] as const,
|
||||||
planLineas: (planId: string) => ['planes', planId, 'lineas'] as const,
|
planLineas: (planId: string) => ['planes', planId, 'lineas'] as const,
|
||||||
planAsignaturas: (planId: string) =>
|
planAsignaturas: (planId: string) =>
|
||||||
['planes', planId, 'asignaturas'] as const,
|
['planes', planId, 'asignaturas'] as const,
|
||||||
@@ -22,6 +24,8 @@ export const qk = {
|
|||||||
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
|
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
|
||||||
asignatura: (asignaturaId: string) =>
|
asignatura: (asignaturaId: string) =>
|
||||||
['asignaturas', 'detail', asignaturaId] as const,
|
['asignaturas', 'detail', asignaturaId] as const,
|
||||||
|
asignaturaMaybe: (asignaturaId: string) =>
|
||||||
|
['asignaturas', 'detail-maybe', asignaturaId] as const,
|
||||||
asignaturaBibliografia: (asignaturaId: string) =>
|
asignaturaBibliografia: (asignaturaId: string) =>
|
||||||
['asignaturas', asignaturaId, 'bibliografia'] as const,
|
['asignaturas', asignaturaId, 'bibliografia'] as const,
|
||||||
asignaturaHistorial: (asignaturaId: string) =>
|
asignaturaHistorial: (asignaturaId: string) =>
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -17,13 +17,19 @@ import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-qu
|
|||||||
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
|
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
|
||||||
import { Route as PlanesPlanIdDetalleRouteImport } from './routes/planes/$planId/_detalle'
|
import { Route as PlanesPlanIdDetalleRouteImport } from './routes/planes/$planId/_detalle'
|
||||||
import { Route as PlanesPlanIdDetalleIndexRouteImport } from './routes/planes/$planId/_detalle/index'
|
import { Route as PlanesPlanIdDetalleIndexRouteImport } from './routes/planes/$planId/_detalle/index'
|
||||||
import { Route as PlanesPlanIdAsignaturasAsignaturaIdRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId'
|
|
||||||
import { Route as PlanesPlanIdDetalleMapaRouteImport } from './routes/planes/$planId/_detalle/mapa'
|
import { Route as PlanesPlanIdDetalleMapaRouteImport } from './routes/planes/$planId/_detalle/mapa'
|
||||||
import { Route as PlanesPlanIdDetalleIaplanRouteImport } from './routes/planes/$planId/_detalle/iaplan'
|
import { Route as PlanesPlanIdDetalleIaplanRouteImport } from './routes/planes/$planId/_detalle/iaplan'
|
||||||
import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial'
|
import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial'
|
||||||
import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo'
|
import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo'
|
||||||
import { Route as PlanesPlanIdDetalleDocumentoRouteImport } from './routes/planes/$planId/_detalle/documento'
|
import { Route as PlanesPlanIdDetalleDocumentoRouteImport } from './routes/planes/$planId/_detalle/documento'
|
||||||
import { Route as PlanesPlanIdDetalleAsignaturasRouteImport } from './routes/planes/$planId/_detalle/asignaturas'
|
import { Route as PlanesPlanIdDetalleAsignaturasRouteImport } from './routes/planes/$planId/_detalle/asignaturas'
|
||||||
|
import { Route as PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/route'
|
||||||
|
import { Route as PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/index'
|
||||||
|
import { Route as PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||||
|
import { Route as PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||||
|
import { Route as PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||||
|
import { Route as PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||||
|
import { Route as PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||||
import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva'
|
import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva'
|
||||||
|
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
@@ -67,12 +73,6 @@ const PlanesPlanIdDetalleIndexRoute =
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const PlanesPlanIdAsignaturasAsignaturaIdRoute =
|
|
||||||
PlanesPlanIdAsignaturasAsignaturaIdRouteImport.update({
|
|
||||||
id: '/planes/$planId/asignaturas/$asignaturaId',
|
|
||||||
path: '/planes/$planId/asignaturas/$asignaturaId',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const PlanesPlanIdDetalleMapaRoute = PlanesPlanIdDetalleMapaRouteImport.update({
|
const PlanesPlanIdDetalleMapaRoute = PlanesPlanIdDetalleMapaRouteImport.update({
|
||||||
id: '/mapa',
|
id: '/mapa',
|
||||||
path: '/mapa',
|
path: '/mapa',
|
||||||
@@ -108,6 +108,48 @@ const PlanesPlanIdDetalleAsignaturasRoute =
|
|||||||
path: '/asignaturas',
|
path: '/asignaturas',
|
||||||
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const PlanesPlanIdAsignaturasAsignaturaIdRouteRoute =
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport.update({
|
||||||
|
id: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
|
path: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const PlanesPlanIdAsignaturasAsignaturaIdIndexRoute =
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute =
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport.update({
|
||||||
|
id: '/iaasignatura',
|
||||||
|
path: '/iaasignatura',
|
||||||
|
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute =
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport.update({
|
||||||
|
id: '/historial',
|
||||||
|
path: '/historial',
|
||||||
|
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute =
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport.update({
|
||||||
|
id: '/documento',
|
||||||
|
path: '/documento',
|
||||||
|
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute =
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport.update({
|
||||||
|
id: '/contenido',
|
||||||
|
path: '/contenido',
|
||||||
|
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute =
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport.update({
|
||||||
|
id: '/bibliografia',
|
||||||
|
path: '/bibliografia',
|
||||||
|
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||||
|
} as any)
|
||||||
const PlanesPlanIdDetalleAsignaturasNuevaRoute =
|
const PlanesPlanIdDetalleAsignaturasNuevaRoute =
|
||||||
PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({
|
PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({
|
||||||
id: '/nueva',
|
id: '/nueva',
|
||||||
@@ -123,15 +165,21 @@ export interface FileRoutesByFullPath {
|
|||||||
'/planes': typeof PlanesListaRouteWithChildren
|
'/planes': typeof PlanesListaRouteWithChildren
|
||||||
'/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren
|
'/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren
|
||||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
|
||||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
||||||
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
|
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
|
||||||
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
|
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
|
||||||
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
|
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
|
||||||
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
|
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
|
||||||
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
|
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
|
||||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
|
|
||||||
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
|
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
|
||||||
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
|
||||||
|
'/planes/$planId/asignaturas/$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
|
||||||
@@ -146,9 +194,14 @@ export interface FileRoutesByTo {
|
|||||||
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
|
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
|
||||||
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
|
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
|
||||||
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
|
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
|
||||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
|
|
||||||
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
|
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
|
||||||
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
|
||||||
|
'/planes/$planId/asignaturas/$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
|
||||||
@@ -159,15 +212,21 @@ export interface FileRoutesById {
|
|||||||
'/planes/_lista': typeof PlanesListaRouteWithChildren
|
'/planes/_lista': typeof PlanesListaRouteWithChildren
|
||||||
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren
|
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren
|
||||||
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
|
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
|
||||||
'/planes/$planId/_detalle/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
'/planes/$planId/_detalle/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
||||||
'/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute
|
'/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute
|
||||||
'/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute
|
'/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute
|
||||||
'/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute
|
'/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute
|
||||||
'/planes/$planId/_detalle/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
|
'/planes/$planId/_detalle/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
|
||||||
'/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute
|
'/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute
|
||||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
|
|
||||||
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
|
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
|
||||||
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
|
||||||
|
'/planes/$planId/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
|
||||||
@@ -179,15 +238,21 @@ export interface FileRouteTypes {
|
|||||||
| '/planes'
|
| '/planes'
|
||||||
| '/planes/$planId'
|
| '/planes/$planId'
|
||||||
| '/planes/nuevo'
|
| '/planes/nuevo'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||||
| '/planes/$planId/asignaturas'
|
| '/planes/$planId/asignaturas'
|
||||||
| '/planes/$planId/documento'
|
| '/planes/$planId/documento'
|
||||||
| '/planes/$planId/flujo'
|
| '/planes/$planId/flujo'
|
||||||
| '/planes/$planId/historial'
|
| '/planes/$planId/historial'
|
||||||
| '/planes/$planId/iaplan'
|
| '/planes/$planId/iaplan'
|
||||||
| '/planes/$planId/mapa'
|
| '/planes/$planId/mapa'
|
||||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
|
||||||
| '/planes/$planId/'
|
| '/planes/$planId/'
|
||||||
| '/planes/$planId/asignaturas/nueva'
|
| '/planes/$planId/asignaturas/nueva'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||||
|
| '/planes/$planId/asignaturas/$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:
|
||||||
| '/'
|
| '/'
|
||||||
@@ -202,9 +267,14 @@ export interface FileRouteTypes {
|
|||||||
| '/planes/$planId/historial'
|
| '/planes/$planId/historial'
|
||||||
| '/planes/$planId/iaplan'
|
| '/planes/$planId/iaplan'
|
||||||
| '/planes/$planId/mapa'
|
| '/planes/$planId/mapa'
|
||||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
|
||||||
| '/planes/$planId'
|
| '/planes/$planId'
|
||||||
| '/planes/$planId/asignaturas/nueva'
|
| '/planes/$planId/asignaturas/nueva'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||||
|
| '/planes/$planId/asignaturas/$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__'
|
||||||
| '/'
|
| '/'
|
||||||
@@ -214,15 +284,21 @@ export interface FileRouteTypes {
|
|||||||
| '/planes/_lista'
|
| '/planes/_lista'
|
||||||
| '/planes/$planId/_detalle'
|
| '/planes/$planId/_detalle'
|
||||||
| '/planes/_lista/nuevo'
|
| '/planes/_lista/nuevo'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||||
| '/planes/$planId/_detalle/asignaturas'
|
| '/planes/$planId/_detalle/asignaturas'
|
||||||
| '/planes/$planId/_detalle/documento'
|
| '/planes/$planId/_detalle/documento'
|
||||||
| '/planes/$planId/_detalle/flujo'
|
| '/planes/$planId/_detalle/flujo'
|
||||||
| '/planes/$planId/_detalle/historial'
|
| '/planes/$planId/_detalle/historial'
|
||||||
| '/planes/$planId/_detalle/iaplan'
|
| '/planes/$planId/_detalle/iaplan'
|
||||||
| '/planes/$planId/_detalle/mapa'
|
| '/planes/$planId/_detalle/mapa'
|
||||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
|
||||||
| '/planes/$planId/_detalle/'
|
| '/planes/$planId/_detalle/'
|
||||||
| '/planes/$planId/_detalle/asignaturas/nueva'
|
| '/planes/$planId/_detalle/asignaturas/nueva'
|
||||||
|
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||||
|
| '/planes/$planId/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 {
|
||||||
@@ -232,7 +308,7 @@ export interface RootRouteChildren {
|
|||||||
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
|
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
|
||||||
PlanesListaRoute: typeof PlanesListaRouteWithChildren
|
PlanesListaRoute: typeof PlanesListaRouteWithChildren
|
||||||
PlanesPlanIdDetalleRoute: typeof PlanesPlanIdDetalleRouteWithChildren
|
PlanesPlanIdDetalleRoute: typeof PlanesPlanIdDetalleRouteWithChildren
|
||||||
PlanesPlanIdAsignaturasAsignaturaIdRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
|
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -293,13 +369,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof PlanesPlanIdDetalleIndexRouteImport
|
preLoaderRoute: typeof PlanesPlanIdDetalleIndexRouteImport
|
||||||
parentRoute: typeof PlanesPlanIdDetalleRoute
|
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||||
}
|
}
|
||||||
'/planes/$planId/asignaturas/$asignaturaId': {
|
|
||||||
id: '/planes/$planId/asignaturas/$asignaturaId'
|
|
||||||
path: '/planes/$planId/asignaturas/$asignaturaId'
|
|
||||||
fullPath: '/planes/$planId/asignaturas/$asignaturaId'
|
|
||||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/planes/$planId/_detalle/mapa': {
|
'/planes/$planId/_detalle/mapa': {
|
||||||
id: '/planes/$planId/_detalle/mapa'
|
id: '/planes/$planId/_detalle/mapa'
|
||||||
path: '/mapa'
|
path: '/mapa'
|
||||||
@@ -342,6 +411,55 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasRouteImport
|
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasRouteImport
|
||||||
parentRoute: typeof PlanesPlanIdDetalleRoute
|
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||||
}
|
}
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId': {
|
||||||
|
id: '/planes/$planId/asignaturas/$asignaturaId'
|
||||||
|
path: '/planes/$planId/asignaturas/$asignaturaId'
|
||||||
|
fullPath: '/planes/$planId/asignaturas/$asignaturaId'
|
||||||
|
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/': {
|
||||||
|
id: '/planes/$planId/asignaturas/$asignaturaId/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/planes/$planId/asignaturas/$asignaturaId/'
|
||||||
|
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport
|
||||||
|
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||||
|
}
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': {
|
||||||
|
id: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||||
|
path: '/iaasignatura'
|
||||||
|
fullPath: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||||
|
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport
|
||||||
|
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||||
|
}
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/historial': {
|
||||||
|
id: '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||||
|
path: '/historial'
|
||||||
|
fullPath: '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||||
|
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport
|
||||||
|
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||||
|
}
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/documento': {
|
||||||
|
id: '/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||||
|
path: '/documento'
|
||||||
|
fullPath: '/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||||
|
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport
|
||||||
|
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||||
|
}
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/contenido': {
|
||||||
|
id: '/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||||
|
path: '/contenido'
|
||||||
|
fullPath: '/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||||
|
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport
|
||||||
|
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||||
|
}
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': {
|
||||||
|
id: '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||||
|
path: '/bibliografia'
|
||||||
|
fullPath: '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||||
|
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport
|
||||||
|
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||||
|
}
|
||||||
'/planes/$planId/_detalle/asignaturas/nueva': {
|
'/planes/$planId/_detalle/asignaturas/nueva': {
|
||||||
id: '/planes/$planId/_detalle/asignaturas/nueva'
|
id: '/planes/$planId/_detalle/asignaturas/nueva'
|
||||||
path: '/nueva'
|
path: '/nueva'
|
||||||
@@ -403,6 +521,36 @@ const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = {
|
|||||||
const PlanesPlanIdDetalleRouteWithChildren =
|
const PlanesPlanIdDetalleRouteWithChildren =
|
||||||
PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren)
|
PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren)
|
||||||
|
|
||||||
|
interface PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren {
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren =
|
||||||
|
{
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute:
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute,
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute:
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute,
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute:
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute,
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute:
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute,
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute:
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute,
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute:
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren =
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute._addFileChildren(
|
||||||
|
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
DashboardRoute: DashboardRoute,
|
DashboardRoute: DashboardRoute,
|
||||||
@@ -410,8 +558,8 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
|
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
|
||||||
PlanesListaRoute: PlanesListaRouteWithChildren,
|
PlanesListaRoute: PlanesListaRouteWithChildren,
|
||||||
PlanesPlanIdDetalleRoute: PlanesPlanIdDetalleRouteWithChildren,
|
PlanesPlanIdDetalleRoute: PlanesPlanIdDetalleRouteWithChildren,
|
||||||
PlanesPlanIdAsignaturasAsignaturaIdRoute:
|
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute:
|
||||||
PlanesPlanIdAsignaturasAsignaturaIdRoute,
|
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -1,22 +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 { 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={{
|
||||||
@@ -60,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,7 +1,6 @@
|
|||||||
import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Copy,
|
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -87,7 +86,7 @@ function AsignaturasPage() {
|
|||||||
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)
|
||||||
|
|
||||||
@@ -99,8 +98,8 @@ function AsignaturasPage() {
|
|||||||
|
|
||||||
// 3. Procesamiento de datos
|
// 3. Procesamiento de datos
|
||||||
const asignaturas = useMemo(
|
const asignaturas = useMemo(
|
||||||
() => mapAsignaturas(asignaturasApi),
|
() => mapAsignaturas(asignaturaApi),
|
||||||
[asignaturasApi],
|
[asignaturaApi],
|
||||||
)
|
)
|
||||||
const lineas = useMemo(() => lineasApi || [], [lineasApi])
|
const lineas = useMemo(() => lineasApi || [], [lineasApi])
|
||||||
|
|
||||||
@@ -144,9 +143,6 @@ function AsignaturasPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Copy className="mr-2 h-4 w-4" /> Clonar
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log('planId desde asignaturas', planId)
|
console.log('planId desde asignaturas', planId)
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ function RouteComponent() {
|
|||||||
}`,
|
}`,
|
||||||
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,
|
||||||
@@ -298,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" />
|
||||||
@@ -306,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">
|
||||||
@@ -328,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,5 +1,6 @@
|
|||||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
/* 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 {
|
||||||
Send,
|
Send,
|
||||||
@@ -13,16 +14,24 @@ import {
|
|||||||
MessageSquarePlus,
|
MessageSquarePlus,
|
||||||
Archive,
|
Archive,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
|
||||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
import 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 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 { 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 = [
|
||||||
@@ -58,18 +67,42 @@ interface SelectedField {
|
|||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
interface EstructuraDefinicion {
|
||||||
|
properties?: {
|
||||||
|
[key: string]: {
|
||||||
|
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()
|
||||||
|
const { data } = usePlan(planId)
|
||||||
const { data } = usePlan('0e0aea4d-b8b4-4e75-8279-6224c3ac769f')
|
|
||||||
const routerState = useRouterState()
|
const routerState = useRouterState()
|
||||||
const [openIA, setOpenIA] = useState(false)
|
const [openIA, setOpenIA] = useState(false)
|
||||||
// archivos
|
const { mutateAsync: sendChat, isPending: isLoading } = useAIPlanChat()
|
||||||
|
const { mutate: updateStatusMutation } = useUpdateConversationStatus()
|
||||||
|
|
||||||
|
const [activeChatId, setActiveChatId] = useState<string | undefined>(
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
const { data: lastConversation, isLoading: isLoadingConv } =
|
||||||
|
useConversationByPlan(planId)
|
||||||
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
|
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -78,118 +111,240 @@ function RouteComponent() {
|
|||||||
>([])
|
>([])
|
||||||
const [uploadedFiles, setUploadedFiles] = useState<Array<UploadedFile>>([])
|
const [uploadedFiles, setUploadedFiles] = useState<Array<UploadedFile>>([])
|
||||||
|
|
||||||
const [messages, setMessages] = useState<Array<any>>([
|
const [messages, setMessages] = useState<Array<any>>([])
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
role: 'assistant',
|
|
||||||
content:
|
|
||||||
'¡Hola! Soy tu asistente de IA. ¿Qué campos deseas mejorar? Puedes escribir ":" para seleccionar uno.',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
const [input, setInput] = useState('')
|
const [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 [activeChatId, setActiveChatId] = useState('1')
|
|
||||||
const [chatHistory, setChatHistory] = useState([
|
|
||||||
{ id: '1', title: 'Chat inicial' },
|
|
||||||
])
|
|
||||||
const [showArchived, setShowArchived] = useState(false)
|
const [showArchived, setShowArchived] = useState(false)
|
||||||
const [archivedHistory, setArchivedHistory] = useState<Array<any>>([])
|
const [editingChatId, setEditingChatId] = useState<string | null>(null)
|
||||||
const [allMessages, setAllMessages] = useState<{ [key: string]: Array<any> }>(
|
const editableRef = useRef<HTMLSpanElement>(null)
|
||||||
{
|
const { mutate: updateTitleMutation } = useUpdateConversationTitle()
|
||||||
'1': [
|
const [isSending, setIsSending] = useState(false)
|
||||||
{
|
const [optimisticMessage, setOptimisticMessage] = useState<string | null>(
|
||||||
id: 'm1',
|
null,
|
||||||
role: 'assistant',
|
|
||||||
content: '¡Hola! Soy tu asistente de IA en este chat inicial.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
const createNewChat = () => {
|
const [filterQuery, setFilterQuery] = useState('')
|
||||||
const newId = Date.now().toString()
|
|
||||||
const newChat = { id: newId, title: `Nuevo chat ${chatHistory.length + 1}` }
|
|
||||||
|
|
||||||
setChatHistory([newChat, ...chatHistory])
|
|
||||||
setAllMessages({
|
|
||||||
...allMessages,
|
|
||||||
[newId]: [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
role: 'assistant',
|
|
||||||
content: '¡Nuevo chat creado! ¿En qué puedo ayudarte?',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
setActiveChatId(newId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const archiveChat = (e: React.MouseEvent, id: string) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
|
|
||||||
const chatToArchive = chatHistory.find((chat) => chat.id === id)
|
|
||||||
if (chatToArchive) {
|
|
||||||
setArchivedHistory([chatToArchive, ...archivedHistory])
|
|
||||||
const newHistory = chatHistory.filter((chat) => chat.id !== id)
|
|
||||||
setChatHistory(newHistory)
|
|
||||||
if (activeChatId === id && newHistory.length > 0) {
|
|
||||||
setActiveChatId(newHistory[0].id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const unarchiveChat = (e: React.MouseEvent, id: string) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
const chatToRestore = archivedHistory.find((chat) => chat.id === id)
|
|
||||||
if (chatToRestore) {
|
|
||||||
setChatHistory([chatToRestore, ...chatHistory])
|
|
||||||
setArchivedHistory(archivedHistory.filter((chat) => chat.id !== id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Transformar datos de la API para el menú de selección
|
|
||||||
const availableFields = useMemo(() => {
|
const availableFields = useMemo(() => {
|
||||||
if (!data?.estructuras_plan?.definicion?.properties) return []
|
const definicion = data?.estructuras_plan
|
||||||
return Object.entries(data.estructuras_plan.definicion.properties).map(
|
?.definicion as EstructuraDefinicion
|
||||||
([key, value]) => ({
|
|
||||||
|
// Encadenamiento opcional para evitar errores si data es null
|
||||||
|
if (!definicion.properties) return []
|
||||||
|
|
||||||
|
return Object.entries(definicion.properties).map(([key, value]) => ({
|
||||||
key,
|
key,
|
||||||
label: value.title,
|
label: value.title,
|
||||||
value: String(value.description || ''),
|
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?.campo_edit || availableFields.length === 0) return
|
if (!state?.campo_edit || availableFields.length === 0) return
|
||||||
|
|
||||||
const field = availableFields.find(
|
const field = availableFields.find(
|
||||||
(f) =>
|
(f) =>
|
||||||
f.value === state.campo_edit.label || f.key === state.campo_edit.clave,
|
f.value === state.campo_edit.label || f.key === state.campo_edit.clave,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!field) return
|
if (!field) return
|
||||||
|
|
||||||
setSelectedFields([field])
|
setSelectedFields([field])
|
||||||
setInput((prev) =>
|
setInput((prev) =>
|
||||||
injectFieldsIntoInput(prev || 'Mejora este campo:', [field]),
|
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)
|
||||||
|
|
||||||
// Si el último carácter es ':', mostramos sugerencias
|
// Busca un ":" seguido de letras justo antes del cursor
|
||||||
if (val.endsWith(':')) {
|
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('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +352,6 @@ function RouteComponent() {
|
|||||||
input: string,
|
input: string,
|
||||||
fields: Array<SelectedField>,
|
fields: Array<SelectedField>,
|
||||||
) => {
|
) => {
|
||||||
// Quita cualquier bloque previo de campos
|
|
||||||
const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim()
|
const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim()
|
||||||
|
|
||||||
if (fields.length === 0) return cleaned
|
if (fields.length === 0) return cleaned
|
||||||
@@ -208,92 +362,73 @@ function RouteComponent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleField = (field: SelectedField) => {
|
const toggleField = (field: SelectedField) => {
|
||||||
// 1. Actualizamos los campos seleccionados (para los badges y la lógica de la IA)
|
// 1. Lo agregamos a la lista de "SelectedFields" (para que la IA sepa qué procesar)
|
||||||
setSelectedFields((prev) => {
|
setSelectedFields((prev) => {
|
||||||
const isSelected = prev.find((f) => f.key === field.key)
|
const isSelected = prev.find((f) => f.key === field.key)
|
||||||
return isSelected ? prev : [...prev, field]
|
return isSelected ? prev : [...prev, field]
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. Insertamos el nombre del campo en el texto y quitamos el ":"
|
// 2. Insertamos el nombre del campo en el texto exactamente donde estaba el ":"
|
||||||
setInput((prevInput) => {
|
setInput((prev) => {
|
||||||
// Buscamos la última posición del ":"
|
// Reemplaza el último ":" y cualquier texto de filtro por el label del campo
|
||||||
const lastColonIndex = prevInput.lastIndexOf(':')
|
const nuevoTexto = prev.replace(/:(\w*)$/, field.label)
|
||||||
|
return nuevoTexto + ' ' // Añadimos un espacio para que el usuario siga escribiendo
|
||||||
if (lastColonIndex !== -1) {
|
|
||||||
// Tomamos lo que está antes del ":" y le concatenamos el nombre del campo
|
|
||||||
const textBefore = prevInput.substring(0, lastColonIndex)
|
|
||||||
const textAfter = prevInput.substring(lastColonIndex + 1)
|
|
||||||
|
|
||||||
// Retornamos el texto con el nombre del campo (puedes añadir espacio si prefieres)
|
|
||||||
return `${textBefore} ${field.label}${textAfter}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return prevInput
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 3. Limpiamos estados de búsqueda
|
||||||
setShowSuggestions(false)
|
setShowSuggestions(false)
|
||||||
|
setFilterQuery('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildPrompt = (userInput: string) => {
|
const buildPrompt = (userInput: string, fields: Array<SelectedField>) => {
|
||||||
// Si no hay campos, enviamos solo el texto
|
if (fields.length === 0) return userInput
|
||||||
if (selectedFields.length === 0) return userInput
|
|
||||||
|
|
||||||
const fieldsText = selectedFields
|
return ` ${userInput}`
|
||||||
.map(
|
|
||||||
(f) =>
|
|
||||||
`### CAMPO: ${f.label}\nCONTENIDO ACTUAL: ${f.value || '(vacío)'}`,
|
|
||||||
)
|
|
||||||
.join('\n\n')
|
|
||||||
|
|
||||||
return `Instrucción del usuario: ${userInput || 'Mejora los campos seleccionados.'}
|
|
||||||
|
|
||||||
A continuación se detallan los campos a procesar:
|
|
||||||
${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)
|
|
||||||
// setSelectedFields([])
|
|
||||||
setSelectedArchivoIds([])
|
setSelectedArchivoIds([])
|
||||||
setSelectedRepositorioIds([])
|
setSelectedRepositorioIds([])
|
||||||
setUploadedFiles([])
|
setUploadedFiles([])
|
||||||
|
try {
|
||||||
setTimeout(() => {
|
const payload: any = {
|
||||||
const suggestions = selectedFields.map((field) => ({
|
planId: planId,
|
||||||
key: field.key,
|
content: finalPrompt,
|
||||||
label: field.label,
|
conversacionId: activeChatId || undefined,
|
||||||
newValue: field.value,
|
}
|
||||||
}))
|
|
||||||
|
if (currentFields.length > 0) {
|
||||||
setMessages((prev) => [
|
payload.campos = currentFields.map((f) => f.key)
|
||||||
...prev,
|
}
|
||||||
{
|
|
||||||
id: Date.now().toString(),
|
const response = await sendChat(payload)
|
||||||
role: 'assistant',
|
|
||||||
type: 'improvement-card',
|
if (response.conversacionId && response.conversacionId !== activeChatId) {
|
||||||
content:
|
setActiveChatId(response.conversacionId)
|
||||||
'He analizado los campos seleccionados. Aquí tienes mis sugerencias de mejora:',
|
}
|
||||||
suggestions: suggestions,
|
|
||||||
},
|
await queryClient.invalidateQueries({
|
||||||
])
|
queryKey: ['conversation-by-plan', planId],
|
||||||
setIsLoading(false)
|
})
|
||||||
}, 1200)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... debajo de tus otros hooks
|
|
||||||
const totalReferencias = useMemo(() => {
|
const totalReferencias = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
selectedArchivoIds.length +
|
selectedArchivoIds.length +
|
||||||
@@ -302,6 +437,10 @@ ${fieldsText}`.trim()
|
|||||||
)
|
)
|
||||||
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
|
}, [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 --- */}
|
{/* --- PANEL IZQUIERDO: HISTORIAL --- */}
|
||||||
@@ -336,10 +475,8 @@ ${fieldsText}`.trim()
|
|||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{/* Lógica de renderizado condicional */}
|
|
||||||
{!showArchived ? (
|
{!showArchived ? (
|
||||||
// LISTA DE CHATS ACTIVOS
|
activeChats.map((chat) => (
|
||||||
chatHistory.map((chat) => (
|
|
||||||
<div
|
<div
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
onClick={() => setActiveChatId(chat.id)}
|
onClick={() => setActiveChatId(chat.id)}
|
||||||
@@ -350,45 +487,99 @@ ${fieldsText}`.trim()
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText size={16} className="shrink-0 opacity-40" />
|
<FileText size={16} className="shrink-0 opacity-40" />
|
||||||
<span className="truncate pr-8">{chat.title}</span>
|
|
||||||
|
<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
|
<button
|
||||||
onClick={(e) => archiveChat(e, chat.id)}
|
onClick={(e) => archiveChat(e, chat.id)}
|
||||||
className="absolute right-2 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-amber-600"
|
className="p-1 text-slate-400 hover:text-amber-600"
|
||||||
title="Archivar"
|
|
||||||
>
|
>
|
||||||
<Archive size={14} />
|
<Archive size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
// LISTA DE CHATS ARCHIVADOS
|
/* ... Resto del código de archivados (sin cambios) ... */
|
||||||
<div className="animate-in fade-in slide-in-from-left-2">
|
<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">
|
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
|
||||||
Archivados
|
Archivados
|
||||||
</p>
|
</p>
|
||||||
{archivedHistory.map((chat) => (
|
{archivedChats.map((chat) => (
|
||||||
<div
|
<div
|
||||||
key={chat.id}
|
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"
|
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" />
|
<Archive size={14} className="shrink-0 opacity-30" />
|
||||||
<span className="truncate pr-8">{chat.title}</span>
|
<span className="truncate pr-8">
|
||||||
|
{chat.nombre ||
|
||||||
|
`Archivado ${chat.creado_en.split('T')[0]}`}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => unarchiveChat(e, chat.id)}
|
onClick={(e) => unarchiveChat(e, chat.id)}
|
||||||
className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600"
|
className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600"
|
||||||
title="Desarchivar"
|
|
||||||
>
|
>
|
||||||
<RotateCcw size={14} />
|
<RotateCcw size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{archivedHistory.length === 0 && (
|
|
||||||
<div className="px-2 py-4 text-center">
|
|
||||||
<p className="text-xs text-slate-400 italic">
|
|
||||||
No hay archivados
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -421,40 +612,98 @@ ${fieldsText}`.trim()
|
|||||||
<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
|
||||||
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}
|
key={msg.id}
|
||||||
>
|
className={`flex max-w-[85%] flex-col ${
|
||||||
<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'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
{msg.content}
|
{msg.content}
|
||||||
|
|
||||||
{msg.type === 'improvement-card' && (
|
{!msg.isRefusal &&
|
||||||
|
msg.suggestions &&
|
||||||
|
msg.suggestions.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
<ImprovementCard
|
<ImprovementCard
|
||||||
suggestions={msg.suggestions}
|
suggestions={msg.suggestions}
|
||||||
onApply={(key, val) => {
|
planId={planId}
|
||||||
setSelectedFields((prev) =>
|
currentDatos={data?.datos}
|
||||||
prev.filter((f) => f.key !== key),
|
activeChatId={activeChatId}
|
||||||
)
|
onApplySuccess={(key) =>
|
||||||
console.log(`Aplicando ${val} al campo ${key}`)
|
removeSelectedField(key)
|
||||||
// Aquí llamarías a tu función de actualización de datos real
|
}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
</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>
|
||||||
@@ -485,25 +734,35 @@ ${fieldsText}`.trim()
|
|||||||
<div className="relative mx-auto max-w-4xl">
|
<div className="relative mx-auto max-w-4xl">
|
||||||
{/* MENÚ DE SUGERENCIAS FLOTANTE */}
|
{/* 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>
|
||||||
)}
|
)}
|
||||||
@@ -535,6 +794,35 @@ ${fieldsText}`.trim()
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={input}
|
value={input}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (showSuggestions) {
|
||||||
|
if (e.key === 'Tab' || e.key === 'Enter') {
|
||||||
|
if (filteredFields.length > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
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={
|
||||||
selectedFields.length > 0
|
selectedFields.length > 0
|
||||||
? 'Escribe instrucciones adicionales...'
|
? 'Escribe instrucciones adicionales...'
|
||||||
@@ -545,12 +833,16 @@ ${fieldsText}`.trim()
|
|||||||
<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>
|
||||||
@@ -620,66 +912,3 @@ ${fieldsText}`.trim()
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImprovementCard = ({
|
|
||||||
suggestions,
|
|
||||||
onApply,
|
|
||||||
}: {
|
|
||||||
suggestions: Array<any>
|
|
||||||
onApply: (key: string, value: string) => void
|
|
||||||
}) => {
|
|
||||||
// Estado para rastrear qué campos han sido aplicados
|
|
||||||
const [appliedFields, setAppliedFields] = useState<Array<string>>([])
|
|
||||||
|
|
||||||
const handleApply = (key: string, value: string) => {
|
|
||||||
onApply(key, value)
|
|
||||||
setAppliedFields((prev) => [...prev, key])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-2 flex w-full flex-col gap-4">
|
|
||||||
{suggestions.map((sug) => {
|
|
||||||
const isApplied = appliedFields.includes(sug.key)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={sug.key}
|
|
||||||
className="rounded-2xl border border-slate-100 bg-white p-5 shadow-sm"
|
|
||||||
>
|
|
||||||
<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}
|
|
||||||
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]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{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-[#ccfbf1] bg-[#f0fdfa] text-slate-700'
|
|
||||||
: 'border-slate-200 bg-slate-50 text-slate-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{sug.newValue}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ function DatosGeneralesPage() {
|
|||||||
if (!data?.datos) return
|
if (!data?.datos) return
|
||||||
|
|
||||||
const datosActualizados = prepararDatosActualizados(data, campo, valor)
|
const datosActualizados = prepararDatosActualizados(data, campo, valor)
|
||||||
|
console.log(datosActualizados)
|
||||||
|
|
||||||
updatePlan.mutate({
|
updatePlan.mutate({
|
||||||
planId,
|
planId,
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Pencil,
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useMemo, useState, useEffect } from 'react'
|
import { useMemo, useState, useEffect, Fragment } from 'react'
|
||||||
|
|
||||||
|
import type { TipoAsignatura } from '@/data'
|
||||||
import type { Asignatura, LineaCurricular } from '@/types/plan'
|
import type { Asignatura, LineaCurricular } from '@/types/plan'
|
||||||
|
import type { TablesUpdate } from '@/types/supabase'
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -60,21 +62,23 @@ const mapLineasToLineaCurricular = (
|
|||||||
const mapAsignaturasToAsignaturas = (
|
const mapAsignaturasToAsignaturas = (
|
||||||
asigApi: Array<any> = [],
|
asigApi: Array<any> = [],
|
||||||
): Array<Asignatura> => {
|
): Array<Asignatura> => {
|
||||||
return asigApi.map((asig) => ({
|
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,
|
||||||
// Mapeo directo de los nuevos campos de la API
|
// Mapeo directo de los nuevos campos de la API
|
||||||
hd: asig.horas_academicas ?? 0,
|
hd: asig.horas_academicas ?? 0,
|
||||||
hi: asig.horas_independientes ?? 0,
|
hi: asig.horas_independientes ?? 0,
|
||||||
prerrequisitos: [],
|
prerrequisitos: [],
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const lineColors = [
|
const lineColors = [
|
||||||
@@ -179,7 +183,7 @@ function MapaCurricularPage() {
|
|||||||
const { mutate: createLinea } = useCreateLinea()
|
const { mutate: createLinea } = useCreateLinea()
|
||||||
const { mutate: updateLineaApi } = useUpdateLinea()
|
const { mutate: updateLineaApi } = useUpdateLinea()
|
||||||
const { mutate: deleteLineaApi } = useDeleteLinea()
|
const { mutate: deleteLineaApi } = useDeleteLinea()
|
||||||
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)
|
||||||
const [asignaturas, setAsignaturas] = useState<Array<Asignatura>>([])
|
const [asignaturas, setAsignaturas] = useState<Array<Asignatura>>([])
|
||||||
@@ -190,7 +194,7 @@ function MapaCurricularPage() {
|
|||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
const [isEditModalOpen, setIsEditModalOpen] = 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 { mutate: updateAsignatura } = useUpdateAsignatura()
|
||||||
const [seriacionValue, setSeriacionValue] = useState<string>('unassigned')
|
const [seriacionValue, setSeriacionValue] = useState<string>('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.numero_ciclos) {
|
if (data?.numero_ciclos) {
|
||||||
@@ -282,9 +286,9 @@ function MapaCurricularPage() {
|
|||||||
}, [lineas])
|
}, [lineas])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (asignaturasApi)
|
if (asignaturaApi)
|
||||||
setAsignaturas(mapAsignaturasToAsignaturas(asignaturasApi))
|
setAsignaturas(mapAsignaturasToAsignaturas(asignaturaApi))
|
||||||
}, [asignaturasApi])
|
}, [asignaturaApi])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi))
|
if (lineasApi) setLineas(mapLineasToLineaCurricular(lineasApi))
|
||||||
@@ -323,7 +327,17 @@ function MapaCurricularPage() {
|
|||||||
setAsignaturas((prev) =>
|
setAsignaturas((prev) =>
|
||||||
prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)),
|
prev.map((m) => (m.id === editingData.id ? { ...editingData } : m)),
|
||||||
)
|
)
|
||||||
const patch = {
|
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,
|
nombre: editingData.nombre,
|
||||||
codigo: editingData.clave,
|
codigo: editingData.clave,
|
||||||
creditos: editingData.creditos,
|
creditos: editingData.creditos,
|
||||||
@@ -331,12 +345,11 @@ function MapaCurricularPage() {
|
|||||||
horas_independientes: editingData.hi,
|
horas_independientes: editingData.hi,
|
||||||
numero_ciclo: editingData.ciclo,
|
numero_ciclo: editingData.ciclo,
|
||||||
linea_plan_id: editingData.lineaCurricularId,
|
linea_plan_id: editingData.lineaCurricularId,
|
||||||
tipo: editingData.tipo.toUpperCase(), // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
|
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
|
||||||
// datos: editingData.datos, // Si editaste algo del JSONB
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAsignatura(
|
updateAsignatura(
|
||||||
{ asignaturaId: editingData.id, patch },
|
{ asignaturaId: editingData.id, patch: patch as any },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setIsEditModalOpen(false)
|
setIsEditModalOpen(false)
|
||||||
@@ -575,37 +588,33 @@ 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 (
|
||||||
<div
|
<Fragment key={linea.id}>
|
||||||
key={linea.id}
|
|
||||||
className="mb-3 grid gap-3"
|
|
||||||
style={{
|
|
||||||
gridTemplateColumns: `220px repeat(${ciclosTotales}, 1fr) 120px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${
|
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${
|
||||||
lineColors[idx % lineColors.length]
|
lineColors[idx % lineColors.length]
|
||||||
@@ -633,41 +642,34 @@ function MapaCurricularPage() {
|
|||||||
{linea.nombre}
|
{linea.nombre}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Botón de edición que aparece en hover o si está editando */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingLineaId(linea.id)}
|
onClick={() => setEditingLineaId(linea.id)}
|
||||||
className={`text-slate-400 transition-opacity hover:text-teal-600 ${
|
className="..."
|
||||||
editingLineaId === linea.id
|
|
||||||
? 'opacity-0'
|
|
||||||
: 'opacity-0 group-hover:opacity-100'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Pencil size={12} />
|
{' '}
|
||||||
|
<Pencil size={12} />{' '}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Trash2
|
<Trash2
|
||||||
size={14}
|
|
||||||
className="cursor-pointer text-slate-400 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-500"
|
|
||||||
onClick={() => borrarLinea(linea.id)}
|
onClick={() => borrarLinea(linea.id)}
|
||||||
|
className="..."
|
||||||
|
size={14}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ciclosArray.map((ciclo) => (
|
{ciclosArray.map((ciclo) => (
|
||||||
<div
|
<div
|
||||||
key={ciclo}
|
key={`${linea.id}-${ciclo}`}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
// ANTES: onDrop={(e) => handleDrop(e, null, null)}
|
|
||||||
// AHORA: Usamos las variables 'ciclo' y 'linea.id' del map
|
|
||||||
onDrop={(e) => handleDrop(e, ciclo, linea.id)}
|
onDrop={(e) => handleDrop(e, ciclo, linea.id)}
|
||||||
className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2"
|
className="min-h-[140px] space-y-2 rounded-xl border-2 border-dashed border-slate-100 bg-slate-50/20 p-2"
|
||||||
>
|
>
|
||||||
{asignaturas
|
{asignaturas
|
||||||
.filter(
|
.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.ciclo === ciclo && m.lineaCurricularId === linea.id,
|
m.ciclo === ciclo &&
|
||||||
|
m.lineaCurricularId === linea.id,
|
||||||
)
|
)
|
||||||
.map((m) => (
|
.map((m) => (
|
||||||
<AsignaturaCardItem
|
<AsignaturaCardItem
|
||||||
@@ -689,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>
|
||||||
@@ -716,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>
|
||||||
@@ -725,7 +725,6 @@ function MapaCurricularPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Asignaturas 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">
|
||||||
@@ -941,8 +940,8 @@ function MapaCurricularPage() {
|
|||||||
<Select
|
<Select
|
||||||
value={seriacionValue}
|
value={seriacionValue}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
if (val === 'unassigned') {
|
if (val === 'none') {
|
||||||
setSeriacionValue('unassigned')
|
setSeriacionValue('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!editingData.prerrequisitos.includes(val)) {
|
if (!editingData.prerrequisitos.includes(val)) {
|
||||||
@@ -951,21 +950,19 @@ function MapaCurricularPage() {
|
|||||||
prerrequisitos: [...editingData.prerrequisitos, val],
|
prerrequisitos: [...editingData.prerrequisitos, val],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
setSeriacionValue('unassigned')
|
setSeriacionValue('')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Seleccionar asignatura..." />
|
<SelectValue placeholder="Seleccionar asignatura..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="unassigned">
|
<SelectItem value="none">-- Sin Seriación --</SelectItem>
|
||||||
-- Sin Seriación --
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
{asignaturas
|
{asignaturas
|
||||||
.filter((m) => m.id !== editingData.id)
|
.filter((m) => m.id !== editingData.id)
|
||||||
.map((m) => (
|
.map((m) => (
|
||||||
<SelectItem key={m.id} value={m.clave}>
|
<SelectItem key={m.id} value={m.id}>
|
||||||
{m.nombre} ({m.clave})
|
{m.nombre} ({m.clave})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -1006,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 })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -1014,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>
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { createFileRoute, notFound, useLocation } from '@tanstack/react-router'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
|
|
||||||
import AsignaturaDetailPage from '@/components/asignaturas/detalle/AsignaturaDetailPage'
|
|
||||||
import { lateralConfetti } from '@/components/ui/lateral-confetti'
|
|
||||||
import { NotFoundPage } from '@/components/ui/NotFoundPage'
|
|
||||||
import { subjects_get } from '@/data/api/subjects.api'
|
|
||||||
import { qk } from '@/data/query/keys'
|
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
|
||||||
'/planes/$planId/asignaturas/$asignaturaId',
|
|
||||||
)({
|
|
||||||
loader: async ({ context: { queryClient }, params: { asignaturaId } }) => {
|
|
||||||
try {
|
|
||||||
await queryClient.ensureQueryData({
|
|
||||||
queryKey: qk.asignatura(asignaturaId),
|
|
||||||
queryFn: () => subjects_get(asignaturaId),
|
|
||||||
})
|
|
||||||
} catch (e: any) {
|
|
||||||
// PGRST116: The result contains 0 rows (Supabase Single response error)
|
|
||||||
if (e?.code === 'PGRST116') {
|
|
||||||
throw notFound()
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
},
|
|
||||||
notFoundComponent: () => {
|
|
||||||
return (
|
|
||||||
<NotFoundPage
|
|
||||||
title="Materia no encontrada"
|
|
||||||
message="La asignatura que buscas no existe o fue eliminada."
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
component: RouteComponent,
|
|
||||||
})
|
|
||||||
|
|
||||||
function RouteComponent() {
|
|
||||||
// const { planId, asignaturaId } = Route.useParams()
|
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
// Confetti al llegar desde creación
|
|
||||||
useEffect(() => {
|
|
||||||
if ((location.state as any)?.showConfetti) {
|
|
||||||
lateralConfetti()
|
|
||||||
window.history.replaceState({}, document.title) // Limpiar el estado para que no se repita
|
|
||||||
}
|
|
||||||
}, [location.state])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<AsignaturaDetailPage></AsignaturaDetailPage>
|
|
||||||
</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 />
|
||||||
|
}
|
||||||
273
src/routes/planes/$planId/asignaturas/$asignaturaId/route.tsx
Normal file
273
src/routes/planes/$planId/asignaturas/$asignaturaId/route.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import {
|
||||||
|
createFileRoute,
|
||||||
|
Outlet,
|
||||||
|
Link,
|
||||||
|
useLocation,
|
||||||
|
useParams,
|
||||||
|
useRouterState,
|
||||||
|
} from '@tanstack/react-router'
|
||||||
|
import { ArrowLeft, GraduationCap } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { lateralConfetti } from '@/components/ui/lateral-confetti'
|
||||||
|
import { useSubject, useUpdateAsignatura } from '@/data'
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
'/planes/$planId/asignaturas/$asignaturaId',
|
||||||
|
)({
|
||||||
|
component: AsignaturaLayout,
|
||||||
|
})
|
||||||
|
|
||||||
|
function EditableHeaderField({
|
||||||
|
value,
|
||||||
|
onSave,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: string | number
|
||||||
|
onSave: (val: string) => void
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const textValue = String(value)
|
||||||
|
|
||||||
|
// Manejador para cuando el usuario termina de editar (pierde el foco)
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLSpanElement>) => {
|
||||||
|
const newValue = e.currentTarget.innerText
|
||||||
|
if (newValue !== textValue) {
|
||||||
|
onSave(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.currentTarget.blur() // Forzamos el guardado al presionar Enter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||||
|
<span
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning={true} // Evita el warning de React por tener hijos y contentEditable
|
||||||
|
spellCheck={false}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={`inline-block cursor-text rounded-sm px-1 transition-all hover:bg-white/10 focus:bg-white/20 focus:ring-2 focus:ring-blue-400/50 focus:outline-none ${className ?? ''} `}
|
||||||
|
>
|
||||||
|
{textValue}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
interface DatosPlan {
|
||||||
|
nombre?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function AsignaturaLayout() {
|
||||||
|
const location = useLocation()
|
||||||
|
const { asignaturaId } = useParams({
|
||||||
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
|
})
|
||||||
|
const { planId } = useParams({
|
||||||
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
|
})
|
||||||
|
const { data: asignaturaApi, isLoading: loadingAsig } =
|
||||||
|
useSubject(asignaturaId)
|
||||||
|
// 1. Asegúrate de tener estos estados en tu componente principal
|
||||||
|
|
||||||
|
const updateAsignatura = useUpdateAsignatura()
|
||||||
|
|
||||||
|
// Dentro de AsignaturaDetailPage
|
||||||
|
const [headerData, setHeaderData] = useState({
|
||||||
|
codigo: '',
|
||||||
|
nombre: '',
|
||||||
|
creditos: 0,
|
||||||
|
ciclo: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sincronizar cuando llegue la API
|
||||||
|
useEffect(() => {
|
||||||
|
if (asignaturaApi) {
|
||||||
|
setHeaderData({
|
||||||
|
codigo: asignaturaApi.codigo ?? '',
|
||||||
|
nombre: asignaturaApi.nombre,
|
||||||
|
creditos: asignaturaApi.creditos,
|
||||||
|
ciclo: asignaturaApi.numero_ciclo ?? 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [asignaturaApi])
|
||||||
|
|
||||||
|
const handleUpdateHeader = (key: string, value: string | number) => {
|
||||||
|
const newData = { ...headerData, [key]: value }
|
||||||
|
setHeaderData(newData)
|
||||||
|
|
||||||
|
const patch: Record<string, any> =
|
||||||
|
key === 'ciclo'
|
||||||
|
? { numero_ciclo: value }
|
||||||
|
: {
|
||||||
|
[key]: value,
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAsignatura.mutate({
|
||||||
|
asignaturaId,
|
||||||
|
patch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = useRouterState({
|
||||||
|
select: (state) => state.location.pathname,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Confetti al llegar desde creación IA
|
||||||
|
useEffect(() => {
|
||||||
|
if ((location.state as any)?.showConfetti) {
|
||||||
|
lateralConfetti()
|
||||||
|
window.history.replaceState({}, document.title)
|
||||||
|
}
|
||||||
|
}, [location.state])
|
||||||
|
|
||||||
|
if (loadingAsig) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white">
|
||||||
|
Cargando asignatura...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no hay datos y no está cargando, algo falló
|
||||||
|
if (!asignaturaApi) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
|
||||||
|
<div className="mx-auto max-w-7xl px-6 py-10">
|
||||||
|
<Link
|
||||||
|
to="/planes/$planId/asignaturas"
|
||||||
|
params={{ planId }}
|
||||||
|
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" /> Volver al plan
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* CÓDIGO EDITABLE */}
|
||||||
|
<Badge className="border border-blue-700 bg-blue-900/50">
|
||||||
|
<EditableHeaderField
|
||||||
|
value={headerData.codigo}
|
||||||
|
onSave={(val) => handleUpdateHeader('codigo', val)}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* NOMBRE EDITABLE */}
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
<EditableHeaderField
|
||||||
|
value={headerData.nombre}
|
||||||
|
onSave={(val) => handleUpdateHeader('nombre', val)}
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<GraduationCap className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="text-blue-100">
|
||||||
|
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
|
||||||
|
.nombre || ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="text-blue-100">
|
||||||
|
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
|
||||||
|
.nombre ?? ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-blue-300">
|
||||||
|
Pertenece al plan:{' '}
|
||||||
|
<span className="cursor-pointer underline">
|
||||||
|
{asignaturaApi.planes_estudio?.nombre}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-2 text-right">
|
||||||
|
{/* CRÉDITOS EDITABLES */}
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<span className="inline-flex max-w-fit">
|
||||||
|
<EditableHeaderField
|
||||||
|
value={headerData.creditos}
|
||||||
|
onSave={(val) =>
|
||||||
|
handleUpdateHeader('creditos', parseInt(val) || 0)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span>créditos</span>
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* SEMESTRE EDITABLE */}
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<EditableHeaderField
|
||||||
|
value={headerData.ciclo}
|
||||||
|
onSave={(val) =>
|
||||||
|
handleUpdateHeader('ciclo', parseInt(val) || 0)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>° ciclo</span>
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<Badge variant="secondary">{asignaturaApi.tipo}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* TABS */}
|
||||||
|
|
||||||
|
<nav className="border-b bg-white">
|
||||||
|
<div className="mx-auto max-w-7xl px-6">
|
||||||
|
<div className="flex justify-center gap-8">
|
||||||
|
{[
|
||||||
|
{ label: 'Datos', to: '' },
|
||||||
|
{ label: 'Contenido', to: 'contenido' },
|
||||||
|
{ label: 'Bibliografía', to: 'bibliografia' },
|
||||||
|
{ label: 'IA', to: 'asignaturaIa' },
|
||||||
|
{ label: 'Documento SEP', to: 'documento' },
|
||||||
|
{ label: 'Historial', to: 'historial' },
|
||||||
|
].map((tab) => {
|
||||||
|
const isActive =
|
||||||
|
tab.to === ''
|
||||||
|
? pathname === `/planes/${planId}/asignaturas/${asignaturaId}`
|
||||||
|
: pathname.includes(tab.to)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.label}
|
||||||
|
to={
|
||||||
|
(tab.to === ''
|
||||||
|
? '/planes/$planId/asignaturas/$asignaturaId'
|
||||||
|
: `/planes/$planId/asignaturas/$asignaturaId/${tab.to}`) as any
|
||||||
|
}
|
||||||
|
from="/planes/$planId/asignaturas/$asignaturaId"
|
||||||
|
params={{ planId, asignaturaId }}
|
||||||
|
className={`border-b-2 py-3 text-sm font-medium ${
|
||||||
|
isActive
|
||||||
|
? 'border-blue-600 text-blue-600'
|
||||||
|
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export type Json =
|
export type Json =
|
||||||
| string
|
| string
|
||||||
| number
|
| number
|
||||||
| boolean
|
| boolean
|
||||||
@@ -460,6 +460,7 @@ export type Database = {
|
|||||||
estado: Database['public']['Enums']['estado_conversacion']
|
estado: Database['public']['Enums']['estado_conversacion']
|
||||||
id: string
|
id: string
|
||||||
intento_archivado: number
|
intento_archivado: number
|
||||||
|
nombre: string | null
|
||||||
openai_conversation_id: string
|
openai_conversation_id: string
|
||||||
plan_estudio_id: string
|
plan_estudio_id: string
|
||||||
}
|
}
|
||||||
@@ -472,6 +473,7 @@ export type Database = {
|
|||||||
estado?: Database['public']['Enums']['estado_conversacion']
|
estado?: Database['public']['Enums']['estado_conversacion']
|
||||||
id?: string
|
id?: string
|
||||||
intento_archivado?: number
|
intento_archivado?: number
|
||||||
|
nombre?: string | null
|
||||||
openai_conversation_id: string
|
openai_conversation_id: string
|
||||||
plan_estudio_id: string
|
plan_estudio_id: string
|
||||||
}
|
}
|
||||||
@@ -484,6 +486,7 @@ export type Database = {
|
|||||||
estado?: Database['public']['Enums']['estado_conversacion']
|
estado?: Database['public']['Enums']['estado_conversacion']
|
||||||
id?: string
|
id?: string
|
||||||
intento_archivado?: number
|
intento_archivado?: number
|
||||||
|
nombre?: string | null
|
||||||
openai_conversation_id?: string
|
openai_conversation_id?: string
|
||||||
plan_estudio_id?: string
|
plan_estudio_id?: string
|
||||||
}
|
}
|
||||||
@@ -1206,11 +1209,24 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Functions: {
|
Functions: {
|
||||||
|
append_conversacion_asignatura: {
|
||||||
|
Args: { p_append: Json; p_id: string }
|
||||||
|
Returns: undefined
|
||||||
|
}
|
||||||
|
append_conversacion_plan: {
|
||||||
|
Args: { p_append: Json; p_id: string }
|
||||||
|
Returns: undefined
|
||||||
|
}
|
||||||
unaccent: { Args: { '': string }; Returns: string }
|
unaccent: { Args: { '': string }; Returns: string }
|
||||||
unaccent_immutable: { Args: { '': string }; Returns: string }
|
unaccent_immutable: { Args: { '': string }; Returns: string }
|
||||||
}
|
}
|
||||||
Enums: {
|
Enums: {
|
||||||
estado_asignatura: 'borrador' | 'revisada' | 'aprobada' | 'generando'
|
estado_asignatura:
|
||||||
|
| 'borrador'
|
||||||
|
| 'revisada'
|
||||||
|
| 'aprobada'
|
||||||
|
| 'generando'
|
||||||
|
| 'fallida'
|
||||||
estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR'
|
estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR'
|
||||||
estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA'
|
estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA'
|
||||||
fuente_cambio: 'HUMANO' | 'IA'
|
fuente_cambio: 'HUMANO' | 'IA'
|
||||||
@@ -1384,7 +1400,13 @@ export const Constants = {
|
|||||||
},
|
},
|
||||||
public: {
|
public: {
|
||||||
Enums: {
|
Enums: {
|
||||||
estado_asignatura: ['borrador', 'revisada', 'aprobada', 'generando'],
|
estado_asignatura: [
|
||||||
|
'borrador',
|
||||||
|
'revisada',
|
||||||
|
'aprobada',
|
||||||
|
'generando',
|
||||||
|
'fallida',
|
||||||
|
],
|
||||||
estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'],
|
estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'],
|
||||||
estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'],
|
estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'],
|
||||||
fuente_cambio: ['HUMANO', 'IA'],
|
fuente_cambio: ['HUMANO', 'IA'],
|
||||||
|
|||||||
Reference in New Issue
Block a user