Compare commits
47 Commits
50c00293cd
...
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 |
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-tooltip": "^1.2.8",
|
||||
"@stepperize/react": "^5.1.9",
|
||||
"@supabase/supabase-js": "^2.90.1",
|
||||
"@supabase/supabase-js": "^2.98.0",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/react-devtools": "^0.7.0",
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
@@ -17,11 +18,11 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"registries": {
|
||||
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
|
||||
"@ss-components": "https://shadcnstudio.com/r/components/{name}.json",
|
||||
"@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json",
|
||||
"@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json"
|
||||
"@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-tooltip": "^1.2.8",
|
||||
"@stepperize/react": "^5.1.9",
|
||||
"@supabase/supabase-js": "^2.90.1",
|
||||
"@supabase/supabase-js": "^2.98.0",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/react-devtools": "^0.7.0",
|
||||
"@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 { Home, Menu, Network, X } from 'lucide-react'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { Home, LogOut, Menu, Network, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { supabaseBrowser } from '@/data/supabase/client'
|
||||
|
||||
export default function Header() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await supabaseBrowser().auth.signOut()
|
||||
} finally {
|
||||
void navigate({ to: '/login', replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -18,13 +29,19 @@ export default function Header() {
|
||||
</button>
|
||||
<h1 className="ml-4 text-xl font-semibold">
|
||||
<Link to="/">
|
||||
<img
|
||||
src="/tanstack-word-logo-white.svg"
|
||||
alt="TanStack Logo"
|
||||
className="h-10"
|
||||
/>
|
||||
<img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" />
|
||||
</Link>
|
||||
</h1>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="ml-auto inline-flex items-center gap-2 rounded-lg p-2 transition-colors hover:bg-gray-700"
|
||||
aria-label="Logout"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<span className="hidden sm:inline">Salir</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<aside
|
||||
|
||||
@@ -1,31 +1,11 @@
|
||||
import {
|
||||
createFileRoute,
|
||||
Link,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useRouterState,
|
||||
} from '@tanstack/react-router'
|
||||
import { ArrowLeft, GraduationCap, Pencil, Sparkles } from 'lucide-react'
|
||||
import { useCallback, useState, useEffect } from 'react'
|
||||
|
||||
import { BibliographyItem } from './BibliographyItem'
|
||||
import { ContenidoTematico } from './ContenidoTematico'
|
||||
import { DocumentoSEPTab } from './DocumentoSEPTab'
|
||||
import { HistorialTab } from './HistorialTab'
|
||||
import { IAAsignaturaTab } from './IAAsignaturaTab'
|
||||
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
||||
import { Pencil, Sparkles } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
import type { AsignaturaDetail } from '@/data'
|
||||
import type {
|
||||
CampoEstructura,
|
||||
IAMessage,
|
||||
IASugerencia,
|
||||
} from '@/types/asignatura'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -34,11 +14,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
|
||||
import {
|
||||
mockAsignatura,
|
||||
mockEstructura,
|
||||
mockDocumentoSep,
|
||||
} from '@/data/mockAsignaturaData'
|
||||
|
||||
export interface BibliografiaEntry {
|
||||
id: string
|
||||
@@ -62,45 +37,54 @@ export interface AsignaturaResponse {
|
||||
datos: AsignaturaDatos
|
||||
}
|
||||
|
||||
function EditableHeaderField({
|
||||
value,
|
||||
onSave,
|
||||
className,
|
||||
}: {
|
||||
value: string | number
|
||||
onSave: (val: string) => void
|
||||
className?: string
|
||||
}) {
|
||||
const textValue = String(value)
|
||||
|
||||
// Manejador para cuando el usuario termina de editar (pierde el foco)
|
||||
const handleBlur = (e: React.FocusEvent<HTMLSpanElement>) => {
|
||||
const newValue = e.currentTarget.innerText
|
||||
if (newValue !== textValue) {
|
||||
onSave(newValue)
|
||||
}
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur() // Forzamos el guardado al presionar Enter
|
||||
function parseContenidoTematicoToPlainText(value: unknown): string {
|
||||
if (!Array.isArray(value)) return ''
|
||||
|
||||
const blocks: Array<string> = []
|
||||
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) continue
|
||||
|
||||
const unidad =
|
||||
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
|
||||
? item.unidad
|
||||
: undefined
|
||||
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
|
||||
|
||||
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
|
||||
if (!header) continue
|
||||
|
||||
const lines: Array<string> = [header]
|
||||
|
||||
const temas = Array.isArray(item.temas) ? item.temas : []
|
||||
temas.forEach((tema, idx) => {
|
||||
const temaNombre =
|
||||
typeof tema === 'string'
|
||||
? tema
|
||||
: isRecord(tema) && typeof tema.nombre === 'string'
|
||||
? tema.nombre
|
||||
: ''
|
||||
if (!temaNombre) return
|
||||
|
||||
if (unidad != null) {
|
||||
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
|
||||
} else {
|
||||
lines.push(`${idx + 1}. ${temaNombre}`)
|
||||
}
|
||||
})
|
||||
|
||||
blocks.push(lines.join('\n'))
|
||||
}
|
||||
|
||||
return (
|
||||
// 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>
|
||||
)
|
||||
return blocks.join('\n\n').trimEnd()
|
||||
}
|
||||
|
||||
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
|
||||
contenido_tematico: parseContenidoTematicoToPlainText,
|
||||
}
|
||||
|
||||
export const Route = createFileRoute(
|
||||
@@ -110,77 +94,21 @@ export const Route = createFileRoute(
|
||||
})
|
||||
|
||||
export default function AsignaturaDetailPage() {
|
||||
const routerState = useRouterState()
|
||||
const state = routerState.location.state as any
|
||||
const { asignaturaId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
const { planId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
const { data: asignaturaApi, isLoading: loadingAsig } =
|
||||
useSubject(asignaturaId)
|
||||
// 1. Asegúrate de tener estos estados en tu componente principal
|
||||
const [messages, setMessages] = useState<Array<IAMessage>>([])
|
||||
const [asignatura, setAsignatura] = useState({})
|
||||
const [campos, setCampos] = useState<Array<CampoEstructura>>([])
|
||||
const [activeTab, setActiveTab] = useState('datos')
|
||||
const { data: asignaturaApi } = useSubject(asignaturaId)
|
||||
|
||||
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
|
||||
const updateAsignatura = useUpdateAsignatura()
|
||||
|
||||
// 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 (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 handlePersistDatoGeneral = (clave: string, value: string) => {
|
||||
const baseDatos =
|
||||
(asignatura as any)?.datos ?? (asignaturaApi as any)?.datos ?? {}
|
||||
const baseDatos = asignatura?.datos ?? (asignaturaApi as any)?.datos ?? {}
|
||||
const mergedDatos = { ...baseDatos, [clave]: value }
|
||||
|
||||
// Mantener estado local coherente para merges posteriores.
|
||||
setAsignatura((prev: any) => ({
|
||||
...(prev && Object.keys(prev).length
|
||||
? prev
|
||||
: ((asignaturaApi as any) ?? {})),
|
||||
setAsignatura((prev) => ({
|
||||
...((prev ?? asignaturaApi ?? {}) as any),
|
||||
datos: mergedDatos,
|
||||
}))
|
||||
|
||||
@@ -193,253 +121,40 @@ export default function AsignaturaDetailPage() {
|
||||
}
|
||||
/* ---------- sincronizar API ---------- */
|
||||
useEffect(() => {
|
||||
if (asignaturaApi?.datos) {
|
||||
setAsignatura(asignaturaApi)
|
||||
}
|
||||
if (asignaturaApi) setAsignatura(asignaturaApi)
|
||||
}, [asignaturaApi])
|
||||
|
||||
// 2. Funciones de manejo para la IA
|
||||
const handleSendMessage = (text: string, campoId?: string) => {
|
||||
const newMessage: IAMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date(),
|
||||
campoAfectado: campoId,
|
||||
}
|
||||
setMessages([...messages, newMessage])
|
||||
|
||||
// Aquí llamarías a tu API de OpenAI/Claude
|
||||
// toast.info("Enviando consulta a la IA...");
|
||||
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
|
||||
}
|
||||
|
||||
const handleAcceptSuggestion = (sugerencia: IASugerencia) => {
|
||||
// Lógica para actualizar el valor del campo en tu estado de asignatura
|
||||
// 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">
|
||||
{asignaturaApi?.planes_estudio?.datos?.nombre || ''}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-blue-100">
|
||||
{asignaturaApi?.planes_estudio?.carreras?.facultades
|
||||
?.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 ================= */}
|
||||
<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={asignatura}
|
||||
isLoading={loadingAsig}
|
||||
asignaturaId={asignaturaId}
|
||||
onPersistDato={handlePersistDatoGeneral}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="contenido">
|
||||
<ContenidoTematico
|
||||
asignaturaId={asignaturaId}
|
||||
data={asignaturaApi ?? null}
|
||||
isLoading={loadingAsig}
|
||||
></ContenidoTematico>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bibliografia">
|
||||
<BibliographyItem
|
||||
bibliografia={bibliografia}
|
||||
id={asignaturaId}
|
||||
onSave={handleSaveBibliografia}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ia">
|
||||
<IAAsignaturaTab
|
||||
campos={campos}
|
||||
asignatura={asignatura}
|
||||
messages={messages}
|
||||
onSendMessage={handleSendMessage}
|
||||
onAcceptSuggestion={handleAcceptSuggestion}
|
||||
onRejectSuggestion={
|
||||
(id) =>
|
||||
console.log(
|
||||
'Rechazada',
|
||||
) /* toast.error("Sugerencia rechazada")*/
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sep">
|
||||
<DocumentoSEPTab
|
||||
documento={mockDocumentoSep}
|
||||
estructura={mockEstructura}
|
||||
asignatura={mockAsignatura}
|
||||
datosGenerales={(asignatura as any)?.datos ?? {}}
|
||||
onRegenerate={handleRegenerateDocument}
|
||||
isRegenerating={isRegenerating}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="historial">
|
||||
<HistorialTab asignaturaId={asignaturaId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ================= TAB CONTENT ================= */
|
||||
interface DatosGeneralesProps {
|
||||
asignaturaId: string
|
||||
data: AsignaturaDetail
|
||||
isLoading: boolean
|
||||
onPersistDato: (clave: string, value: string) => void
|
||||
}
|
||||
function DatosGenerales({
|
||||
data,
|
||||
isLoading,
|
||||
asignaturaId,
|
||||
onPersistDato,
|
||||
}: DatosGeneralesProps) {
|
||||
const formatTitle = (key: string): string =>
|
||||
key.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase())
|
||||
}: {
|
||||
onPersistDato: (clave: string, value: string) => void
|
||||
}) {
|
||||
const { asignaturaId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
|
||||
const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
|
||||
|
||||
// 1. Extraemos la definición de la estructura (los metadatos)
|
||||
const structureProps =
|
||||
data.estructuras_asignatura?.definicion?.properties || {}
|
||||
const definicionRaw = data?.estructuras_asignatura?.definicion
|
||||
const definicion = isRecord(definicionRaw)
|
||||
? (definicionRaw as Record<string, unknown>)
|
||||
: null
|
||||
|
||||
const propertiesRaw = definicion ? (definicion as any).properties : undefined
|
||||
const structureProps = isRecord(propertiesRaw)
|
||||
? (propertiesRaw as Record<string, any>)
|
||||
: {}
|
||||
|
||||
// 2. Extraemos los valores reales (el contenido redactado)
|
||||
const 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 (
|
||||
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
|
||||
@@ -459,15 +174,16 @@ function DatosGenerales({
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{/* Columna Principal (Más ancha) */}
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
{isLoading && <p>Cargando información...</p>}
|
||||
|
||||
{!isLoading &&
|
||||
Object.entries(structureProps).map(
|
||||
{Object.entries(structureProps).map(
|
||||
([key, config]: [string, any]) => {
|
||||
// 1. METADATOS (Vienen de structureProps -> config)
|
||||
const cardTitle = config.title || key
|
||||
const description = config.description || ''
|
||||
|
||||
const xColumn =
|
||||
typeof config?.['x-column'] === 'string'
|
||||
? config['x-column']
|
||||
: undefined
|
||||
|
||||
// Obtenemos el placeholder del arreglo 'examples' de la estructura
|
||||
const placeholder =
|
||||
config.examples && config.examples.length > 0
|
||||
@@ -476,11 +192,15 @@ function DatosGenerales({
|
||||
|
||||
const valActual = valoresActuales[key]
|
||||
|
||||
const isContentEmpty =
|
||||
!valActual?.description ||
|
||||
valActual.description === config.description
|
||||
let currentContent = valActual ?? ''
|
||||
|
||||
const currentContent = valActual ?? ''
|
||||
if (xColumn) {
|
||||
const rawValue = (data as any)?.[xColumn]
|
||||
const parser = columnParsers[xColumn]
|
||||
currentContent = parser
|
||||
? parser(rawValue)
|
||||
: String(rawValue ?? '')
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCard
|
||||
@@ -488,10 +208,10 @@ function DatosGenerales({
|
||||
key={key}
|
||||
clave={key}
|
||||
title={cardTitle}
|
||||
initialContent={currentContent} // Si es igual a la descripción de la SEP, pasamos vacío
|
||||
placeholder={placeholder} // Aquí irá "Primer semestre", "MAT-101", etc.
|
||||
description={description} // El texto largo de "Indicar el ciclo..."
|
||||
onEnhanceAI={(contenido) => console.log(contenido)}
|
||||
initialContent={currentContent}
|
||||
xColumn={xColumn}
|
||||
placeholder={placeholder}
|
||||
description={description}
|
||||
onPersist={(clave, value) => onPersistDato(clave, value)}
|
||||
/>
|
||||
)
|
||||
@@ -545,6 +265,7 @@ interface InfoCardProps {
|
||||
initialContent: any
|
||||
placeholder?: string
|
||||
description?: string
|
||||
xColumn?: string
|
||||
required?: boolean // Nueva prop para el asterisco
|
||||
type?: 'text' | 'requirements' | 'evaluation'
|
||||
onEnhanceAI?: (content: any) => void
|
||||
@@ -558,6 +279,7 @@ function InfoCard({
|
||||
initialContent,
|
||||
placeholder,
|
||||
description,
|
||||
xColumn,
|
||||
required,
|
||||
type = 'text',
|
||||
onPersist,
|
||||
@@ -589,13 +311,16 @@ function InfoCard({
|
||||
const handleIARequest = (campoClave: string) => {
|
||||
console.log(placeholder)
|
||||
|
||||
// Añadimos un timestamp a la state para forzar que la navegación
|
||||
// genere una nueva ubicación incluso si la ruta y los params son iguales.
|
||||
navigate({
|
||||
to: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
|
||||
params: { planId, asignaturaId: asignaturaId! },
|
||||
state: {
|
||||
activeTab: 'ia',
|
||||
prefillCampo: campoClave,
|
||||
prefillContenido: data,
|
||||
_ts: Date.now(),
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
@@ -649,7 +374,21 @@ function InfoCard({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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" />
|
||||
</Button>
|
||||
@@ -669,7 +408,7 @@ function InfoCard({
|
||||
value={tempText}
|
||||
placeholder={placeholder}
|
||||
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">
|
||||
<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 { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -30,40 +34,13 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
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 { 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 ---
|
||||
export interface BibliografiaEntry {
|
||||
@@ -76,24 +53,21 @@ export interface BibliografiaEntry {
|
||||
fuenteBiblioteca?: any
|
||||
}
|
||||
|
||||
interface BibliografiaTabProps {
|
||||
id: string
|
||||
bibliografia: Array<BibliografiaEntry>
|
||||
onSave: (bibliografia: Array<BibliografiaEntry>) => void
|
||||
isSaving: boolean
|
||||
}
|
||||
export function BibliographyItem() {
|
||||
const { asignaturaId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
|
||||
export function BibliographyItem({
|
||||
bibliografia,
|
||||
id,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: BibliografiaTabProps) {
|
||||
console.log(id)
|
||||
// --- 1. Única fuente de verdad: La Query ---
|
||||
const { data: bibliografia = [], isLoading } =
|
||||
useSubjectBibliografia(asignaturaId)
|
||||
|
||||
const { data: bibliografia2, isLoading: loadinasignatura } =
|
||||
useSubjectBibliografia(id)
|
||||
const [entries, setEntries] = useState<Array<BibliografiaEntry>>(bibliografia)
|
||||
// --- 2. Mutaciones ---
|
||||
const { mutate: crearBibliografia } = useCreateBibliografia()
|
||||
const { mutate: actualizarBibliografia } = useUpdateBibliografia(asignaturaId)
|
||||
const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId)
|
||||
|
||||
// --- 3. Estados de UI (Solo para diálogos y edición) ---
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
@@ -102,30 +76,27 @@ export function BibliographyItem({
|
||||
'BASICA',
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (bibliografia2 && Array.isArray(bibliografia2)) {
|
||||
setEntries(bibliografia2)
|
||||
} else if (bibliografia) {
|
||||
// Fallback a la prop inicial si la API no devuelve nada
|
||||
setEntries(bibliografia)
|
||||
}
|
||||
}, [bibliografia2, bibliografia])
|
||||
|
||||
const basicaEntries = entries.filter((e) => e.tipo === 'BASICA')
|
||||
const complementariaEntries = entries.filter(
|
||||
console.log('Datos actuales en el front:', bibliografia)
|
||||
// --- 4. Derivación de datos (Se calculan en cada render) ---
|
||||
const basicaEntries = bibliografia.filter((e) => e.tipo === 'BASICA')
|
||||
const complementariaEntries = bibliografia.filter(
|
||||
(e) => e.tipo === 'COMPLEMENTARIA',
|
||||
)
|
||||
console.log(bibliografia2)
|
||||
|
||||
// --- Handlers Conectados a la Base de Datos ---
|
||||
|
||||
const handleAddManual = (cita: string) => {
|
||||
const newEntry: BibliografiaEntry = {
|
||||
id: `manual-${Date.now()}`,
|
||||
crearBibliografia(
|
||||
{
|
||||
asignatura_id: asignaturaId,
|
||||
tipo: newEntryType,
|
||||
cita,
|
||||
}
|
||||
setEntries([...entries, newEntry])
|
||||
setIsAddDialogOpen(false)
|
||||
// toast.success('Referencia manual añadida');
|
||||
tipo_fuente: 'MANUAL',
|
||||
},
|
||||
{
|
||||
onSuccess: () => setIsAddDialogOpen(false),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddFromLibrary = (
|
||||
@@ -133,22 +104,43 @@ export function BibliographyItem({
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA',
|
||||
) => {
|
||||
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`
|
||||
const newEntry: BibliografiaEntry = {
|
||||
id: `lib-ref-${Date.now()}`,
|
||||
crearBibliografia(
|
||||
{
|
||||
asignatura_id: asignaturaId,
|
||||
tipo,
|
||||
cita,
|
||||
fuenteBibliotecaId: resource.id,
|
||||
fuenteBiblioteca: resource,
|
||||
}
|
||||
setEntries([...entries, newEntry])
|
||||
setIsLibraryDialogOpen(false)
|
||||
// toast.success('Añadido desde biblioteca');
|
||||
tipo_fuente: 'BIBLIOTECA',
|
||||
biblioteca_item_id: resource.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => setIsLibraryDialogOpen(false),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleUpdateCita = (id: string, cita: string) => {
|
||||
setEntries(entries.map((e) => (e.id === id ? { ...e, cita } : e)))
|
||||
const handleUpdateCita = (id: string, nuevaCita: string) => {
|
||||
actualizarBibliografia(
|
||||
{
|
||||
id,
|
||||
updates: { cita: nuevaCita },
|
||||
},
|
||||
{
|
||||
onSuccess: () => setEditingId(null),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const onConfirmDelete = () => {
|
||||
if (deleteId) {
|
||||
eliminarBibliografia(deleteId, {
|
||||
onSuccess: () => setDeleteId(null),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading)
|
||||
return <div className="p-10 text-center">Cargando bibliografía...</div>
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -176,8 +168,13 @@ export function BibliographyItem({
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<LibrarySearchDialog
|
||||
// CORRECCIÓN: Usamos 'bibliografia' en lugar de 'bibliografia2'
|
||||
resources={[]} // Aquí deberías pasar el catálogo general, no la bibliografía de la asignatura
|
||||
onSelect={handleAddFromLibrary}
|
||||
existingIds={entries.map((e) => e.fuenteBibliotecaId || '')}
|
||||
// CORRECCIÓN: Usamos 'bibliografia' en lugar de 'entries'
|
||||
existingIds={bibliografia.map(
|
||||
(e) => e.biblioteca_item_id || '',
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -257,13 +254,7 @@ export function BibliographyItem({
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setEntries(entries.filter((e) => e.id !== deleteId))
|
||||
setDeleteId(null)
|
||||
}}
|
||||
className="bg-red-600"
|
||||
>
|
||||
<AlertDialogAction onClick={onConfirmDelete} className="bg-red-600">
|
||||
Eliminar
|
||||
</AlertDialogAction>
|
||||
</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 [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA')
|
||||
const filtered = mockLibraryResources.filter(
|
||||
(r) =>
|
||||
const filtered = (resources || []).filter(
|
||||
(r: any) =>
|
||||
!existingIds.includes(r.id) &&
|
||||
r.titulo.toLowerCase().includes(search.toLowerCase()),
|
||||
r.titulo?.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
console.log(filtered)
|
||||
console.log(resources)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-2">
|
||||
@@ -451,7 +444,7 @@ function LibrarySearchDialog({ onSelect, existingIds }: any) {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="max-h-[300px] space-y-2 overflow-y-auto pr-2">
|
||||
{filtered.map((res) => (
|
||||
{filtered.map((res: any) => (
|
||||
<div
|
||||
key={res.id}
|
||||
onClick={() => onSelect(res, tipo)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import {
|
||||
Plus,
|
||||
GripVertical,
|
||||
@@ -7,9 +8,10 @@ import {
|
||||
Trash2,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
|
||||
import type { FocusEvent, KeyboardEvent } from 'react'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -30,7 +32,7 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useUpdateSubjectContenido } from '@/data/hooks/useSubjects'
|
||||
import { useSubject, useUpdateSubjectContenido } from '@/data/hooks/useSubjects'
|
||||
import { cn } from '@/lib/utils'
|
||||
// import { toast } from 'sonner';
|
||||
|
||||
@@ -156,32 +158,39 @@ function serializeUnidadesToApi(
|
||||
}
|
||||
|
||||
// Props del componente
|
||||
interface ContenidoTematicoProps {
|
||||
asignaturaId: string
|
||||
data?: {
|
||||
contenido_tematico?: unknown
|
||||
} | null
|
||||
isLoading: boolean
|
||||
}
|
||||
export function ContenidoTematico({
|
||||
asignaturaId,
|
||||
data,
|
||||
isLoading,
|
||||
}: ContenidoTematicoProps) {
|
||||
const updateContenido = useUpdateSubjectContenido()
|
||||
|
||||
export function ContenidoTematico() {
|
||||
const updateContenido = useUpdateSubjectContenido()
|
||||
const { asignaturaId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
|
||||
const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
|
||||
const [unidades, setUnidades] = useState<Array<UnidadTematica>>([])
|
||||
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(new Set())
|
||||
const unitContainerRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||
const unitTitleInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const temaNombreInputElRef = useRef<HTMLInputElement | null>(null)
|
||||
const [pendingScrollUnitId, setPendingScrollUnitId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const cancelNextBlurRef = useRef(false)
|
||||
const [deleteDialog, setDeleteDialog] = useState<{
|
||||
type: 'unidad' | 'tema'
|
||||
id: string
|
||||
parentId?: string
|
||||
} | null>(null)
|
||||
const [editingUnit, setEditingUnit] = useState<string | null>(null)
|
||||
const [unitDraftNombre, setUnitDraftNombre] = useState('')
|
||||
const [unitOriginalNombre, setUnitOriginalNombre] = useState('')
|
||||
const [editingTema, setEditingTema] = useState<{
|
||||
unitId: string
|
||||
temaId: string
|
||||
} | null>(null)
|
||||
const [temaDraftNombre, setTemaDraftNombre] = useState('')
|
||||
const [temaOriginalNombre, setTemaOriginalNombre] = useState('')
|
||||
const [temaDraftHoras, setTemaDraftHoras] = useState('')
|
||||
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
|
||||
|
||||
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
|
||||
const payload = serializeUnidadesToApi(nextUnidades)
|
||||
@@ -191,18 +200,116 @@ export function ContenidoTematico({
|
||||
})
|
||||
}
|
||||
|
||||
const beginEditUnit = (unitId: string) => {
|
||||
const unit = unidades.find((u) => u.id === unitId)
|
||||
const nombre = unit?.nombre ?? ''
|
||||
setEditingUnit(unitId)
|
||||
setUnitDraftNombre(nombre)
|
||||
setUnitOriginalNombre(nombre)
|
||||
setExpandedUnits((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(unitId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const commitEditUnit = () => {
|
||||
if (!editingUnit) return
|
||||
const next = unidades.map((u) =>
|
||||
u.id === editingUnit ? { ...u, nombre: unitDraftNombre } : u,
|
||||
)
|
||||
setUnidades(next)
|
||||
setEditingUnit(null)
|
||||
void persistUnidades(next)
|
||||
}
|
||||
|
||||
const cancelEditUnit = () => {
|
||||
setEditingUnit(null)
|
||||
setUnitDraftNombre(unitOriginalNombre)
|
||||
}
|
||||
|
||||
const beginEditTema = (unitId: string, temaId: string) => {
|
||||
const unit = unidades.find((u) => u.id === unitId)
|
||||
const tema = unit?.temas.find((t) => t.id === temaId)
|
||||
const nombre = tema?.nombre ?? ''
|
||||
const horas = tema?.horasEstimadas ?? 0
|
||||
|
||||
setEditingTema({ unitId, temaId })
|
||||
setTemaDraftNombre(nombre)
|
||||
setTemaOriginalNombre(nombre)
|
||||
setTemaDraftHoras(String(horas))
|
||||
setTemaOriginalHoras(horas)
|
||||
setExpandedUnits((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(unitId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const commitEditTema = () => {
|
||||
if (!editingTema) return
|
||||
const parsedHoras = Number.parseInt(temaDraftHoras, 10)
|
||||
const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
|
||||
|
||||
const next = unidades.map((u) => {
|
||||
if (u.id !== editingTema.unitId) return u
|
||||
return {
|
||||
...u,
|
||||
temas: u.temas.map((t) =>
|
||||
t.id === editingTema.temaId
|
||||
? { ...t, nombre: temaDraftNombre, horasEstimadas }
|
||||
: t,
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
setUnidades(next)
|
||||
setEditingTema(null)
|
||||
void persistUnidades(next)
|
||||
}
|
||||
|
||||
const cancelEditTema = () => {
|
||||
setEditingTema(null)
|
||||
setTemaDraftNombre(temaOriginalNombre)
|
||||
setTemaDraftHoras(String(temaOriginalHoras))
|
||||
}
|
||||
|
||||
const handleTemaEditorBlurCapture = (e: FocusEvent<HTMLDivElement>) => {
|
||||
if (cancelNextBlurRef.current) {
|
||||
cancelNextBlurRef.current = false
|
||||
return
|
||||
}
|
||||
const nextFocus = e.relatedTarget as Node | null
|
||||
if (nextFocus && e.currentTarget.contains(nextFocus)) return
|
||||
commitEditTema()
|
||||
}
|
||||
|
||||
const handleTemaEditorKeyDownCapture = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (e.target instanceof HTMLElement) e.target.blur()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelNextBlurRef.current = true
|
||||
cancelEditTema()
|
||||
if (e.target instanceof HTMLElement) e.target.blur()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const contenido = mapContenidoTematicoFromDb(
|
||||
data ? data.contenido_tematico : undefined,
|
||||
)
|
||||
|
||||
const transformed = contenido.map((u, idx) => ({
|
||||
id: `u-${idx}`,
|
||||
id: `u-${u.unidad || idx + 1}`,
|
||||
numero: u.unidad || idx + 1,
|
||||
nombre: u.titulo || 'Sin título',
|
||||
temas: Array.isArray(u.temas)
|
||||
? u.temas.map((t: any, tidx: number) => ({
|
||||
id: `t-${idx}-${tidx}`,
|
||||
id: `t-${u.unidad || idx + 1}-${tidx + 1}`,
|
||||
nombre: typeof t === 'string' ? t : t?.nombre || 'Tema',
|
||||
horasEstimadas: t?.horasEstimadas || 0,
|
||||
}))
|
||||
@@ -210,15 +317,36 @@ export function ContenidoTematico({
|
||||
}))
|
||||
|
||||
setUnidades(transformed)
|
||||
|
||||
// Expandir la primera unidad automáticamente
|
||||
if (transformed.length > 0) {
|
||||
setExpandedUnits(new Set([transformed[0].id]))
|
||||
} else {
|
||||
setExpandedUnits(new Set())
|
||||
}
|
||||
// Mantener las unidades ya expandidas si existen; si no, expandir la primera.
|
||||
setExpandedUnits((prev) => {
|
||||
const validIds = new Set(transformed.map((u) => u.id))
|
||||
const filtered = new Set(
|
||||
Array.from(prev).filter((id) => validIds.has(id)),
|
||||
)
|
||||
if (filtered.size > 0) return filtered
|
||||
return transformed.length > 0 ? new Set([transformed[0].id]) : new Set()
|
||||
})
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingUnit) return
|
||||
// Foco controlado (evitamos autoFocus por lint/a11y)
|
||||
setTimeout(() => unitTitleInputRef.current?.focus(), 0)
|
||||
}, [editingUnit])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingTema) return
|
||||
setTimeout(() => temaNombreInputElRef.current?.focus(), 0)
|
||||
}, [editingTema])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingScrollUnitId) return
|
||||
const el = unitContainerRefs.current.get(pendingScrollUnitId)
|
||||
if (!el) return
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
setPendingScrollUnitId(null)
|
||||
}, [pendingScrollUnitId, unidades.length])
|
||||
|
||||
if (isLoading)
|
||||
return <div className="p-10 text-center">Cargando contenido...</div>
|
||||
|
||||
@@ -237,60 +365,57 @@ export function ContenidoTematico({
|
||||
}
|
||||
|
||||
const addUnidad = () => {
|
||||
const newId = `u-${Date.now()}`
|
||||
const newNumero = unidades.length + 1
|
||||
const newId = `u-${newNumero}`
|
||||
const newUnidad: UnidadTematica = {
|
||||
id: newId,
|
||||
nombre: 'Nueva Unidad',
|
||||
numero: unidades.length + 1,
|
||||
numero: newNumero,
|
||||
temas: [],
|
||||
}
|
||||
const next = [...unidades, newUnidad]
|
||||
setUnidades(next)
|
||||
setExpandedUnits(new Set([...expandedUnits, newId]))
|
||||
setEditingUnit(newId)
|
||||
}
|
||||
setExpandedUnits((prev) => {
|
||||
const n = new Set(prev)
|
||||
n.add(newId)
|
||||
return n
|
||||
})
|
||||
setPendingScrollUnitId(newId)
|
||||
|
||||
const updateUnidadNombre = (id: string, nombre: string) => {
|
||||
setUnidades(unidades.map((u) => (u.id === id ? { ...u, nombre } : u)))
|
||||
// Abrir edición del título inmediatamente
|
||||
setEditingUnit(newId)
|
||||
setUnitDraftNombre(newUnidad.nombre)
|
||||
setUnitOriginalNombre(newUnidad.nombre)
|
||||
}
|
||||
|
||||
// --- Lógica de Temas ---
|
||||
const addTema = (unidadId: string) => {
|
||||
setUnidades(
|
||||
unidades.map((u) => {
|
||||
if (u.id === unidadId) {
|
||||
const newTemaId = `t-${Date.now()}`
|
||||
const unit = unidades.find((u) => u.id === unidadId)
|
||||
const unitNumero = unit?.numero ?? 0
|
||||
const newTemaIndex = (unit?.temas.length ?? 0) + 1
|
||||
const newTemaId = `t-${unitNumero}-${newTemaIndex}`
|
||||
const newTema: Tema = {
|
||||
id: newTemaId,
|
||||
nombre: 'Nuevo tema',
|
||||
horasEstimadas: 2,
|
||||
}
|
||||
setEditingTema({ unitId: unidadId, temaId: newTemaId })
|
||||
return { ...u, temas: [...u.temas, newTema] }
|
||||
}
|
||||
return u
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const updateTema = (
|
||||
unidadId: string,
|
||||
temaId: string,
|
||||
updates: Partial<Tema>,
|
||||
) => {
|
||||
setUnidades(
|
||||
unidades.map((u) => {
|
||||
if (u.id === unidadId) {
|
||||
return {
|
||||
...u,
|
||||
temas: u.temas.map((t) =>
|
||||
t.id === temaId ? { ...t, ...updates } : t,
|
||||
),
|
||||
}
|
||||
}
|
||||
return u
|
||||
}),
|
||||
const next = unidades.map((u) =>
|
||||
u.id === unidadId ? { ...u, temas: [...u.temas, newTema] } : u,
|
||||
)
|
||||
setUnidades(next)
|
||||
|
||||
// Expandir unidad y poner el subtema en edición con foco en el nombre
|
||||
setExpandedUnits((prev) => {
|
||||
const n = new Set(prev)
|
||||
n.add(unidadId)
|
||||
return n
|
||||
})
|
||||
setEditingTema({ unitId: unidadId, temaId: newTemaId })
|
||||
setTemaDraftNombre(newTema.nombre)
|
||||
setTemaOriginalNombre(newTema.nombre)
|
||||
setTemaDraftHoras(String(newTema.horasEstimadas ?? 0))
|
||||
setTemaOriginalHoras(newTema.horasEstimadas ?? 0)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
@@ -324,19 +449,18 @@ export function ContenidoTematico({
|
||||
{unidades.length} unidades • {totalHoras} horas estimadas totales
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={addUnidad} className="gap-2">
|
||||
<Plus className="h-4 w-4" /> Nueva unidad
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{unidades.map((unidad) => (
|
||||
<Card
|
||||
<div
|
||||
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
|
||||
open={expandedUnits.has(unidad.id)}
|
||||
onOpenChange={() => toggleUnit(unidad.id)}
|
||||
@@ -359,18 +483,27 @@ export function ContenidoTematico({
|
||||
|
||||
{editingUnit === unidad.id ? (
|
||||
<Input
|
||||
value={unidad.nombre}
|
||||
onChange={(e) =>
|
||||
updateUnidadNombre(unidad.id, e.target.value)
|
||||
}
|
||||
ref={unitTitleInputRef}
|
||||
value={unitDraftNombre}
|
||||
onChange={(e) => setUnitDraftNombre(e.target.value)}
|
||||
onBlur={() => {
|
||||
setEditingUnit(null)
|
||||
void persistUnidades(unidades)
|
||||
if (cancelNextBlurRef.current) {
|
||||
cancelNextBlurRef.current = false
|
||||
return
|
||||
}
|
||||
commitEditUnit()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setEditingUnit(null)
|
||||
void persistUnidades(unidades)
|
||||
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"
|
||||
@@ -378,7 +511,7 @@ export function ContenidoTematico({
|
||||
) : (
|
||||
<CardTitle
|
||||
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
|
||||
onClick={() => setEditingUnit(unidad.id)}
|
||||
onClick={() => beginEditUnit(unidad.id)}
|
||||
>
|
||||
{unidad.nombre}
|
||||
</CardTitle>
|
||||
@@ -419,16 +552,18 @@ export function ContenidoTematico({
|
||||
editingTema.unitId === unidad.id &&
|
||||
editingTema.temaId === tema.id
|
||||
}
|
||||
onEdit={() =>
|
||||
setEditingTema({ unitId: unidad.id, temaId: tema.id })
|
||||
draftNombre={temaDraftNombre}
|
||||
draftHoras={temaDraftHoras}
|
||||
onBeginEdit={() => beginEditTema(unidad.id, tema.id)}
|
||||
onDraftNombreChange={setTemaDraftNombre}
|
||||
onDraftHorasChange={setTemaDraftHoras}
|
||||
onEditorBlurCapture={handleTemaEditorBlurCapture}
|
||||
onEditorKeyDownCapture={
|
||||
handleTemaEditorKeyDownCapture
|
||||
}
|
||||
onStopEditing={() => {
|
||||
setEditingTema(null)
|
||||
void persistUnidades(unidades)
|
||||
onNombreInputRef={(el) => {
|
||||
temaNombreInputElRef.current = el
|
||||
}}
|
||||
onUpdate={(updates) =>
|
||||
updateTema(unidad.id, tema.id, updates)
|
||||
}
|
||||
onDelete={() =>
|
||||
setDeleteDialog({
|
||||
type: 'tema',
|
||||
@@ -451,9 +586,24 @@ export function ContenidoTematico({
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={(e) => {
|
||||
// Evita que Enter vuelva a disparar el click sobre el botón.
|
||||
e.currentTarget.blur()
|
||||
addUnidad()
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Nueva unidad
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
dialog={deleteDialog}
|
||||
setDialog={setDeleteDialog}
|
||||
@@ -468,9 +618,14 @@ interface TemaRowProps {
|
||||
tema: Tema
|
||||
index: number
|
||||
isEditing: boolean
|
||||
onEdit: () => void
|
||||
onStopEditing: () => void
|
||||
onUpdate: (updates: Partial<Tema>) => void
|
||||
draftNombre: string
|
||||
draftHoras: string
|
||||
onBeginEdit: () => void
|
||||
onDraftNombreChange: (value: string) => void
|
||||
onDraftHorasChange: (value: string) => void
|
||||
onEditorBlurCapture: (e: FocusEvent<HTMLDivElement>) => void
|
||||
onEditorKeyDownCapture: (e: KeyboardEvent<HTMLDivElement>) => void
|
||||
onNombreInputRef: (el: HTMLInputElement | null) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
@@ -478,9 +633,14 @@ function TemaRow({
|
||||
tema,
|
||||
index,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onStopEditing,
|
||||
onUpdate,
|
||||
draftNombre,
|
||||
draftHoras,
|
||||
onBeginEdit,
|
||||
onDraftNombreChange,
|
||||
onDraftHorasChange,
|
||||
onEditorBlurCapture,
|
||||
onEditorKeyDownCapture,
|
||||
onNombreInputRef,
|
||||
onDelete,
|
||||
}: TemaRowProps) {
|
||||
return (
|
||||
@@ -492,47 +652,49 @@ function TemaRow({
|
||||
>
|
||||
<span className="w-4 font-mono text-xs text-slate-400">{index}.</span>
|
||||
{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
|
||||
value={tema.nombre}
|
||||
onChange={(e) => onUpdate({ nombre: e.target.value })}
|
||||
ref={onNombreInputRef}
|
||||
value={draftNombre}
|
||||
onChange={(e) => onDraftNombreChange(e.target.value)}
|
||||
className="h-8 flex-1 bg-white"
|
||||
placeholder="Nombre"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={tema.horasEstimadas}
|
||||
onChange={(e) =>
|
||||
onUpdate({ horasEstimadas: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
value={draftHoras}
|
||||
onChange={(e) => onDraftHorasChange(e.target.value)}
|
||||
className="h-8 w-16 bg-white"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-emerald-600"
|
||||
onClick={onStopEditing}
|
||||
>
|
||||
Listo
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 cursor-pointer text-left"
|
||||
onClick={onEdit}
|
||||
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>
|
||||
</button>
|
||||
<Badge variant="secondary" className="text-[10px] opacity-60">
|
||||
{tema.horasEstimadas}h
|
||||
</Badge>
|
||||
</button>
|
||||
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-blue-600"
|
||||
onClick={onEdit}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onBeginEdit()
|
||||
}}
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -540,7 +702,10 @@ function TemaRow({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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" />
|
||||
</Button>
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
RefreshCw,
|
||||
FileCheck,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { FileCheck, Download, RefreshCw, Loader2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type {
|
||||
DocumentoAsignatura,
|
||||
Asignatura,
|
||||
AsignaturaStructure,
|
||||
} from '@/types/asignatura'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -25,51 +12,34 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} 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 { cn } from '@/lib/utils'
|
||||
// import { toast } from 'sonner';
|
||||
// import { format } from 'date-fns';
|
||||
// import { es } from 'date-fns/locale';
|
||||
import { Card } from '@/components/ui/card'
|
||||
|
||||
interface DocumentoSEPTabProps {
|
||||
documento: DocumentoAsignatura | null
|
||||
asignatura: Asignatura
|
||||
estructura: AsignaturaStructure
|
||||
datosGenerales: Record<string, any>
|
||||
pdfUrl: string | null
|
||||
isLoading: boolean
|
||||
onDownload: () => void
|
||||
onRegenerate: () => void
|
||||
isRegenerating: boolean
|
||||
}
|
||||
|
||||
export function DocumentoSEPTab({
|
||||
documento,
|
||||
asignatura,
|
||||
datosGenerales,
|
||||
estructura,
|
||||
pdfUrl,
|
||||
isLoading,
|
||||
onDownload,
|
||||
onRegenerate,
|
||||
isRegenerating,
|
||||
}: DocumentoSEPTabProps) {
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
||||
|
||||
// Check completeness
|
||||
const camposObligatorios = estructura.campos.filter((c) => c.obligatorio)
|
||||
const camposCompletos = camposObligatorios.filter((c) =>
|
||||
datosGenerales[c.id]?.trim(),
|
||||
)
|
||||
const completeness = Math.round(
|
||||
(camposCompletos.length / camposObligatorios.length) * 100,
|
||||
)
|
||||
const isComplete = completeness === 100
|
||||
|
||||
const handleRegenerate = () => {
|
||||
setShowConfirmDialog(false)
|
||||
onRegenerate()
|
||||
// toast.success('Regenerando documento...');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
|
||||
@@ -77,30 +47,24 @@ export function DocumentoSEPTab({
|
||||
Documento SEP
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Previsualización del documento oficial para la SEP
|
||||
Previsualización del documento oficial generado
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{documento?.estado === 'listo' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={
|
||||
() =>
|
||||
console.log(
|
||||
'descargando',
|
||||
) /* toast.info('Descarga iniciada')*/
|
||||
}
|
||||
>
|
||||
{pdfUrl && !isLoading && (
|
||||
<Button variant="outline" onClick={onDownload}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Descargar
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={showConfirmDialog}
|
||||
onOpenChange={setShowConfirmDialog}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button disabled={isRegenerating || !isComplete}>
|
||||
<Button disabled={isRegenerating}>
|
||||
{isRegenerating ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
@@ -109,15 +73,16 @@ export function DocumentoSEPTab({
|
||||
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Se creará una nueva versión del documento con los datos
|
||||
actuales de la asignatura. La versión anterior quedará en el
|
||||
historial.
|
||||
Se generará una nueva versión del documento con la información
|
||||
actual.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleRegenerate}>
|
||||
@@ -129,308 +94,24 @@ export function DocumentoSEPTab({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Document preview */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="card-elevated h-[700px] overflow-hidden">
|
||||
{documento?.estado === 'listo' ? (
|
||||
<div className="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' ? (
|
||||
{/* PDF Preview */}
|
||||
<Card className="h-[800px] overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="text-accent mx-auto mb-4 h-12 w-12 animate-spin" />
|
||||
<p className="text-muted-foreground">
|
||||
Generando documento...
|
||||
</p>
|
||||
<Loader2 className="h-10 w-10 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="max-w-sm text-center">
|
||||
<FileText className="text-muted-foreground/50 mx-auto mb-4 h-12 w-12" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No hay documento generado aún
|
||||
</p>
|
||||
{!isComplete && (
|
||||
<div className="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}%` }}
|
||||
) : pdfUrl ? (
|
||||
<iframe
|
||||
src={`${pdfUrl}#toolbar=0`}
|
||||
className="h-full w-full border-none"
|
||||
title="Documento SEP"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs',
|
||||
completeness === 100
|
||||
? 'text-success'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{completeness === 100
|
||||
? 'Todos los campos obligatorios están completos'
|
||||
: `Faltan ${camposObligatorios.length - camposCompletos.length} campos por completar`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Missing fields */}
|
||||
{!isComplete && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
Campos faltantes:
|
||||
</p>
|
||||
{camposObligatorios
|
||||
.filter((c) => !datosGenerales[c.id]?.trim())
|
||||
.map((campo) => (
|
||||
<div
|
||||
key={campo.id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="text-warning h-3 w-3" />
|
||||
<span className="text-foreground">{campo.nombre}</span>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center">
|
||||
No se pudo cargar el documento.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Requirements */}
|
||||
<Card className="card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Requisitos SEP
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
|
||||
datosGenerales['objetivo_general']
|
||||
? 'bg-success/20'
|
||||
: 'bg-muted',
|
||||
)}
|
||||
>
|
||||
{datosGenerales['objetivo_general'] && (
|
||||
<Check className="text-success h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
Objetivo general definido
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
|
||||
datosGenerales['competencias']
|
||||
? 'bg-success/20'
|
||||
: 'bg-muted',
|
||||
)}
|
||||
>
|
||||
{datosGenerales['competencias'] && (
|
||||
<Check className="text-success h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
Competencias especificadas
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex h-4 w-4 items-center justify-center rounded-full',
|
||||
datosGenerales['evaluacion']
|
||||
? 'bg-success/20'
|
||||
: 'bg-muted',
|
||||
)}
|
||||
>
|
||||
{datosGenerales['evaluacion'] && (
|
||||
<Check className="text-success h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
Criterios de evaluación
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Check({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
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
|
||||
const { data: rawData, isLoading } = useSubjectHistorial(asignaturaId)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRouterState } from '@tanstack/react-router'
|
||||
import { useParams, useRouterState } from '@tanstack/react-router'
|
||||
import {
|
||||
Sparkles,
|
||||
Send,
|
||||
@@ -13,17 +13,14 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
|
||||
import type {
|
||||
IAMessage,
|
||||
IASugerencia,
|
||||
CampoEstructura,
|
||||
} from '@/types/asignatura'
|
||||
import type { IAMessage, IASugerencia } from '@/types/asignatura'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useSubject } from '@/data'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Tipos importados de tu archivo de asignatura
|
||||
@@ -62,7 +59,6 @@ interface SelectedField {
|
||||
}
|
||||
|
||||
interface IAAsignaturaTabProps {
|
||||
campos: Array<CampoEstructura>
|
||||
asignatura: Record<string, any>
|
||||
messages: Array<IAMessage>
|
||||
onSendMessage: (message: string, campoId?: string) => void
|
||||
@@ -71,15 +67,18 @@ interface IAAsignaturaTabProps {
|
||||
}
|
||||
|
||||
export function IAAsignaturaTab({
|
||||
campos,
|
||||
asignatura: datosGenerales,
|
||||
messages,
|
||||
onSendMessage,
|
||||
onAcceptSuggestion,
|
||||
onRejectSuggestion,
|
||||
}: IAAsignaturaTabProps) {
|
||||
const routerState = useRouterState()
|
||||
const { asignaturaId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
|
||||
const { data: datosGenerales, isLoading: loadingAsig } =
|
||||
useSubject(asignaturaId)
|
||||
// ESTADOS PRINCIPALES (Igual que en Planes)
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
|
||||
@@ -89,25 +88,25 @@ export function IAAsignaturaTab({
|
||||
|
||||
// 1. Transformar datos de la asignatura para el menú
|
||||
const availableFields = useMemo(() => {
|
||||
// Extraemos las claves directamente del objeto datosGenerales
|
||||
// ["nombre", "descripcion", "perfil_de_egreso", "fines_de_aprendizaje_o_formacion"]
|
||||
if (!datosGenerales.datos) return []
|
||||
return Object.keys(datosGenerales.datos).map((key) => {
|
||||
// Buscamos si existe un nombre amigable en la estructura de campos
|
||||
const estructuraCampo = campos.find((c) => c.id === key)
|
||||
if (!datosGenerales?.datos) return []
|
||||
|
||||
const estructuraProps =
|
||||
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
|
||||
|
||||
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 =
|
||||
estructuraCampo?.nombre ||
|
||||
estructuraCampo?.title ||
|
||||
key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
|
||||
|
||||
return {
|
||||
key: key,
|
||||
key,
|
||||
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)
|
||||
|
||||
@@ -218,7 +217,7 @@ export function IAAsignaturaTab({
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<ScrollArea ref={scrollRef} className="h-full w-full">
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
{messages.map((msg) => (
|
||||
{messages?.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
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>>
|
||||
}) {
|
||||
const enfoque = wizard.iaMultiple?.enfoque ?? ''
|
||||
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
|
||||
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 5
|
||||
const isLoading = wizard.iaMultiple?.isLoading ?? false
|
||||
|
||||
const [showConservacionTooltip, setShowConservacionTooltip] = useState(false)
|
||||
@@ -163,7 +163,7 @@ export default function PasoSugerenciasForm({
|
||||
Cantidad de sugerencias
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="Ej. 10"
|
||||
placeholder="Ej. 5"
|
||||
value={cantidadDeSugerencias}
|
||||
type="number"
|
||||
min={1}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
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 { TablesInsert } from '@/types/supabase'
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
useGenerateSubjectAI,
|
||||
qk,
|
||||
useCreateSubjectManual,
|
||||
subjects_get_maybe,
|
||||
} from '@/data'
|
||||
|
||||
export function WizardControls({
|
||||
@@ -41,6 +43,154 @@ export function WizardControls({
|
||||
const generateSubjectAI = useGenerateSubjectAI()
|
||||
const createSubjectManual = useCreateSubjectManual()
|
||||
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
||||
const cancelledRef = useRef(false)
|
||||
const realtimeChannelRef = useRef<RealtimeChannel | null>(null)
|
||||
const watchSubjectIdRef = useRef<string | null>(null)
|
||||
const watchTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
cancelledRef.current = false
|
||||
return () => {
|
||||
cancelledRef.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopSubjectWatch = useCallback(() => {
|
||||
if (watchTimeoutRef.current) {
|
||||
window.clearTimeout(watchTimeoutRef.current)
|
||||
watchTimeoutRef.current = null
|
||||
}
|
||||
|
||||
watchSubjectIdRef.current = null
|
||||
|
||||
const ch = realtimeChannelRef.current
|
||||
if (ch) {
|
||||
realtimeChannelRef.current = null
|
||||
try {
|
||||
supabaseBrowser().removeChannel(ch)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopSubjectWatch()
|
||||
}
|
||||
}, [stopSubjectWatch])
|
||||
|
||||
const handleSubjectReady = (args: {
|
||||
id: string
|
||||
plan_estudio_id: string
|
||||
estado?: unknown
|
||||
}) => {
|
||||
if (cancelledRef.current) return
|
||||
|
||||
const estado = String(args.estado ?? '').toLowerCase()
|
||||
if (estado === 'generando') return
|
||||
|
||||
stopSubjectWatch()
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({ ...w, isLoading: false }))
|
||||
|
||||
navigate({
|
||||
to: `/planes/${args.plan_estudio_id}/asignaturas/${args.id}`,
|
||||
state: { showConfetti: true },
|
||||
})
|
||||
}
|
||||
|
||||
const beginSubjectWatch = (args: { subjectId: string; planId: string }) => {
|
||||
stopSubjectWatch()
|
||||
|
||||
watchSubjectIdRef.current = args.subjectId
|
||||
|
||||
// Timeout de seguridad (mismo límite que teníamos con polling)
|
||||
watchTimeoutRef.current = window.setTimeout(
|
||||
() => {
|
||||
if (cancelledRef.current) return
|
||||
if (watchSubjectIdRef.current !== args.subjectId) return
|
||||
|
||||
stopSubjectWatch()
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage:
|
||||
'La generación está tardando demasiado. Intenta de nuevo en unos minutos.',
|
||||
}))
|
||||
},
|
||||
6 * 60 * 1000,
|
||||
)
|
||||
|
||||
const supabase = supabaseBrowser()
|
||||
const channel = supabase.channel(`asignaturas-status-${args.subjectId}`)
|
||||
realtimeChannelRef.current = channel
|
||||
|
||||
channel.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'asignaturas',
|
||||
filter: `id=eq.${args.subjectId}`,
|
||||
},
|
||||
(payload) => {
|
||||
if (cancelledRef.current) return
|
||||
|
||||
const next: any = (payload as any)?.new
|
||||
if (!next?.id || !next?.plan_estudio_id) return
|
||||
handleSubjectReady({
|
||||
id: String(next.id),
|
||||
plan_estudio_id: String(next.plan_estudio_id),
|
||||
estado: next.estado,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
channel.subscribe((status) => {
|
||||
if (cancelledRef.current) return
|
||||
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
|
||||
stopSubjectWatch()
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage:
|
||||
'No se pudo suscribir al estado de la asignatura. Intenta de nuevo.',
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const uploadAiAttachments = async (args: {
|
||||
planId: string
|
||||
files: Array<{ file: File }>
|
||||
}): Promise<Array<string>> => {
|
||||
const supabase = supabaseBrowser()
|
||||
if (!args.files.length) return []
|
||||
|
||||
const runId = crypto.randomUUID()
|
||||
const basePath = `planes/${args.planId}/asignaturas/ai/${runId}`
|
||||
|
||||
const keys: Array<string> = []
|
||||
for (const f of args.files) {
|
||||
const safeName = (f.file.name || 'archivo').replace(/[\\/]+/g, '_')
|
||||
const key = `${basePath}/${crypto.randomUUID()}-${safeName}`
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from('ai-storage')
|
||||
.upload(key, f.file, {
|
||||
contentType: f.file.type || undefined,
|
||||
})
|
||||
|
||||
if (error) throw new Error(error.message)
|
||||
keys.push(key)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
@@ -48,48 +198,99 @@ export function WizardControls({
|
||||
errorMessage: null,
|
||||
}))
|
||||
|
||||
let startedWaiting = false
|
||||
|
||||
try {
|
||||
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,
|
||||
datosBasicos: {
|
||||
estructura_id: wizard.datosBasicos.estructuraId,
|
||||
nombre: wizard.datosBasicos.nombre,
|
||||
codigo: wizard.datosBasicos.codigo,
|
||||
tipo: wizard.datosBasicos.tipo!,
|
||||
creditos: wizard.datosBasicos.creditos!,
|
||||
horasIndependientes: wizard.datosBasicos.horasIndependientes,
|
||||
horasAcademicas: wizard.datosBasicos.horasAcademicas,
|
||||
estructuraId: wizard.datosBasicos.estructuraId!,
|
||||
codigo: wizard.datosBasicos.codigo ?? null,
|
||||
tipo: wizard.datosBasicos.tipo ?? undefined,
|
||||
creditos: wizard.datosBasicos.creditos,
|
||||
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
|
||||
horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
|
||||
estado: 'generando',
|
||||
tipo_origen: 'IA',
|
||||
}
|
||||
|
||||
const { data: inserted, error: insertError } = await supabase
|
||||
.from('asignaturas')
|
||||
.insert(placeholder)
|
||||
.select('id,plan_estudio_id')
|
||||
.single()
|
||||
|
||||
if (insertError) throw new Error(insertError.message)
|
||||
const subjectId = inserted.id
|
||||
|
||||
setIsSpinningIA(true)
|
||||
|
||||
// Inicia watch realtime antes de disparar la Edge para no perder updates.
|
||||
startedWaiting = true
|
||||
beginSubjectWatch({ subjectId, planId: wizard.plan_estudio_id })
|
||||
|
||||
const archivosAdjuntos = await uploadAiAttachments({
|
||||
planId: wizard.plan_estudio_id,
|
||||
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
|
||||
file: x.file,
|
||||
})),
|
||||
})
|
||||
|
||||
const payload: AISubjectUnifiedInput = {
|
||||
datosUpdate: {
|
||||
id: subjectId,
|
||||
plan_estudio_id: wizard.plan_estudio_id,
|
||||
estructura_id: wizard.datosBasicos.estructuraId,
|
||||
nombre: wizard.datosBasicos.nombre,
|
||||
codigo: wizard.datosBasicos.codigo ?? null,
|
||||
tipo: wizard.datosBasicos.tipo ?? null,
|
||||
creditos: wizard.datosBasicos.creditos,
|
||||
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
|
||||
horas_independientes:
|
||||
wizard.datosBasicos.horasIndependientes ?? null,
|
||||
},
|
||||
iaConfig: {
|
||||
descripcionEnfoqueAcademico:
|
||||
wizard.iaConfig!.descripcionEnfoqueAcademico,
|
||||
wizard.iaConfig?.descripcionEnfoqueAcademico ?? undefined,
|
||||
instruccionesAdicionalesIA:
|
||||
wizard.iaConfig!.instruccionesAdicionalesIA,
|
||||
archivosReferencia: wizard.iaConfig!.archivosReferencia,
|
||||
repositoriosReferencia:
|
||||
wizard.iaConfig!.repositoriosReferencia || [],
|
||||
archivosAdjuntos: wizard.iaConfig!.archivosAdjuntos || [],
|
||||
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
|
||||
archivosAdjuntos,
|
||||
},
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${new Date().toISOString()} - Enviando a generar asignatura con IA`,
|
||||
`${new Date().toISOString()} - Disparando Edge IA asignatura (unified)`,
|
||||
)
|
||||
|
||||
setIsSpinningIA(true)
|
||||
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,
|
||||
// )
|
||||
await generateSubjectAI.mutateAsync(payload as any)
|
||||
|
||||
navigate({
|
||||
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
|
||||
state: { showConfetti: true },
|
||||
// Fallback: una lectura puntual por si el UPDATE llegó antes de suscribir.
|
||||
const latest = await subjects_get_maybe(subjectId)
|
||||
if (latest) {
|
||||
handleSubjectReady({
|
||||
id: latest.id as any,
|
||||
plan_estudio_id: latest.plan_estudio_id as any,
|
||||
estado: (latest as any).estado,
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -108,6 +309,15 @@ export function WizardControls({
|
||||
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
setIsSpinningIA(true)
|
||||
|
||||
const archivosAdjuntos = await uploadAiAttachments({
|
||||
planId: wizard.plan_estudio_id,
|
||||
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
|
||||
file: x.file,
|
||||
})),
|
||||
})
|
||||
|
||||
const placeholders: Array<TablesInsert<'asignaturas'>> = selected.map(
|
||||
(s): TablesInsert<'asignaturas'> => ({
|
||||
plan_estudio_id: wizard.plan_estudio_id,
|
||||
@@ -141,16 +351,33 @@ export function WizardControls({
|
||||
// Disparar generación en paralelo (no bloquear navegación)
|
||||
insertedIds.forEach((id, 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,
|
||||
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,
|
||||
// (opcionales) parches directos si el edge los usa
|
||||
estructura_id: wizard.estructuraId,
|
||||
linea_plan_id: s.linea_plan_id,
|
||||
numero_ciclo: s.numero_ciclo,
|
||||
instruccionesAdicionalesIA:
|
||||
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
|
||||
archivosAdjuntos,
|
||||
},
|
||||
}
|
||||
|
||||
void generateSubjectAI.mutateAsync(payload).catch((e) => {
|
||||
void generateSubjectAI.mutateAsync(payload as any).catch((e) => {
|
||||
console.error('Error generando asignatura IA (multiple):', e)
|
||||
})
|
||||
})
|
||||
@@ -166,6 +393,8 @@ export function WizardControls({
|
||||
resetScroll: false,
|
||||
})
|
||||
|
||||
setIsSpinningIA(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -195,16 +424,19 @@ export function WizardControls({
|
||||
}
|
||||
} catch (err: any) {
|
||||
setIsSpinningIA(false)
|
||||
stopSubjectWatch()
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage: err?.message ?? 'Error creando la asignatura',
|
||||
}))
|
||||
} finally {
|
||||
if (!startedWaiting) {
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({ ...w, isLoading: false }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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 { supabase } from '@/lib/supabase'
|
||||
import { LoginInput } from '../ui/LoginInput'
|
||||
import { SubmitButton } from '../ui/SubmitButton'
|
||||
|
||||
import { throwIfError } from '@/data/api/_helpers'
|
||||
import { qk } from '@/data/query/keys'
|
||||
import { supabaseBrowser } from '@/data/supabase/client'
|
||||
|
||||
export function ExternalLoginForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate({ from: '/login' })
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const submit = async () => {
|
||||
/* await supabase.auth.signInWithPassword({
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
})*/
|
||||
})
|
||||
throwIfError(error)
|
||||
|
||||
qc.invalidateQueries({ queryKey: qk.session() })
|
||||
qc.invalidateQueries({ queryKey: qk.auth })
|
||||
await navigate({ to: '/dashboard', replace: true })
|
||||
} catch (e: unknown) {
|
||||
const anyErr = e as any
|
||||
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -34,7 +60,11 @@ export function ExternalLoginForm() {
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
/>
|
||||
<SubmitButton />
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
<SubmitButton
|
||||
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,45 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
|
||||
// import { supabase } from '@/lib/supabase'
|
||||
import { LoginInput } from '../ui/LoginInput'
|
||||
import { SubmitButton } from '../ui/SubmitButton'
|
||||
|
||||
import { throwIfError } from '@/data/api/_helpers'
|
||||
import { qk } from '@/data/query/keys'
|
||||
import { supabaseBrowser } from '@/data/supabase/client'
|
||||
|
||||
export function InternalLoginForm() {
|
||||
const [clave, setClave] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate({ from: '/login' })
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const submit = async () => {
|
||||
/* await supabase.auth.signInWithPassword({
|
||||
email: `${clave}@ulsa.mx`,
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const email = clave.includes('@') ? clave : `${clave}@ulsa.mx`
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
})*/
|
||||
})
|
||||
throwIfError(error)
|
||||
|
||||
qc.invalidateQueries({ queryKey: qk.session() })
|
||||
qc.invalidateQueries({ queryKey: qk.auth })
|
||||
await navigate({ to: '/dashboard', replace: true })
|
||||
} catch (e: unknown) {
|
||||
const anyErr = e as any
|
||||
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -30,7 +57,11 @@ export function InternalLoginForm() {
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
/>
|
||||
<SubmitButton />
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
<SubmitButton
|
||||
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,26 +2,29 @@ import { Check, Loader2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useUpdatePlanFields } from '@/data' // Tu hook existente
|
||||
import { useUpdatePlanFields, useUpdateRecommendationApplied } from '@/data'
|
||||
|
||||
export const ImprovementCard = ({
|
||||
suggestions,
|
||||
onApply,
|
||||
planId, // Necesitamos el ID
|
||||
currentDatos, // Necesitamos los datos actuales para no sobrescribir todo el JSON
|
||||
planId,
|
||||
currentDatos,
|
||||
activeChatId,
|
||||
onApplySuccess,
|
||||
}: {
|
||||
suggestions: Array<any>
|
||||
onApply?: (key: string, value: string) => void
|
||||
planId: string
|
||||
currentDatos: any
|
||||
activeChatId: any
|
||||
onApplySuccess?: (key: string) => void
|
||||
}) => {
|
||||
const [appliedFields, setAppliedFields] = useState<Array<string>>([])
|
||||
const [localApplied, setLocalApplied] = useState<Array<string>>([])
|
||||
const updatePlan = useUpdatePlanFields()
|
||||
const updateAppliedStatus = useUpdateRecommendationApplied()
|
||||
|
||||
const handleApply = (key: string, newValue: string) => {
|
||||
if (!currentDatos) return
|
||||
|
||||
// 1. Lógica para preparar el valor (idéntica a tu handleSave original)
|
||||
const currentValue = currentDatos[key]
|
||||
let finalValue: any
|
||||
|
||||
@@ -35,13 +38,11 @@ export const ImprovementCard = ({
|
||||
finalValue = newValue
|
||||
}
|
||||
|
||||
// 2. Construir el nuevo objeto 'datos' manteniendo lo que ya existía
|
||||
const datosActualizados = {
|
||||
...currentDatos,
|
||||
[key]: finalValue,
|
||||
}
|
||||
|
||||
// 3. Ejecutar la mutación directamente aquí
|
||||
updatePlan.mutate(
|
||||
{
|
||||
planId: planId as any,
|
||||
@@ -49,9 +50,17 @@ export const ImprovementCard = ({
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setAppliedFields((prev) => [...prev, key])
|
||||
setLocalApplied((prev) => [...prev, key])
|
||||
|
||||
if (onApplySuccess) onApplySuccess(key)
|
||||
if (activeChatId) {
|
||||
updateAppliedStatus.mutate({
|
||||
conversacionId: activeChatId,
|
||||
campoAfectado: key,
|
||||
})
|
||||
}
|
||||
|
||||
if (onApply) onApply(key, newValue)
|
||||
console.log(`Campo ${key} guardado exitosamente`)
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -60,7 +69,7 @@ export const ImprovementCard = ({
|
||||
return (
|
||||
<div className="mt-2 flex w-full flex-col gap-4">
|
||||
{suggestions.map((sug) => {
|
||||
const isApplied = appliedFields.includes(sug.key)
|
||||
const isApplied = sug.applied === true || localApplied.includes(sug.key)
|
||||
const isUpdating =
|
||||
updatePlan.isPending &&
|
||||
updatePlan.variables.patch.datos?.[sug.key] !== undefined
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { AIGeneratePlanInput } from '@/data'
|
||||
import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
// import type { Database } from '@/types/supabase'
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
// import { supabaseBrowser } from '@/data'
|
||||
import { useCreatePlanManual, useGeneratePlanAI } from '@/data/hooks/usePlans'
|
||||
import { plans_get_maybe } from '@/data/api/plans.api'
|
||||
import {
|
||||
useCreatePlanManual,
|
||||
useDeletePlanEstudio,
|
||||
useGeneratePlanAI,
|
||||
} from '@/data/hooks/usePlans'
|
||||
import { supabaseBrowser } from '@/data/supabase/client'
|
||||
|
||||
export function WizardControls({
|
||||
errorMessage,
|
||||
@@ -35,9 +41,152 @@ export function WizardControls({
|
||||
const navigate = useNavigate()
|
||||
const generatePlanAI = useGeneratePlanAI()
|
||||
const createPlanManual = useCreatePlanManual()
|
||||
const deletePlan = useDeletePlanEstudio()
|
||||
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
||||
// const supabaseClient = supabaseBrowser()
|
||||
// const persistPlanFromAI = usePersistPlanFromAI()
|
||||
const cancelledRef = useRef(false)
|
||||
const realtimeChannelRef = useRef<RealtimeChannel | null>(null)
|
||||
const watchPlanIdRef = useRef<string | null>(null)
|
||||
const watchTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
cancelledRef.current = false
|
||||
return () => {
|
||||
cancelledRef.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopPlanWatch = useCallback(() => {
|
||||
if (watchTimeoutRef.current) {
|
||||
window.clearTimeout(watchTimeoutRef.current)
|
||||
watchTimeoutRef.current = null
|
||||
}
|
||||
|
||||
watchPlanIdRef.current = null
|
||||
|
||||
const ch = realtimeChannelRef.current
|
||||
if (ch) {
|
||||
realtimeChannelRef.current = null
|
||||
try {
|
||||
supabaseBrowser().removeChannel(ch)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPlanWatch()
|
||||
}
|
||||
}, [stopPlanWatch])
|
||||
|
||||
const checkPlanStateAndAct = useCallback(
|
||||
async (planId: string) => {
|
||||
if (cancelledRef.current) return
|
||||
if (watchPlanIdRef.current !== planId) return
|
||||
|
||||
const plan = await plans_get_maybe(planId as any)
|
||||
if (!plan) return
|
||||
|
||||
const clave = String(plan.estados_plan?.clave ?? '').toUpperCase()
|
||||
|
||||
if (clave.startsWith('GENERANDO')) return
|
||||
|
||||
if (clave.startsWith('BORRADOR')) {
|
||||
stopPlanWatch()
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({ ...w, isLoading: false }))
|
||||
navigate({
|
||||
to: `/planes/${plan.id}`,
|
||||
state: { showConfetti: true },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (clave.startsWith('FALLID')) {
|
||||
stopPlanWatch()
|
||||
setIsSpinningIA(false)
|
||||
|
||||
deletePlan
|
||||
.mutateAsync(plan.id)
|
||||
.catch(() => {
|
||||
// Si falla el borrado, igual mostramos el error.
|
||||
})
|
||||
.finally(() => {
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage: 'La generación del plan falló',
|
||||
}))
|
||||
})
|
||||
}
|
||||
},
|
||||
[deletePlan, navigate, setWizard, stopPlanWatch],
|
||||
)
|
||||
|
||||
const beginPlanWatch = useCallback(
|
||||
(planId: string) => {
|
||||
stopPlanWatch()
|
||||
watchPlanIdRef.current = planId
|
||||
|
||||
watchTimeoutRef.current = window.setTimeout(
|
||||
() => {
|
||||
if (cancelledRef.current) return
|
||||
if (watchPlanIdRef.current !== planId) return
|
||||
|
||||
stopPlanWatch()
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage:
|
||||
'La generación está tardando demasiado. Intenta de nuevo en unos minutos.',
|
||||
}))
|
||||
},
|
||||
6 * 60 * 1000,
|
||||
)
|
||||
|
||||
const supabase = supabaseBrowser()
|
||||
const channel = supabase.channel(`planes-status-${planId}`)
|
||||
realtimeChannelRef.current = channel
|
||||
|
||||
channel.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'planes_estudio',
|
||||
filter: `id=eq.${planId}`,
|
||||
},
|
||||
() => {
|
||||
void checkPlanStateAndAct(planId)
|
||||
},
|
||||
)
|
||||
|
||||
channel.subscribe((status) => {
|
||||
const st = status as
|
||||
| 'SUBSCRIBED'
|
||||
| 'TIMED_OUT'
|
||||
| 'CLOSED'
|
||||
| 'CHANNEL_ERROR'
|
||||
if (cancelledRef.current) return
|
||||
if (st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
|
||||
stopPlanWatch()
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage:
|
||||
'No se pudo suscribir al estado del plan. Intenta de nuevo.',
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback inmediato por si el plan ya cambió antes de suscribir.
|
||||
void checkPlanStateAndAct(planId)
|
||||
},
|
||||
[checkPlanStateAndAct, setWizard, stopPlanWatch],
|
||||
)
|
||||
|
||||
const handleCreate = async () => {
|
||||
// Start loading
|
||||
@@ -82,14 +231,16 @@ export function WizardControls({
|
||||
console.log(`${new Date().toISOString()} - Enviando a generar plan IA`)
|
||||
|
||||
setIsSpinningIA(true)
|
||||
const plan = await generatePlanAI.mutateAsync(aiInput as any)
|
||||
setIsSpinningIA(false)
|
||||
console.log(`${new Date().toISOString()} - Plan IA generado`, plan)
|
||||
const resp: any = await generatePlanAI.mutateAsync(aiInput as any)
|
||||
const planId = resp?.plan?.id ?? resp?.id
|
||||
console.log(`${new Date().toISOString()} - Plan IA generado`, resp)
|
||||
|
||||
navigate({
|
||||
to: `/planes/${plan.id}`,
|
||||
state: { showConfetti: true },
|
||||
})
|
||||
if (!planId) {
|
||||
throw new Error('No se pudo obtener el id del plan generado por IA')
|
||||
}
|
||||
|
||||
// Inicia realtime; los efectos navegan o marcan error.
|
||||
beginPlanWatch(String(planId))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -114,14 +265,14 @@ export function WizardControls({
|
||||
}
|
||||
} catch (err: any) {
|
||||
setIsSpinningIA(false)
|
||||
stopPlanWatch()
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage: err?.message ?? 'Error generando el plan',
|
||||
}))
|
||||
} finally {
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({ ...w, isLoading: false }))
|
||||
// Si entramos en watch realtime, el loading se corta desde checkPlanStateAndAct.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
interface Props {
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function SubmitButton({ text = 'Iniciar sesión' }: Props) {
|
||||
export function SubmitButton({ text = 'Iniciar sesión', disabled }: Props) {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-[#7b0f1d] text-white py-2 rounded-lg
|
||||
font-semibold hover:opacity-90 transition"
|
||||
disabled={disabled}
|
||||
className="w-full rounded-lg bg-[#7b0f1d] py-2 font-semibold text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
|
||||
@@ -111,12 +111,6 @@ export async function create_conversation(planId: string) {
|
||||
)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// LOG de depuración: Mira qué estructura trae 'data'
|
||||
console.log('Respuesta creación conv:', data)
|
||||
|
||||
// Si data es { id: "..." }, devolvemos data.
|
||||
// Si data viene envuelto, asegúrate de retornar el objeto con el id.
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -181,3 +175,64 @@ export async function getConversationByPlan(planId: string) {
|
||||
// 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.')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
planId: UUID,
|
||||
): Promise<Array<LineaPlan>> {
|
||||
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
TipoAsignatura,
|
||||
UUID,
|
||||
} from '../types/domain'
|
||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
||||
import type {
|
||||
AsignaturaSugerida,
|
||||
DataAsignaturaSugerida,
|
||||
@@ -178,54 +177,49 @@ export async function subjects_create_manual(
|
||||
return requireData(data, 'No se pudo crear la asignatura.')
|
||||
}
|
||||
|
||||
export type AIGenerateSubjectInput = {
|
||||
plan_estudio_id: Asignatura['plan_estudio_id']
|
||||
datosBasicos: {
|
||||
nombre: Asignatura['nombre']
|
||||
codigo?: Asignatura['codigo']
|
||||
tipo: Asignatura['tipo'] | null
|
||||
creditos: Asignatura['creditos'] | null
|
||||
horasAcademicas?: Asignatura['horas_academicas'] | null
|
||||
horasIndependientes?: Asignatura['horas_independientes'] | null
|
||||
estructuraId: Asignatura['estructura_id'] | null
|
||||
/**
|
||||
* Nuevo payload unificado (JSON) para la Edge `ai_generate_subject`.
|
||||
* - Siempre incluye `datosUpdate.plan_estudio_id`.
|
||||
* - `datosUpdate.id` es opcional (si no existe, la Edge puede crear).
|
||||
* En el frontend, insertamos primero y usamos `id` para actualizar.
|
||||
*/
|
||||
export type AISubjectUnifiedInput = {
|
||||
datosUpdate: Partial<{
|
||||
id: string
|
||||
plan_estudio_id: string
|
||||
estructura_id: string
|
||||
nombre: string
|
||||
codigo: string | null
|
||||
tipo: string | null
|
||||
creditos: number
|
||||
horas_academicas: number | null
|
||||
horas_independientes: number | null
|
||||
numero_ciclo: number | null
|
||||
linea_plan_id: string | null
|
||||
orden_celda: number | null
|
||||
}> & {
|
||||
plan_estudio_id: string
|
||||
}
|
||||
// clonInterno?: {
|
||||
// facultadId?: string
|
||||
// carreraId?: string
|
||||
// planOrigenId?: string
|
||||
// asignaturaOrigenId?: string | null
|
||||
// }
|
||||
// clonTradicional?: {
|
||||
// archivoWordAsignaturaId: string | null
|
||||
// archivosAdicionalesIds: Array<string>
|
||||
// }
|
||||
iaConfig?: {
|
||||
descripcionEnfoqueAcademico: string
|
||||
instruccionesAdicionalesIA: string
|
||||
archivosReferencia: Array<string>
|
||||
repositoriosReferencia?: Array<string>
|
||||
archivosAdjuntos?: Array<UploadedFile>
|
||||
descripcionEnfoqueAcademico?: string
|
||||
instruccionesAdicionalesIA?: string
|
||||
archivosAdjuntos?: Array<string>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge (JSON): actualizar/llenar una asignatura existente por id.
|
||||
* Nota: este flujo NO acepta `instruccionesAdicionalesIA` (solo FormData lo usa).
|
||||
*/
|
||||
export type AIGenerateSubjectJsonInput = Partial<{
|
||||
plan_estudio_id: Asignatura['plan_estudio_id']
|
||||
nombre: Asignatura['nombre']
|
||||
codigo: Asignatura['codigo']
|
||||
tipo: Asignatura['tipo'] | null
|
||||
creditos: Asignatura['creditos']
|
||||
horas_academicas: Asignatura['horas_academicas'] | null
|
||||
horas_independientes: Asignatura['horas_independientes'] | null
|
||||
estructura_id: Asignatura['estructura_id'] | null
|
||||
linea_plan_id: Asignatura['linea_plan_id'] | null
|
||||
numero_ciclo: Asignatura['numero_ciclo'] | null
|
||||
descripcionEnfoqueAcademico: string
|
||||
}> & {
|
||||
id: Asignatura['id']
|
||||
export async function subjects_get_maybe(
|
||||
subjectId: UUID,
|
||||
): Promise<Asignatura | null> {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('asignaturas')
|
||||
.select('id,plan_estudio_id,estado')
|
||||
.eq('id', subjectId)
|
||||
.maybeSingle()
|
||||
|
||||
throwIfError(error)
|
||||
return (data ?? null) as unknown as Asignatura | null
|
||||
}
|
||||
|
||||
export type GenerateSubjectSuggestionsInput = {
|
||||
@@ -263,30 +257,8 @@ export async function generate_subject_suggestions(
|
||||
}
|
||||
|
||||
export async function ai_generate_subject(
|
||||
input: AIGenerateSubjectInput | AIGenerateSubjectJsonInput,
|
||||
input: AISubjectUnifiedInput,
|
||||
): 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, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
@@ -490,3 +462,51 @@ export async function lineas_delete(lineaId: string) {
|
||||
if (error) throw error
|
||||
return lineaId
|
||||
}
|
||||
|
||||
export async function bibliografia_insert(entry: {
|
||||
asignatura_id: string
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
||||
cita: string
|
||||
tipo_fuente: 'MANUAL' | 'BIBLIOTECA'
|
||||
biblioteca_item_id?: string | null
|
||||
}) {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from('bibliografia_asignatura')
|
||||
.insert([entry])
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export async function bibliografia_update(
|
||||
id: string,
|
||||
updates: {
|
||||
cita?: string
|
||||
tipo?: 'BASICA' | 'COMPLEMENTARIA'
|
||||
},
|
||||
) {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from('bibliografia_asignatura')
|
||||
.update(updates) // Ahora 'updates' es compatible con lo que espera Supabase
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export async function bibliografia_delete(id: string) {
|
||||
const supabase = supabaseBrowser()
|
||||
const { error } = await supabase
|
||||
.from('bibliografia_asignatura')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
getConversationByPlan,
|
||||
library_search,
|
||||
update_conversation_status,
|
||||
update_recommendation_applied_status,
|
||||
update_conversation_title,
|
||||
} from '../api/ai.api'
|
||||
|
||||
// eslint-disable-next-line node/prefer-node-protocol
|
||||
@@ -35,8 +37,6 @@ export function useAIPlanChat() {
|
||||
|
||||
// CAMBIO AQUÍ: Accedemos a la estructura correcta según tu consola
|
||||
currentId = response.conversation_plan.id
|
||||
|
||||
console.log('Nuevo ID extraído:', currentId)
|
||||
}
|
||||
|
||||
// 2. Ahora enviamos el mensaje con el ID garantizado
|
||||
@@ -56,11 +56,8 @@ export function useChatHistory(conversacionId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['chat-history', conversacionId],
|
||||
queryFn: async () => {
|
||||
console.log('--- EJECUTANDO QUERY FN ---')
|
||||
console.log('ID RECIBIDO:', conversacionId)
|
||||
return get_chat_history(conversacionId!)
|
||||
},
|
||||
// Simplificamos el enabled para probar
|
||||
enabled: Boolean(conversacionId),
|
||||
})
|
||||
}
|
||||
@@ -91,6 +88,31 @@ export function useConversationByPlan(planId: string | null) {
|
||||
})
|
||||
}
|
||||
|
||||
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() {
|
||||
return useMutation({ mutationFn: ai_subject_improve })
|
||||
}
|
||||
@@ -102,3 +124,16 @@ export function useAISubjectChat() {
|
||||
export function useLibrarySearch() {
|
||||
return useMutation({ mutationFn: library_search })
|
||||
}
|
||||
|
||||
export function useUpdateConversationTitle() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, nombre }: { id: string; nombre: string }) =>
|
||||
update_conversation_title(id, nombre),
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidamos para que la lista de chats se refresque
|
||||
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,59 +1,145 @@
|
||||
import { useEffect } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { qk } from "../query/keys";
|
||||
import { throwIfError } from "../api/_helpers";
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { throwIfError } from '../api/_helpers'
|
||||
import { qk } from '../query/keys'
|
||||
import { supabaseBrowser } from '../supabase/client'
|
||||
|
||||
export function useSession() {
|
||||
const supabase = supabaseBrowser();
|
||||
const qc = useQueryClient();
|
||||
const supabase = supabaseBrowser()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: qk.session(),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
throwIfError(error);
|
||||
return data.session ?? null;
|
||||
const { data, error } = await supabase.auth.getSession()
|
||||
throwIfError(error)
|
||||
return data.session ?? null
|
||||
},
|
||||
staleTime: Infinity,
|
||||
});
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const { data } = supabase.auth.onAuthStateChange(() => {
|
||||
qc.invalidateQueries({ queryKey: qk.session() });
|
||||
qc.invalidateQueries({ queryKey: qk.meProfile() });
|
||||
qc.invalidateQueries({ queryKey: qk.auth });
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: qk.session() })
|
||||
qc.invalidateQueries({ queryKey: qk.meProfile() })
|
||||
qc.invalidateQueries({ queryKey: qk.meAccess() })
|
||||
qc.invalidateQueries({ queryKey: qk.auth })
|
||||
})
|
||||
|
||||
return () => data.subscription.unsubscribe();
|
||||
}, [supabase, qc]);
|
||||
return () => data.subscription.unsubscribe()
|
||||
}, [supabase, qc])
|
||||
|
||||
return query;
|
||||
return query
|
||||
}
|
||||
|
||||
export function useMeProfile() {
|
||||
const supabase = supabaseBrowser();
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
return useQuery({
|
||||
queryKey: qk.meProfile(),
|
||||
queryFn: async () => {
|
||||
const { data: u, error: uErr } = await supabase.auth.getUser();
|
||||
throwIfError(uErr);
|
||||
const userId = u.user?.id;
|
||||
if (!userId) return null;
|
||||
const { data: u, error: uErr } = await supabase.auth.getUser()
|
||||
throwIfError(uErr)
|
||||
const userId = u.user?.id
|
||||
if (!userId) return null
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("usuarios_app")
|
||||
.select("id,nombre_completo,email,externo,creado_en,actualizado_en")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
.from('usuarios_app')
|
||||
.select('id,nombre_completo,email,externo,creado_en,actualizado_en')
|
||||
.eq('id', userId)
|
||||
.single()
|
||||
|
||||
// si aún no existe perfil en usuarios_app, permite null (tu seed/trigger puede crearlo)
|
||||
if (error && (error as any).code === "PGRST116") return null;
|
||||
if (error && (error as any).code === 'PGRST116') return null
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? null;
|
||||
throwIfError(error)
|
||||
return data ?? null
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export type MeAccessRole = {
|
||||
assignmentId: string
|
||||
rolId: string
|
||||
clave: string
|
||||
nombre: string
|
||||
descripcion: string | null
|
||||
facultadId: string | null
|
||||
carreraId: string | null
|
||||
}
|
||||
|
||||
export type MeAccess = {
|
||||
userId: string
|
||||
roles: Array<MeAccessRole>
|
||||
permissions: Array<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Database-first RBAC: obtiene roles del usuario desde tablas app (NO desde JWT).
|
||||
*
|
||||
* Nota: el esquema actual modela roles con `usuarios_roles` -> `roles`.
|
||||
*/
|
||||
export function useMeAccess() {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
return useQuery({
|
||||
queryKey: qk.meAccess(),
|
||||
queryFn: async (): Promise<MeAccess | null> => {
|
||||
const { data: u, error: uErr } = await supabase.auth.getUser()
|
||||
throwIfError(uErr)
|
||||
const userId = u.user?.id
|
||||
if (!userId) return null
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('usuarios_roles')
|
||||
.select(
|
||||
'id,rol_id,facultad_id,carrera_id,roles(id,clave,nombre,descripcion)',
|
||||
)
|
||||
.eq('usuario_id', userId)
|
||||
|
||||
throwIfError(error)
|
||||
|
||||
const roles: Array<MeAccessRole> = (data ?? [])
|
||||
.map((row: any) => {
|
||||
const rol = row.roles
|
||||
if (!rol) return null
|
||||
return {
|
||||
assignmentId: row.id,
|
||||
rolId: rol.id,
|
||||
clave: rol.clave,
|
||||
nombre: rol.nombre,
|
||||
descripcion: rol.descripcion ?? null,
|
||||
facultadId: row.facultad_id ?? null,
|
||||
carreraId: row.carrera_id ?? null,
|
||||
} satisfies MeAccessRole
|
||||
})
|
||||
.filter(Boolean) as Array<MeAccessRole>
|
||||
|
||||
// Por ahora, los permisos granulares se derivan de claves de rol.
|
||||
// Si luego existe una tabla `roles_permisos`, aquí se expande a permisos reales.
|
||||
const permissions = Array.from(new Set(roles.map((r) => r.clave)))
|
||||
|
||||
return {
|
||||
userId,
|
||||
roles,
|
||||
permissions,
|
||||
}
|
||||
},
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const session = useSession()
|
||||
const meProfile = useMeProfile()
|
||||
const meAccess = useMeAccess()
|
||||
|
||||
return {
|
||||
session,
|
||||
meProfile,
|
||||
meAccess,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import {
|
||||
ai_generate_plan,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
plan_lineas_list,
|
||||
plans_clone_from_existing,
|
||||
plans_create_manual,
|
||||
plans_delete,
|
||||
plans_generate_document,
|
||||
plans_get,
|
||||
plans_get_document,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
} from '../api/plans.api'
|
||||
import { lineas_delete } from '../api/subjects.api'
|
||||
import { qk } from '../query/keys'
|
||||
import { supabaseBrowser } from '../supabase/client'
|
||||
|
||||
import type {
|
||||
PlanListFilters,
|
||||
@@ -71,23 +74,79 @@ export function usePlanLineas(planId: UUID | null | undefined) {
|
||||
}
|
||||
|
||||
export function usePlanAsignaturas(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
const qc = useQueryClient()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: planId
|
||||
? qk.planAsignaturas(planId)
|
||||
: ['planes', 'asignaturas', null],
|
||||
queryFn: () => plan_asignaturas_list(planId as UUID),
|
||||
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(
|
||||
@@ -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() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ai_generate_subject,
|
||||
asignaturas_update,
|
||||
bibliografia_delete,
|
||||
bibliografia_insert,
|
||||
bibliografia_update,
|
||||
lineas_insert,
|
||||
lineas_update,
|
||||
subjects_bibliografia_list,
|
||||
@@ -276,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,
|
||||
CambioAsignatura,
|
||||
DocumentoAsignatura,
|
||||
LibraryResource,
|
||||
} from '@/types/asignatura'
|
||||
|
||||
export const mockAsignatura: Asignatura = {
|
||||
@@ -310,67 +309,3 @@ export const mockDocumentoSep: DocumentoAsignatura = {
|
||||
fechaGeneracion: new Date('2024-12-06T11:30:00'),
|
||||
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,
|
||||
session: () => ['auth', 'session'] as const,
|
||||
meProfile: () => ['auth', 'meProfile'] as const,
|
||||
meAccess: () => ['auth', 'meAccess'] as const,
|
||||
|
||||
facultades: () => ['meta', 'facultades'] as const,
|
||||
carreras: (facultadId?: string | null) =>
|
||||
@@ -13,6 +14,7 @@ export const qk = {
|
||||
|
||||
planesList: (filters: unknown) => ['planes', 'list', filters] as const,
|
||||
plan: (planId: string) => ['planes', 'detail', planId] as const,
|
||||
planMaybe: (planId: string) => ['planes', 'detail-maybe', planId] as const,
|
||||
planLineas: (planId: string) => ['planes', planId, 'lineas'] as const,
|
||||
planAsignaturas: (planId: string) =>
|
||||
['planes', planId, 'asignaturas'] as const,
|
||||
@@ -22,6 +24,8 @@ export const qk = {
|
||||
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
|
||||
asignatura: (asignaturaId: string) =>
|
||||
['asignaturas', 'detail', asignaturaId] as const,
|
||||
asignaturaMaybe: (asignaturaId: string) =>
|
||||
['asignaturas', 'detail-maybe', asignaturaId] as const,
|
||||
asignaturaBibliografia: (asignaturaId: string) =>
|
||||
['asignaturas', asignaturaId, 'bibliografia'] as const,
|
||||
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() {
|
||||
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: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
@@ -13,8 +49,9 @@ export function getContext() {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
queryClientRef.current = queryClient
|
||||
return {
|
||||
queryClient,
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import reportWebVitals from './reportWebVitals.ts'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
import * as TanStackQueryProvider from '@/data/query/queryClient.tsx'
|
||||
import { supabaseBrowser } from '@/data/supabase/client'
|
||||
|
||||
import './styles.css'
|
||||
|
||||
@@ -16,6 +17,7 @@ const router = createRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
...TanStackQueryProviderContext,
|
||||
supabase: supabaseBrowser(),
|
||||
},
|
||||
defaultPreload: 'intent',
|
||||
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 PlanesPlanIdDetalleRouteImport } from './routes/planes/$planId/_detalle'
|
||||
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 PlanesPlanIdDetalleIaplanRouteImport } from './routes/planes/$planId/_detalle/iaplan'
|
||||
import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial'
|
||||
import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo'
|
||||
import { Route as PlanesPlanIdDetalleDocumentoRouteImport } from './routes/planes/$planId/_detalle/documento'
|
||||
import { Route as PlanesPlanIdDetalleAsignaturasRouteImport } from './routes/planes/$planId/_detalle/asignaturas'
|
||||
import { Route as 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'
|
||||
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
@@ -67,12 +73,6 @@ const PlanesPlanIdDetalleIndexRoute =
|
||||
path: '/',
|
||||
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdAsignaturasAsignaturaIdRoute =
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRouteImport.update({
|
||||
id: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
path: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlanesPlanIdDetalleMapaRoute = PlanesPlanIdDetalleMapaRouteImport.update({
|
||||
id: '/mapa',
|
||||
path: '/mapa',
|
||||
@@ -108,6 +108,48 @@ const PlanesPlanIdDetalleAsignaturasRoute =
|
||||
path: '/asignaturas',
|
||||
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||
} 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 =
|
||||
PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({
|
||||
id: '/nueva',
|
||||
@@ -123,15 +165,21 @@ export interface FileRoutesByFullPath {
|
||||
'/planes': typeof PlanesListaRouteWithChildren
|
||||
'/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren
|
||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
||||
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
|
||||
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
|
||||
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
|
||||
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
|
||||
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
|
||||
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
|
||||
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
@@ -146,9 +194,14 @@ export interface FileRoutesByTo {
|
||||
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
|
||||
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
|
||||
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
|
||||
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
|
||||
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -159,15 +212,21 @@ export interface FileRoutesById {
|
||||
'/planes/_lista': typeof PlanesListaRouteWithChildren
|
||||
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren
|
||||
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
|
||||
'/planes/$planId/_detalle/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
||||
'/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute
|
||||
'/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute
|
||||
'/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute
|
||||
'/planes/$planId/_detalle/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
|
||||
'/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
|
||||
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
|
||||
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -179,15 +238,21 @@ export interface FileRouteTypes {
|
||||
| '/planes'
|
||||
| '/planes/$planId'
|
||||
| '/planes/nuevo'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/asignaturas'
|
||||
| '/planes/$planId/documento'
|
||||
| '/planes/$planId/flujo'
|
||||
| '/planes/$planId/historial'
|
||||
| '/planes/$planId/iaplan'
|
||||
| '/planes/$planId/mapa'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/'
|
||||
| '/planes/$planId/asignaturas/nueva'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
@@ -202,9 +267,14 @@ export interface FileRouteTypes {
|
||||
| '/planes/$planId/historial'
|
||||
| '/planes/$planId/iaplan'
|
||||
| '/planes/$planId/mapa'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId'
|
||||
| '/planes/$planId/asignaturas/nueva'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
@@ -214,15 +284,21 @@ export interface FileRouteTypes {
|
||||
| '/planes/_lista'
|
||||
| '/planes/$planId/_detalle'
|
||||
| '/planes/_lista/nuevo'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/_detalle/asignaturas'
|
||||
| '/planes/$planId/_detalle/documento'
|
||||
| '/planes/$planId/_detalle/flujo'
|
||||
| '/planes/$planId/_detalle/historial'
|
||||
| '/planes/$planId/_detalle/iaplan'
|
||||
| '/planes/$planId/_detalle/mapa'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/_detalle/'
|
||||
| '/planes/$planId/_detalle/asignaturas/nueva'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -232,7 +308,7 @@ export interface RootRouteChildren {
|
||||
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
|
||||
PlanesListaRoute: typeof PlanesListaRouteWithChildren
|
||||
PlanesPlanIdDetalleRoute: typeof PlanesPlanIdDetalleRouteWithChildren
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRoute
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -293,13 +369,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleIndexRouteImport
|
||||
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': {
|
||||
id: '/planes/$planId/_detalle/mapa'
|
||||
path: '/mapa'
|
||||
@@ -342,6 +411,55 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasRouteImport
|
||||
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': {
|
||||
id: '/planes/$planId/_detalle/asignaturas/nueva'
|
||||
path: '/nueva'
|
||||
@@ -403,6 +521,36 @@ const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = {
|
||||
const PlanesPlanIdDetalleRouteWithChildren =
|
||||
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 = {
|
||||
IndexRoute: IndexRoute,
|
||||
DashboardRoute: DashboardRoute,
|
||||
@@ -410,8 +558,8 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
|
||||
PlanesListaRoute: PlanesListaRouteWithChildren,
|
||||
PlanesPlanIdDetalleRoute: PlanesPlanIdDetalleRouteWithChildren,
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRoute:
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRoute,
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute:
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,22 +1,59 @@
|
||||
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 { useEffect } from 'react'
|
||||
|
||||
import Header from '../components/Header'
|
||||
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
|
||||
|
||||
import type { Database } from '@/types/supabase'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { NotFoundPage } from '@/components/ui/NotFoundPage'
|
||||
import { throwIfError } from '@/data/api/_helpers'
|
||||
import { useSession } from '@/data/hooks/useAuth'
|
||||
import { qk } from '@/data/query/keys'
|
||||
|
||||
interface MyRouterContext {
|
||||
queryClient: QueryClient
|
||||
supabase: SupabaseClient<Database>
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<MyRouterContext>()({
|
||||
beforeLoad: async ({ context, location }) => {
|
||||
const pathname = location.pathname
|
||||
const isLogin = pathname === '/login'
|
||||
const isIndex = pathname === '/'
|
||||
|
||||
const session = await context.queryClient.ensureQueryData({
|
||||
queryKey: qk.session(),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await context.supabase.auth.getSession()
|
||||
throwIfError(error)
|
||||
return data.session ?? null
|
||||
},
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
if (!session && !isLogin) {
|
||||
throw redirect({ to: '/login' })
|
||||
}
|
||||
if (session && (isLogin || isIndex)) {
|
||||
throw redirect({ to: '/dashboard' })
|
||||
}
|
||||
},
|
||||
|
||||
component: () => (
|
||||
<>
|
||||
<Header />
|
||||
<AuthSync />
|
||||
<MaybeHeader />
|
||||
<Outlet />
|
||||
<TanStackDevtools
|
||||
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 {
|
||||
Plus,
|
||||
Copy,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronRight,
|
||||
@@ -144,9 +143,6 @@ function AsignaturasPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Copy className="mr-2 h-4 w-4" /> Clonar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
console.log('planId desde asignaturas', planId)
|
||||
|
||||
@@ -96,7 +96,8 @@ function RouteComponent() {
|
||||
}`,
|
||||
date: parseISO(item.cambiado_en),
|
||||
icon: config.icon,
|
||||
campo: item.campo,
|
||||
campo:
|
||||
data?.estructuras_plan?.definicion?.properties?.[item.campo]?.title,
|
||||
details: {
|
||||
from: item.valor_anterior,
|
||||
to: item.valor_nuevo,
|
||||
@@ -298,6 +299,8 @@ function RouteComponent() {
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid h-full grid-cols-2 gap-6">
|
||||
{/* Lado Antes */}
|
||||
{/* Lado Antes: Solo se renderiza si existe valor_anterior */}
|
||||
{selectedEvent?.details.from && (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
|
||||
<div className="h-2 w-2 rounded-full bg-red-400" />
|
||||
@@ -306,9 +309,10 @@ function RouteComponent() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-red-100 bg-red-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
|
||||
{renderValue(selectedEvent?.details.from)}
|
||||
{renderValue(selectedEvent.details.from)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lado Después */}
|
||||
<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">
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
Campo: {selectedEvent?.campo}
|
||||
{console.log(
|
||||
data?.estructuras_plan?.definicion?.properties?.[
|
||||
selectedEvent?.campo
|
||||
]?.title,
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
MessageSquarePlus,
|
||||
Archive,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
|
||||
@@ -27,9 +28,9 @@ import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
useAIPlanChat,
|
||||
useChatHistory,
|
||||
useConversationByPlan,
|
||||
useUpdateConversationStatus,
|
||||
useUpdateConversationTitle,
|
||||
} from '@/data'
|
||||
import { usePlan } from '@/data/hooks/usePlans'
|
||||
|
||||
@@ -66,7 +67,25 @@ interface SelectedField {
|
||||
label: 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')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
@@ -76,19 +95,14 @@ function RouteComponent() {
|
||||
const { data } = usePlan(planId)
|
||||
const routerState = useRouterState()
|
||||
const [openIA, setOpenIA] = useState(false)
|
||||
const [conversacionId, setConversacionId] = useState<string | null>(null)
|
||||
const { mutateAsync: sendChat, isLoading } = useAIPlanChat()
|
||||
const { mutateAsync: sendChat, isPending: isLoading } = useAIPlanChat()
|
||||
const { mutate: updateStatusMutation } = useUpdateConversationStatus()
|
||||
|
||||
const [activeChatId, setActiveChatId] = useState<string | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
const { data: historyMessages, isLoading: isLoadingHistory } =
|
||||
useChatHistory(activeChatId)
|
||||
const { data: lastConversation, isLoading: isLoadingConv } =
|
||||
useConversationByPlan(planId)
|
||||
// archivos
|
||||
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
|
||||
[],
|
||||
)
|
||||
@@ -104,76 +118,167 @@ function RouteComponent() {
|
||||
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
|
||||
const queryClient = useQueryClient()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
const [editingChatId, setEditingChatId] = useState<string | null>(null)
|
||||
const editableRef = useRef<HTMLSpanElement>(null)
|
||||
const { mutate: updateTitleMutation } = useUpdateConversationTitle()
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [optimisticMessage, setOptimisticMessage] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [filterQuery, setFilterQuery] = useState('')
|
||||
const availableFields = useMemo(() => {
|
||||
const definicion = data?.estructuras_plan
|
||||
?.definicion as EstructuraDefinicion
|
||||
|
||||
useEffect(() => {
|
||||
// 1. Si no hay ID o está cargando el historial, no hacemos nada
|
||||
if (!activeChatId || isLoadingHistory) return
|
||||
// Encadenamiento opcional para evitar errores si data es null
|
||||
if (!definicion.properties) return []
|
||||
|
||||
const messagesFromApi = historyMessages?.items || historyMessages
|
||||
|
||||
if (Array.isArray(messagesFromApi)) {
|
||||
const flattened = messagesFromApi.map((msg) => {
|
||||
let content = msg.content
|
||||
let suggestions: Array<any> = []
|
||||
|
||||
if (typeof content === 'object' && content !== null) {
|
||||
suggestions = Object.entries(content)
|
||||
.filter(([key]) => key !== 'ai-message')
|
||||
.map(([key, value]) => ({
|
||||
return Object.entries(definicion.properties).map(([key, value]) => ({
|
||||
key,
|
||||
label: key.replace(/_/g, ' '),
|
||||
newValue: value as string,
|
||||
label: value.title,
|
||||
value: String(value.description || ''),
|
||||
}))
|
||||
}, [data])
|
||||
|
||||
content = content['ai-message'] || JSON.stringify(content)
|
||||
}
|
||||
// Si el content es un string que parece JSON (caso común en respuestas RAW)
|
||||
else if (typeof content === 'string' && content.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
suggestions = Object.entries(parsed)
|
||||
.filter(([key]) => key !== 'ai-message')
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: key.replace(/_/g, ' '),
|
||||
newValue: value as string,
|
||||
}))
|
||||
content = parsed['ai-message'] || content
|
||||
} catch (e) {
|
||||
/* no es json */
|
||||
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 {
|
||||
...msg,
|
||||
content,
|
||||
suggestions,
|
||||
type: suggestions.length > 0 ? 'improvement-card' : 'text',
|
||||
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])
|
||||
|
||||
// Solo actualizamos si no estamos esperando la respuesta de un POST
|
||||
// para evitar saltos visuales
|
||||
if (!isLoading) {
|
||||
setMessages(flattened.reverse())
|
||||
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',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [historyMessages, activeChatId, isLoadingHistory, isLoading])
|
||||
}
|
||||
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(() => {
|
||||
// Si no hay un chat seleccionado manualmente y la API nos devuelve chats existentes
|
||||
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
|
||||
if (
|
||||
!activeChatId &&
|
||||
lastConversation &&
|
||||
lastConversation.length > 0 &&
|
||||
!isCreationMode
|
||||
) {
|
||||
setActiveChatId(lastConversation[0].id)
|
||||
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)
|
||||
}
|
||||
}, [lastConversation, activeChatId])
|
||||
}, [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(() => {
|
||||
const state = routerState.location.state as any
|
||||
if (!state?.campo_edit || availableFields.length === 0) return
|
||||
const field = availableFields.find(
|
||||
(f) =>
|
||||
f.value === state.campo_edit.label || f.key === state.campo_edit.clave,
|
||||
)
|
||||
if (!field) return
|
||||
setSelectedFields([field])
|
||||
setInput((prev) =>
|
||||
injectFieldsIntoInput(prev || 'Mejora este campo:', [field]),
|
||||
)
|
||||
}, [availableFields])
|
||||
|
||||
const createNewChat = () => {
|
||||
setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo
|
||||
@@ -202,6 +307,9 @@ function RouteComponent() {
|
||||
if (activeChatId === id) {
|
||||
setActiveChatId(undefined)
|
||||
setMessages([])
|
||||
setOptimisticMessage(null)
|
||||
setInput('')
|
||||
setSelectedFields([])
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -214,8 +322,6 @@ function RouteComponent() {
|
||||
{ id, estado: 'ACTIVA' },
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Al invalidar la query, React Query traerá la lista fresca
|
||||
// y el chat se moverá solo de "archivados" a "activos"
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['conversation-by-plan', planId],
|
||||
})
|
||||
@@ -224,49 +330,28 @@ function RouteComponent() {
|
||||
)
|
||||
}
|
||||
|
||||
// 1. Transformar datos de la API para el menú de selección
|
||||
const availableFields = useMemo(() => {
|
||||
if (!data?.estructuras_plan?.definicion?.properties) return []
|
||||
return Object.entries(data.estructuras_plan.definicion.properties).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
label: value.title,
|
||||
value: String(value.description || ''),
|
||||
}),
|
||||
)
|
||||
}, [data])
|
||||
|
||||
// 2. Manejar el estado inicial si viene de "Datos Generales"
|
||||
useEffect(() => {
|
||||
const state = routerState.location.state as any
|
||||
if (!state?.campo_edit || availableFields.length === 0) return
|
||||
|
||||
const field = availableFields.find(
|
||||
(f) =>
|
||||
f.value === state.campo_edit.label || f.key === state.campo_edit.clave,
|
||||
)
|
||||
|
||||
if (!field) return
|
||||
|
||||
setSelectedFields([field])
|
||||
setInput((prev) =>
|
||||
injectFieldsIntoInput(prev || 'Mejora este campo:', [field]),
|
||||
)
|
||||
}, [availableFields])
|
||||
|
||||
// 3. Lógica para el disparador ":"
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value
|
||||
const cursorPosition = e.target.selectionStart // Dónde está escribiendo el usuario
|
||||
setInput(val)
|
||||
// Solo abrir si termina en ":"
|
||||
setShowSuggestions(val.endsWith(':'))
|
||||
|
||||
// Busca un ":" seguido de letras justo antes del cursor
|
||||
const textBeforeCursor = val.slice(0, cursorPosition)
|
||||
const match = textBeforeCursor.match(/:(\w*)$/)
|
||||
|
||||
if (match) {
|
||||
setShowSuggestions(true)
|
||||
setFilterQuery(match[1]) // Esto es lo que se usa para el filtrado
|
||||
} else {
|
||||
setShowSuggestions(false)
|
||||
setFilterQuery('')
|
||||
}
|
||||
}
|
||||
|
||||
const injectFieldsIntoInput = (
|
||||
input: string,
|
||||
fields: Array<SelectedField>,
|
||||
) => {
|
||||
// Quita cualquier bloque previo de campos
|
||||
const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim()
|
||||
|
||||
if (fields.length === 0) return cleaned
|
||||
@@ -277,60 +362,42 @@ function RouteComponent() {
|
||||
}
|
||||
|
||||
const toggleField = (field: SelectedField) => {
|
||||
let isAdding = false
|
||||
|
||||
// 1. Lo agregamos a la lista de "SelectedFields" (para que la IA sepa qué procesar)
|
||||
setSelectedFields((prev) => {
|
||||
const isSelected = prev.find((f) => f.key === field.key)
|
||||
if (isSelected) {
|
||||
return prev.filter((f) => f.key !== field.key)
|
||||
} else {
|
||||
isAdding = true
|
||||
return [...prev, field]
|
||||
}
|
||||
return isSelected ? prev : [...prev, field]
|
||||
})
|
||||
|
||||
// 2. Insertamos el nombre del campo en el texto exactamente donde estaba el ":"
|
||||
setInput((prev) => {
|
||||
// 1. Eliminamos TODOS los ":" que existan en el texto actual
|
||||
// 2. Quitamos espacios en blanco extra al final
|
||||
const cleanPrev = prev.replace(/:/g, '').trim()
|
||||
|
||||
// 3. Si el input resultante está vacío, solo ponemos la frase
|
||||
if (cleanPrev === '') {
|
||||
return `${field.label} `
|
||||
}
|
||||
|
||||
// 4. Si ya había algo, lo concatenamos con un espacio
|
||||
// Usamos un espacio simple al final para que el usuario pueda seguir escribiendo
|
||||
return `${cleanPrev} ${field.label} `
|
||||
// Reemplaza el último ":" y cualquier texto de filtro por el label del campo
|
||||
const nuevoTexto = prev.replace(/:(\w*)$/, field.label)
|
||||
return nuevoTexto + ' ' // Añadimos un espacio para que el usuario siga escribiendo
|
||||
})
|
||||
|
||||
// 3. Limpiamos estados de búsqueda
|
||||
setShowSuggestions(false)
|
||||
setFilterQuery('')
|
||||
}
|
||||
|
||||
const buildPrompt = (userInput: string, fields: Array<SelectedField>) => {
|
||||
// Si no hay campos, enviamos el texto tal cual
|
||||
if (fields.length === 0) return userInput
|
||||
|
||||
return `Instrucción del usuario: ${userInput}`
|
||||
return ` ${userInput}`
|
||||
}
|
||||
|
||||
const handleSend = async (promptOverride?: string) => {
|
||||
const rawText = promptOverride || input
|
||||
if (!rawText.trim() && selectedFields.length === 0) return
|
||||
|
||||
if (isSending || (!rawText.trim() && selectedFields.length === 0)) return
|
||||
const currentFields = [...selectedFields]
|
||||
const finalPrompt = buildPrompt(rawText, currentFields)
|
||||
|
||||
const userMsg = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: rawText,
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, userMsg])
|
||||
setIsSending(true)
|
||||
setOptimisticMessage(rawText)
|
||||
setInput('')
|
||||
// setSelectedFields([])
|
||||
|
||||
setSelectedArchivoIds([])
|
||||
setSelectedRepositorioIds([])
|
||||
setUploadedFiles([])
|
||||
try {
|
||||
const payload: any = {
|
||||
planId: planId,
|
||||
@@ -346,58 +413,19 @@ function RouteComponent() {
|
||||
|
||||
if (response.conversacionId && response.conversacionId !== activeChatId) {
|
||||
setActiveChatId(response.conversacionId)
|
||||
}
|
||||
|
||||
// Esto obliga a 'useConversationByPlan' a buscar en la DB el nuevo chat creado
|
||||
queryClient.invalidateQueries({
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['conversation-by-plan', planId],
|
||||
})
|
||||
}
|
||||
|
||||
// --- NUEVA LÓGICA DE PARSEO ---
|
||||
let aiText = 'Sin respuesta del asistente'
|
||||
let suggestions: Array<any> = []
|
||||
|
||||
if (response.raw) {
|
||||
try {
|
||||
const rawData = JSON.parse(response.raw)
|
||||
|
||||
// Extraemos el mensaje conversacional
|
||||
aiText = rawData['ai-message'] || 'Cambios aplicados con éxito.'
|
||||
|
||||
// Filtramos todo lo que no sea el mensaje para crear las sugerencias
|
||||
suggestions = Object.entries(rawData)
|
||||
.filter(([key]) => key !== 'ai-message')
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: key.replace(/_/g, ' '),
|
||||
newValue: value as string,
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('Error parseando el campo raw:', e)
|
||||
aiText = response.raw // Fallback si no es JSON
|
||||
}
|
||||
}
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: aiText,
|
||||
type: suggestions.length > 0 ? 'improvement-card' : 'text',
|
||||
suggestions: suggestions,
|
||||
},
|
||||
])
|
||||
setOptimisticMessage(null)
|
||||
} catch (error) {
|
||||
console.error('Error en el chat:', error)
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: 'error',
|
||||
role: 'assistant',
|
||||
content: 'Lo siento, hubo un error al procesar tu solicitud.',
|
||||
},
|
||||
])
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,15 +437,9 @@ function RouteComponent() {
|
||||
)
|
||||
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
|
||||
|
||||
const { activeChats, archivedChats } = useMemo(() => {
|
||||
const allChats = lastConversation || []
|
||||
return {
|
||||
activeChats: allChats.filter((chat: any) => chat.estado === 'ACTIVA'),
|
||||
archivedChats: allChats.filter(
|
||||
(chat: any) => chat.estado === 'ARCHIVADA',
|
||||
),
|
||||
const removeSelectedField = (fieldKey: string) => {
|
||||
setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
|
||||
}
|
||||
}, [lastConversation])
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
|
||||
@@ -454,7 +476,6 @@ function RouteComponent() {
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-1">
|
||||
{!showArchived ? (
|
||||
// --- LISTA DE CHATS ACTIVOS ---
|
||||
activeChats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
@@ -466,21 +487,77 @@ function RouteComponent() {
|
||||
}`}
|
||||
>
|
||||
<FileText size={16} className="shrink-0 opacity-40" />
|
||||
{/* Usamos el primer mensaje o un título por defecto */}
|
||||
<span className="truncate pr-8">
|
||||
{chat.title || `Chat ${chat.creado_en.split('T')[0]}`}
|
||||
|
||||
<span
|
||||
ref={editingChatId === chat.id ? editableRef : null}
|
||||
contentEditable={editingChatId === chat.id}
|
||||
suppressContentEditableWarning={true}
|
||||
className={`truncate pr-14 transition-all outline-none ${
|
||||
editingChatId === chat.id
|
||||
? 'min-w-[50px] cursor-text rounded bg-white px-1 ring-1 ring-teal-500'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingChatId(chat.id)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const newTitle = e.currentTarget.textContent || ''
|
||||
updateTitleMutation(
|
||||
{ id: chat.id, nombre: newTitle },
|
||||
{
|
||||
onSuccess: () => setEditingChatId(null),
|
||||
},
|
||||
)
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setEditingChatId(null)
|
||||
|
||||
e.currentTarget.textContent = chat.nombre || ''
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (editingChatId === chat.id) {
|
||||
const newTitle = e.currentTarget.textContent || ''
|
||||
if (newTitle !== chat.nombre) {
|
||||
updateTitleMutation({ id: chat.id, nombre: newTitle })
|
||||
}
|
||||
setEditingChatId(null)
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (editingChatId === chat.id) e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
{chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`}
|
||||
</span>
|
||||
|
||||
{/* ACCIONES */}
|
||||
<div className="absolute right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingChatId(chat.id)
|
||||
// Pequeño timeout para asegurar que el DOM se actualice antes de enfocar
|
||||
setTimeout(() => editableRef.current?.focus(), 50)
|
||||
}}
|
||||
className="p-1 text-slate-400 hover:text-teal-600"
|
||||
>
|
||||
<Send size={12} className="rotate-45" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => archiveChat(e, chat.id)}
|
||||
className="absolute right-2 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-amber-600"
|
||||
title="Archivar"
|
||||
className="p-1 text-slate-400 hover:text-amber-600"
|
||||
>
|
||||
<Archive size={14} />
|
||||
</button>
|
||||
</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">
|
||||
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
|
||||
Archivados
|
||||
@@ -492,25 +569,17 @@ function RouteComponent() {
|
||||
>
|
||||
<Archive size={14} className="shrink-0 opacity-30" />
|
||||
<span className="truncate pr-8">
|
||||
{chat.title ||
|
||||
{chat.nombre ||
|
||||
`Archivado ${chat.creado_en.split('T')[0]}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => unarchiveChat(e, chat.id)}
|
||||
className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600"
|
||||
title="Desarchivar"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{archivedChats.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>
|
||||
@@ -543,46 +612,98 @@ function RouteComponent() {
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<ScrollArea ref={scrollRef} className="h-full w-full">
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
{messages.map((msg) => (
|
||||
{!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
|
||||
key={msg.id}
|
||||
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'ml-auto items-end' : 'items-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm ${
|
||||
className={`flex max-w-[85%] flex-col ${
|
||||
msg.role === 'user'
|
||||
? 'rounded-tr-none bg-teal-600 text-white'
|
||||
: 'rounded-tl-none border bg-white text-slate-700'
|
||||
? 'ml-auto items-end'
|
||||
: 'items-start'
|
||||
}`}
|
||||
>
|
||||
{/* Contenido de texto normal */}
|
||||
<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}
|
||||
|
||||
{/* Si el mensaje tiene sugerencias (ImprovementCard) */}
|
||||
{msg.suggestions && msg.suggestions.length > 0 && (
|
||||
{!msg.isRefusal &&
|
||||
msg.suggestions &&
|
||||
msg.suggestions.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<ImprovementCard
|
||||
suggestions={msg.suggestions}
|
||||
planId={planId} // Del useParams()
|
||||
currentDatos={data?.datos} // De tu query usePlan(planId)
|
||||
onApply={(key, val) => {
|
||||
// Esto es opcional, si quieres hacer algo más en la UI del chat
|
||||
console.log(
|
||||
'Evento onApply disparado desde el chat',
|
||||
)
|
||||
}}
|
||||
planId={planId}
|
||||
currentDatos={data?.datos}
|
||||
activeChatId={activeChatId}
|
||||
onApplySuccess={(key) =>
|
||||
removeSelectedField(key)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex gap-2 p-4">
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.2s]" />
|
||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.4s]" />
|
||||
|
||||
{optimisticMessage && (
|
||||
<div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end">
|
||||
<div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm">
|
||||
{optimisticMessage}
|
||||
</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>
|
||||
</ScrollArea>
|
||||
@@ -613,25 +734,35 @@ function RouteComponent() {
|
||||
<div className="relative mx-auto max-w-4xl">
|
||||
{/* MENÚ DE SUGERENCIAS FLOTANTE */}
|
||||
{showSuggestions && (
|
||||
<div className="animate-in slide-in-from-bottom-2 absolute bottom-full z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
|
||||
<div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold tracking-wider text-slate-500 uppercase">
|
||||
Seleccionar campo para IA
|
||||
<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 text-slate-500 uppercase">
|
||||
Resultados para "{filterQuery}"
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto p-1">
|
||||
{availableFields.map((field) => (
|
||||
{filteredFields.length > 0 ? (
|
||||
filteredFields.map((field, index) => (
|
||||
<button
|
||||
key={field.key}
|
||||
onClick={() => toggleField(field)}
|
||||
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50"
|
||||
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">
|
||||
{field.label}
|
||||
<span>{field.label}</span>
|
||||
{index === 0 && (
|
||||
<span className="font-mono text-[10px] opacity-50">
|
||||
TAB
|
||||
</span>
|
||||
{selectedFields.find((f) => f.key === field.key) && (
|
||||
<Check size={14} className="text-teal-600" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<div className="p-3 text-center text-xs text-slate-400">
|
||||
No hay coincidencias
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -663,6 +794,35 @@ function RouteComponent() {
|
||||
<Textarea
|
||||
value={input}
|
||||
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={
|
||||
selectedFields.length > 0
|
||||
? 'Escribe instrucciones adicionales...'
|
||||
@@ -673,12 +833,16 @@ function RouteComponent() {
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={
|
||||
(!input.trim() && selectedFields.length === 0) || isLoading
|
||||
isSending || (!input.trim() && selectedFields.length === 0)
|
||||
}
|
||||
size="icon"
|
||||
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>
|
||||
</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
|
||||
| number
|
||||
| boolean
|
||||
@@ -460,6 +460,7 @@ export type Database = {
|
||||
estado: Database['public']['Enums']['estado_conversacion']
|
||||
id: string
|
||||
intento_archivado: number
|
||||
nombre: string | null
|
||||
openai_conversation_id: string
|
||||
plan_estudio_id: string
|
||||
}
|
||||
@@ -472,6 +473,7 @@ export type Database = {
|
||||
estado?: Database['public']['Enums']['estado_conversacion']
|
||||
id?: string
|
||||
intento_archivado?: number
|
||||
nombre?: string | null
|
||||
openai_conversation_id: string
|
||||
plan_estudio_id: string
|
||||
}
|
||||
@@ -484,6 +486,7 @@ export type Database = {
|
||||
estado?: Database['public']['Enums']['estado_conversacion']
|
||||
id?: string
|
||||
intento_archivado?: number
|
||||
nombre?: string | null
|
||||
openai_conversation_id?: string
|
||||
plan_estudio_id?: string
|
||||
}
|
||||
@@ -1206,11 +1209,24 @@ export type Database = {
|
||||
}
|
||||
}
|
||||
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_immutable: { Args: { '': string }; Returns: string }
|
||||
}
|
||||
Enums: {
|
||||
estado_asignatura: 'borrador' | 'revisada' | 'aprobada' | 'generando'
|
||||
estado_asignatura:
|
||||
| 'borrador'
|
||||
| 'revisada'
|
||||
| 'aprobada'
|
||||
| 'generando'
|
||||
| 'fallida'
|
||||
estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR'
|
||||
estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA'
|
||||
fuente_cambio: 'HUMANO' | 'IA'
|
||||
@@ -1384,7 +1400,13 @@ export const Constants = {
|
||||
},
|
||||
public: {
|
||||
Enums: {
|
||||
estado_asignatura: ['borrador', 'revisada', 'aprobada', 'generando'],
|
||||
estado_asignatura: [
|
||||
'borrador',
|
||||
'revisada',
|
||||
'aprobada',
|
||||
'generando',
|
||||
'fallida',
|
||||
],
|
||||
estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'],
|
||||
estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'],
|
||||
fuente_cambio: ['HUMANO', 'IA'],
|
||||
|
||||
Reference in New Issue
Block a user