1 Commits

Author SHA1 Message Date
Guillermo.Arrieta 7e1045358d Login de usuarios wip
Lo que ya sirve:
- Ya se puede hacer login con email y contraseña
- Se puede hacer logout con un botón en el header
- La página te redirige a login si no hay sesion
- La página te redirige a dashboard desde login si hay sesión

Lo que falta:
- Comprobar si se atrapan y manejan correctamente los errores por violación a RLS
- Cambiar la BDD para asignar roles y permisos a usuarios
- Comprobar si de manera defensiva se reestablecen los roles/permisos cuando el usuario intenta hacer algo que no está permitido
2026-03-04 12:16:48 -06:00
70 changed files with 1972 additions and 16074 deletions
-37
View File
@@ -1,37 +0,0 @@
name: Deploy to Azure Static Web Apps
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Build
env:
VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ vars.VITE_SUPABASE_ANON_KEY }}
run: bunx --bun vite build
# No hace falta instalar el CLI globalmente, usamos bunx
- name: Deploy to Azure Static Web Apps
env:
AZURE_SWA_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOYMENT_TOKEN }}
run: |
bunx @azure/static-web-apps-cli deploy ./dist \
--env production \
--deployment-token "$AZURE_SWA_DEPLOYMENT_TOKEN"
+1 -1
View File
@@ -1 +1 @@
Ignora los problemas de imports de eslint Al funcionar como agente, ignora los problemas de eslint del orden de imports
+7 -25
View File
@@ -4,7 +4,6 @@
"": { "": {
"name": "acad-ia-2", "name": "acad-ia-2",
"dependencies": { "dependencies": {
"@dnd-kit/react": "^0.3.2",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -21,7 +20,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@stepperize/react": "^5.1.9", "@stepperize/react": "^5.1.9",
"@supabase/supabase-js": "^2.90.1", "@supabase/supabase-js": "^2.98.0",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0", "@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5", "@tanstack/react-query": "^5.66.5",
@@ -31,7 +30,6 @@
"@tanstack/router-plugin": "^1.132.0", "@tanstack/router-plugin": "^1.132.0",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"citeproc": "^2.4.63",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -139,18 +137,6 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.3.2", "", { "dependencies": { "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-uvPVK+SZYD6Viddn9M0K0JQdXknuVSxA/EbMlFRanve3P/XTc18oLa5zGftKSGjfQGmuzkZ34E26DSbly1zi3Q=="],
"@dnd-kit/collision": ["@dnd-kit/collision@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-pNmNSLCI8S9fNQ7QJ3fBCDjiT0sqBhUFcKgmyYaGvGCAU+kq0AP8OWlh0JSisc9k5mFyxmRpmFQcnJpILz/RPA=="],
"@dnd-kit/dom": ["@dnd-kit/dom@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/collision": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-cIUAVgt2szQyz6JRy7I+0r+xeyOAGH21Y15hb5bIyHoDEaZBvIDH+OOlD9eoLjCbsxDLN9WloU2CBi3OE6LYDg=="],
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.3.2", "", { "dependencies": { "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-3UBPuIS7E3oGiHxOE8h810QA+0pnrnCtGxl4Os1z3yy5YkC/BEYGY+TxWPTQaY1/OMV7GCX7ZNMlama2QN3n3w=="],
"@dnd-kit/react": ["@dnd-kit/react@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/dom": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-1Opg1xw6I75Z95c+rF2NJa0pdGb8rLAENtuopKtJ1J0PudWlz+P6yL137xy/6DV43uaRmNGtsdbMbR0yRYJ72g=="],
"@dnd-kit/state": ["@dnd-kit/state@0.3.2", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-dLUIkoYrIJhGXfF2wGLTfb46vUokEsO/OoE21TSfmahYrx7ysTmnwbePsznFaHlwgZhQEh6AlLvthLCeY21b1A=="],
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -263,8 +249,6 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@preact/signals-core": ["@preact/signals-core@1.14.0", "", {}, "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
@@ -457,17 +441,17 @@
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.7.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.53.1", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-zjTUwIsEfT+k9BmXwq1QEFYsb4afBlsI1AXFyWQBgggMzwBFOuu92pGrE5OFx90IOjNl+lUbQoTG7f8S0PkOdg=="], "@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.7.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.53.1", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-zjTUwIsEfT+k9BmXwq1QEFYsb4afBlsI1AXFyWQBgggMzwBFOuu92pGrE5OFx90IOjNl+lUbQoTG7f8S0PkOdg=="],
"@supabase/auth-js": ["@supabase/auth-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-pC0Ek4xk4z6q7A/3+UuZ/eYgfFUUQTg3DhapzrAgJnFGDJDFDyGCj6v9nIz8+3jfLqSZ3QKGe6AoEodYjShghg=="], "@supabase/auth-js": ["@supabase/auth-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg=="],
"@supabase/functions-js": ["@supabase/functions-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Ott2IcIXHGupaC0nX9WNEiJAX4OdlGRu9upkkURaQHbaLdz9JuCcHxlwTERgtgjMpikbIWHfMM1M9QTQFYABiA=="], "@supabase/functions-js": ["@supabase/functions-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.93.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-uRKKQJBDnfi6XFNFPNMh9+u3HT2PCgp065PcMPmG7e0xGuqvLtN89QxO2/SZcGbw2y1+mNBz0yUs5KmyNqF2fA=="], "@supabase/postgrest-js": ["@supabase/postgrest-js@2.98.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg=="],
"@supabase/realtime-js": ["@supabase/realtime-js@2.93.1", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-2WaP/KVHPlQDjWM6qe4wOZz6zSRGaXw1lfXf4thbfvk3C3zPPKqXRyspyYnk3IhphyxSsJ2hQ/cXNOz48008tg=="], "@supabase/realtime-js": ["@supabase/realtime-js@2.98.0", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw=="],
"@supabase/storage-js": ["@supabase/storage-js@2.93.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-3KVwd4S1i1BVPL6KIywe5rnruNQXSkLyvrdiJmwnqwbCcDujQumARdGWBPesqCjOPKEU2M9ORWKAsn+2iLzquA=="], "@supabase/storage-js": ["@supabase/storage-js@2.98.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ=="],
"@supabase/supabase-js": ["@supabase/supabase-js@2.93.1", "", { "dependencies": { "@supabase/auth-js": "2.93.1", "@supabase/functions-js": "2.93.1", "@supabase/postgrest-js": "2.93.1", "@supabase/realtime-js": "2.93.1", "@supabase/storage-js": "2.93.1" } }, "sha512-FJTgS5s0xEgRQ3u7gMuzGObwf3jA4O5Ki/DgCDXx94w1pihLM4/WG3XFa4BaCJYfuzLxLcv6zPPA5tDvBUjAUg=="], "@supabase/supabase-js": ["@supabase/supabase-js@2.98.0", "", { "dependencies": { "@supabase/auth-js": "2.98.0", "@supabase/functions-js": "2.98.0", "@supabase/postgrest-js": "2.98.0", "@supabase/realtime-js": "2.98.0", "@supabase/storage-js": "2.98.0" } }, "sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
@@ -751,8 +735,6 @@
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"citeproc": ["citeproc@2.4.63", "", {}, "sha512-68F95Bp4UbgZU/DBUGQn0qV3HDZLCdI9+Bb2ByrTaNJDL5VEm9LqaiNaxljsvoaExSLEXe1/r6n2Z06SCzW3/Q=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+3 -2
View File
@@ -10,6 +10,7 @@
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"iconLibrary": "lucide",
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
@@ -17,11 +18,11 @@
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"iconLibrary": "lucide",
"registries": { "registries": {
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json", "@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
"@ss-components": "https://shadcnstudio.com/r/components/{name}.json", "@ss-components": "https://shadcnstudio.com/r/components/{name}.json",
"@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json", "@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json",
"@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json" "@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json",
"@supabase": "https://supabase.com/ui/r/{name}.json"
} }
} }
+1 -3
View File
@@ -17,7 +17,6 @@
"ci:verify": "prettier --check . && eslint . && tsc --noEmit" "ci:verify": "prettier --check . && eslint . && tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/react": "^0.3.2",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -34,7 +33,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@stepperize/react": "^5.1.9", "@stepperize/react": "^5.1.9",
"@supabase/supabase-js": "^2.90.1", "@supabase/supabase-js": "^2.98.0",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
"@tanstack/react-devtools": "^0.7.0", "@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-query": "^5.66.5", "@tanstack/react-query": "^5.66.5",
@@ -44,7 +43,6 @@
"@tanstack/router-plugin": "^1.132.0", "@tanstack/router-plugin": "^1.132.0",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"citeproc": "^2.4.63",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
-757
View File
@@ -1,757 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<locale xmlns="http://purl.org/net/xbiblio/csl" version="1.0" xml:lang="es-MX">
<info>
<translator>
<name>Juan Ignacio Flores Salgado</name>
<uri>https://www.mendeley.com/profiles/juan-ignacio-flores-salgado/</uri>
</translator>
<rights license="http://creativecommons.org/licenses/by-sa/3.0/">This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 License</rights>
<updated>2025-10-16T03:24:00+00:00</updated>
</info>
<style-options punctuation-in-quote="false"/>
<date form="text">
<date-part name="day" prefix="el " suffix=" de "/>
<date-part name="month" suffix=" de "/>
<date-part name="year"/>
</date>
<date form="numeric">
<date-part name="day" form="numeric-leading-zeros" suffix="/"/>
<date-part name="month" form="numeric-leading-zeros" suffix="/"/>
<date-part name="year"/>
</date>
<terms>
<!-- LONG GENERAL TERMS -->
<term name="accessed">consultado</term>
<term name="advance-online-publication">advance online publication</term>
<term name="album">album</term>
<term name="and">y</term>
<term name="and others">et&#160;al.</term>
<term name="anonymous">anónimo</term>
<term name="at">en</term>
<term name="audio-recording">audio recording</term>
<term name="available at">disponible en</term>
<term name="by">de</term>
<term name="circa">circa</term>
<term name="cited">citado</term>
<term name="et-al">et&#160;al.</term>
<term name="film">film</term>
<term name="forthcoming">en preparación</term>
<term name="from">a partir de</term>
<term name="henceforth">henceforth</term>
<term name="ibid">ibid.</term>
<term name="in">en</term>
<term name="in press">en imprenta</term>
<term name="internet">internet</term>
<term name="letter">carta</term>
<term name="loc-cit">loc. cit.</term> <!-- like ibid., the abbreviated form is the regular form -->
<term name="no date">sin fecha</term>
<term name="no-place">no place</term>
<term name="no-publisher">no publisher</term> <!-- sine nomine -->
<term name="on">on</term>
<term name="online">en línea</term>
<term name="op-cit">op. cit.</term> <!-- like ibid., the abbreviated form is the regular form -->
<term name="original-work-published">obra original publicada en</term>
<term name="personal-communication">comunicación personal</term>
<term name="podcast">podcast</term>
<term name="podcast-episode">podcast episode</term>
<term name="preprint">preprint</term>
<term name="presented at">presentado en</term>
<term name="radio-broadcast">radio broadcast</term>
<term name="radio-series">radio series</term>
<term name="radio-series-episode">radio series episode</term>
<term name="reference">
<single>referencia</single>
<multiple>referencias</multiple>
</term>
<term name="retrieved">recuperado</term>
<term name="review-of">review of</term>
<term name="scale">escala</term>
<term name="special-issue">special issue</term>
<term name="special-section">special section</term>
<term name="television-broadcast">television broadcast</term>
<term name="television-series">television series</term>
<term name="television-series-episode">television series episode</term>
<term name="video">video</term>
<term name="working-paper">working paper</term>
<!-- SHORT GENERAL TERMS -->
<term name="anonymous" form="short">anón.</term>
<term name="circa" form="short">c.</term>
<term name="no date" form="short">s/f</term>
<term name="no-place" form="short">n.p.</term>
<term name="no-publisher" form="short">n.p.</term>
<term name="reference" form="short">
<single>ref.</single>
<multiple>refs.</multiple>
</term>
<term name="review-of" form="short">rev. of</term>
<!-- SYMBOLIC GENERAL FORMS -->
<!-- LONG ITEM TYPE FORMS -->
<term name="article">preprint</term>
<term name="article-journal">journal article</term>
<term name="article-magazine">magazine article</term>
<term name="article-newspaper">newspaper article</term>
<term name="bill">bill</term>
<!-- book is in the list of locator terms -->
<term name="broadcast">broadcast</term>
<!-- chapter is in the list of locator terms -->
<term name="classic">classic</term>
<term name="collection">collection</term>
<term name="dataset">dataset</term>
<term name="document">document</term>
<term name="entry">entry</term>
<term name="entry-dictionary">dictionary entry</term>
<term name="entry-encyclopedia">encyclopedia entry</term>
<term name="event">event</term>
<!-- figure is in the list of locator terms -->
<term name="graphic">graphic</term>
<term name="hearing">hearing</term>
<term name="interview">entrevista</term>
<term name="legal_case">legal case</term>
<term name="legislation">legislation</term>
<term name="manuscript">manuscript</term>
<term name="map">map</term>
<term name="motion_picture">video recording</term>
<term name="musical_score">musical score</term>
<term name="pamphlet">pamphlet</term>
<term name="paper-conference">conference paper</term>
<term name="patent">patent</term>
<term name="performance">performance</term>
<term name="periodical">periodical</term>
<term name="personal_communication">comunicación personal</term>
<term name="post">post</term>
<term name="post-weblog">blog post</term>
<term name="regulation">regulation</term>
<term name="report">report</term>
<term name="review">review</term>
<term name="review-book">book review</term>
<term name="software">software</term>
<term name="song">audio recording</term>
<term name="speech">presentation</term>
<term name="standard">standard</term>
<term name="thesis">thesis</term>
<term name="treaty">treaty</term>
<term name="webpage">webpage</term>
<!-- SHORT ITEM TYPE FORMS -->
<term name="article-journal" form="short">journal art.</term>
<term name="article-magazine" form="short">mag. art.</term>
<term name="article-newspaper" form="short">newspaper art.</term>
<!-- book is in the list of locator terms -->
<!-- chapter is in the list of locator terms -->
<term name="document" form="short">doc.</term>
<!-- figure is in the list of locator terms -->
<term name="graphic" form="short">graph.</term>
<term name="interview" form="short">interv.</term>
<term name="manuscript" form="short">MS</term>
<term name="motion_picture" form="short">video rec.</term>
<term name="report" form="short">rep.</term>
<term name="review" form="short">rev.</term>
<term name="review-book" form="short">bk. rev.</term>
<term name="song" form="short">audio rec.</term>
<!-- LONG VERB ITEM TYPE FORMS -->
<!-- Only where applicable -->
<term name="hearing" form="verb">testimony of</term>
<term name="review" form="verb">review of</term>
<term name="review-book" form="verb">review of the book</term>
<!-- SHORT VERB ITEM TYPE FORMS -->
<!-- HISTORICAL ERA TERMS -->
<term name="ad">d.&#160;C.</term>
<term name="bc">a.&#160;C.</term>
<term name="bce">BCE</term>
<term name="ce">CE</term>
<!-- PUNCTUATION -->
<term name="open-quote"></term>
<term name="close-quote"></term>
<term name="open-inner-quote"></term>
<term name="close-inner-quote"></term>
<term name="page-range-delimiter"></term>
<term name="colon">:</term>
<term name="comma">,</term>
<term name="semicolon">;</term>
<!-- ORDINALS -->
<term name="ordinal">a</term>
<term name="ordinal-01" gender-form="feminine" match="whole-number">a</term>
<term name="ordinal-01" gender-form="masculine" match="whole-number">o</term>
<!-- LONG ORDINALS -->
<term name="long-ordinal-01">primera</term>
<term name="long-ordinal-02">segunda</term>
<term name="long-ordinal-03">tercera</term>
<term name="long-ordinal-04">cuarta</term>
<term name="long-ordinal-05">quinta</term>
<term name="long-ordinal-06">sexta</term>
<term name="long-ordinal-07">séptima</term>
<term name="long-ordinal-08">octava</term>
<term name="long-ordinal-09">novena</term>
<term name="long-ordinal-10">décima</term>
<!-- LONG LOCATOR FORMS -->
<term name="act">
<single>act</single>
<multiple>acts</multiple>
</term>
<term name="appendix">
<single>appendix</single>
<multiple>appendices</multiple>
</term>
<term name="article-locator">
<single>article</single>
<multiple>articles</multiple>
</term>
<term name="book">
<single>libro</single>
<multiple>libros</multiple>
</term>
<term name="canon">
<single>canon</single>
<multiple>canons</multiple>
</term>
<term name="chapter">
<single>capítulo</single>
<multiple>capítulos</multiple>
</term>
<term name="column">
<single>columna</single>
<multiple>columnas</multiple>
</term>
<term name="elocation">
<single>location</single>
<multiple>locations</multiple>
</term>
<term name="equation">
<single>equation</single>
<multiple>equations</multiple>
</term>
<term name="figure">
<single>figura</single>
<multiple>figuras</multiple>
</term>
<term name="folio">
<single>folio</single>
<multiple>folios</multiple>
</term>
<term name="issue">
<single>número</single>
<multiple>números</multiple>
</term>
<term name="line">
<single>línea</single>
<multiple>líneas</multiple>
</term>
<term name="note">
<single>nota</single>
<multiple>notas</multiple>
</term>
<term name="opus">
<single>opus</single>
<multiple>opera</multiple>
</term>
<term name="page">
<single>página</single>
<multiple>páginas</multiple>
</term>
<term name="paragraph">
<single>párrafo</single>
<multiple>párrafos</multiple>
</term>
<term name="part">
<single>parte</single>
<multiple>partes</multiple>
</term>
<term name="rule">
<single>rule</single>
<multiple>rules</multiple>
</term>
<term name="scene">
<single>scene</single>
<multiple>scenes</multiple>
</term>
<term name="section">
<single>sección</single>
<multiple>secciones</multiple>
</term>
<term name="sub-verbo">
<single>sub voce</single>
<multiple>sub vocibus</multiple>
</term>
<term name="supplement">
<single>supplement</single>
<multiple>supplements</multiple>
</term>
<term name="table">
<single>table</single>
<multiple>tables</multiple>
</term>
<term name="timestamp"> <!-- generally blank -->
<single/>
<multiple/>
</term>
<term name="title-locator">
<single>title</single>
<multiple>titles</multiple>
</term>
<term name="verse">
<single>verso</single>
<multiple>versos</multiple>
</term>
<term name="volume">
<single>volumen</single>
<multiple>volúmenes</multiple>
</term>
<!-- SHORT LOCATOR FORMS -->
<term name="appendix" form="short">
<single>app.</single>
<multiple>apps.</multiple>
</term>
<term name="article-locator" form="short">
<single>art.</single>
<multiple>arts.</multiple>
</term>
<term name="book" form="short">
<single>lib.</single>
<multiple>libs.</multiple>
</term>
<term name="chapter" form="short">
<single>cap.</single>
<multiple>caps.</multiple>
</term>
<term name="column" form="short">
<single>col.</single>
<multiple>cols.</multiple>
</term>
<term name="elocation" form="short">
<single>loc.</single>
<multiple>locs.</multiple>
</term>
<term name="equation" form="short">
<single>eq.</single>
<multiple>eqs.</multiple>
</term>
<term name="figure" form="short">
<single>fig.</single>
<multiple>figs.</multiple>
</term>
<term name="folio" form="short">
<single>f.</single>
<multiple>ff.</multiple>
</term>
<term name="issue" form="short">
<single>núm.</single>
<multiple>núms.</multiple>
</term>
<term name="line" form="short">
<single>l.</single>
<multiple>ls.</multiple>
</term>
<term name="note" form="short">
<single>n.</single>
<multiple>nn.</multiple>
</term>
<term name="opus" form="short">
<single>op.</single>
<multiple>opp.</multiple>
</term>
<term name="page" form="short">
<single>p.</single>
<multiple>pp.</multiple>
</term>
<term name="paragraph" form="short">
<single>párr.</single>
<multiple>párrs.</multiple>
</term>
<term name="part" form="short">
<single>pt.</single>
<multiple>pts.</multiple>
</term>
<term name="rule" form="short">
<single>r.</single>
<multiple>rr.</multiple>
</term>
<term name="scene" form="short">
<single>sc.</single>
<multiple>scs.</multiple>
</term>
<term name="section" form="short">
<single>sec.</single>
<multiple>secs.</multiple>
</term>
<term name="sub-verbo" form="short">
<single>s.&#160;v.</single>
<multiple>s.&#160;vv.</multiple>
</term>
<term name="supplement" form="short">
<single>supp.</single>
<multiple>supps.</multiple>
</term>
<term name="table" form="short">
<single>tbl.</single>
<multiple>tbls.</multiple>
</term>
<term name="timestamp" form="short"> <!-- generally blank -->
<single/>
<multiple/>
</term>
<term name="title-locator" form="short">
<single>tit.</single>
<multiple>tits.</multiple>
</term>
<term name="verse" form="short">
<single>v.</single>
<multiple>vv.</multiple>
</term>
<term name="volume" form="short">
<single>vol.</single>
<multiple>vols.</multiple>
</term>
<!-- SYMBOLIC LOCATOR FORMS -->
<term name="paragraph" form="symbol">
<single></single>
<multiple></multiple>
</term>
<term name="section" form="symbol">
<single>§</single>
<multiple>§</multiple>
</term>
<!-- LONG NUMBER VARIABLE FORMS -->
<term name="chapter-number">
<single>chapter</single>
<multiple>chapters</multiple>
</term>
<term name="citation-number">
<single>citation</single>
<multiple>citations</multiple>
</term>
<term name="collection-number">
<single>número</single>
<multiple>números</multiple>
</term>
<term name="edition">
<single>edición</single>
<multiple>ediciones</multiple>
</term>
<term name="first-reference-note-number">
<single>reference</single>
<multiple>references</multiple>
</term>
<term name="number">
<single>number</single>
<multiple>numbers</multiple>
</term>
<term name="number-of-pages">
<single>página</single>
<multiple>páginas</multiple>
</term>
<term name="number-of-volumes">
<single>volume</single>
<multiple>volumes</multiple>
</term>
<term name="page-first">
<single>page</single>
<multiple>pages</multiple>
</term>
<term name="printing">
<single>printing</single>
<multiple>printings</multiple>
</term>
<term name="version">versión</term>
<!-- SHORT NUMBER VARIABLE FORMS -->
<term name="chapter-number" form="short">
<single>chap.</single>
<multiple>chaps.</multiple>
</term>
<term name="citation-number" form="short">
<single>cit.</single>
<multiple>cits.</multiple>
</term>
<term name="collection-number" form="short">
<single>núm.</single>
<multiple>núms.</multiple>
</term>
<term name="edition" form="short">
<single>ed.</single>
<multiple>eds.</multiple>
</term>
<term name="first-reference-note-number" form="short">
<single>ref.</single>
<multiple>refs.</multiple>
</term>
<term name="number" form="short">
<single>no.</single>
<multiple>nos.</multiple>
</term>
<term name="number-of-pages" form="short">
<single>p.</single>
<multiple>pp.</multiple>
</term>
<term name="number-of-volumes" form="short">
<single>vol.</single>
<multiple>vols.</multiple>
</term>
<term name="page-first" form="short">
<single>p.</single>
<multiple>pp.</multiple>
</term>
<term name="printing" form="short">
<single>print.</single>
<multiple>prints.</multiple>
</term>
<!-- LONG ROLE FORMS -->
<term name="author"/> <!-- generally blank -->
<term name="chair">
<single>chair</single>
<multiple>chairs</multiple>
</term>
<term name="collection-editor">
<single>ed.</single>
<multiple>eds.</multiple>
</term>
<term name="compiler">
<single>compiler</single>
<multiple>compilers</multiple>
</term>
<term name="composer"/> <!-- generally blank -->
<term name="container-author"/> <!-- generally blank -->
<term name="contributor">
<single>contributor</single>
<multiple>contributors</multiple>
</term>
<term name="curator">
<single>curator</single>
<multiple>curators</multiple>
</term>
<term name="director">
<single>director</single>
<multiple>directores</multiple>
</term>
<term name="editor">
<single>editor</single>
<multiple>editores</multiple>
</term>
<term name="editor-translator">
<single>editor y traductor</single>
<multiple>editores y traductores</multiple>
</term>
<term name="editortranslator">
<single>editor y traductor</single>
<multiple>editores y traductores</multiple>
</term>
<term name="editorial-director">
<single>coordinador</single>
<multiple>coordinadores</multiple>
</term>
<term name="executive-producer">
<single>executive producer</single>
<multiple>executive producers</multiple>
</term>
<term name="guest">
<single>guest</single>
<multiple>guests</multiple>
</term>
<term name="host">
<single>host</single>
<multiple>hosts</multiple>
</term>
<term name="illustrator">
<single>ilustrador</single>
<multiple>ilustradores</multiple>
</term>
<term name="interviewer"/> <!-- generally blank -->
<term name="narrator">
<single>narrator</single>
<multiple>narrators</multiple>
</term>
<term name="organizer">
<single>organizer</single>
<multiple>organizers</multiple>
</term>
<term name="original-author"/> <!-- generally blank -->
<term name="performer">
<single>performer</single>
<multiple>performers</multiple>
</term>
<term name="producer">
<single>producer</single>
<multiple>producers</multiple>
</term>
<term name="recipient"/> <!-- generally blank -->
<term name="reviewed-author"/> <!-- generally blank -->
<term name="script-writer">
<single>writer</single>
<multiple>writers</multiple>
</term>
<term name="series-creator">
<single>series creator</single>
<multiple>series creators</multiple>
</term>
<term name="translator">
<single>traductor</single>
<multiple>traductores</multiple>
</term>
<!-- SHORT ROLE FORMS -->
<term name="compiler" form="short">
<single>comp.</single>
<multiple>comps.</multiple>
</term>
<term name="contributor" form="short">
<single>contrib.</single>
<multiple>contribs.</multiple>
</term>
<term name="curator" form="short">
<single>cur.</single>
<multiple>curs.</multiple>
</term>
<term name="director" form="short">
<single>dir.</single>
<multiple>dirs.</multiple>
</term>
<term name="editor" form="short">
<single>ed.</single>
<multiple>eds.</multiple>
</term>
<term name="editor-translator" form="short">
<single>ed. y trad.</single>
<multiple>eds. y trads.</multiple>
</term>
<term name="editortranslator" form="short">
<single>ed. y trad.</single>
<multiple>eds. y trads.</multiple>
</term>
<term name="editorial-director" form="short">
<single>coord.</single>
<multiple>coords.</multiple>
</term>
<term name="executive-producer" form="short">
<single>exec. prod.</single>
<multiple>exec. prods.</multiple>
</term>
<term name="illustrator" form="short">
<single>ilust.</single>
<multiple>ilusts.</multiple>
</term>
<term name="narrator" form="short">
<single>narr.</single>
<multiple>narrs.</multiple>
</term>
<term name="organizer" form="short">
<single>org.</single>
<multiple>orgs.</multiple>
</term>
<term name="performer" form="short">
<single>perf.</single>
<multiple>perfs.</multiple>
</term>
<term name="producer" form="short">
<single>prod.</single>
<multiple>prods.</multiple>
</term>
<term name="script-writer" form="short">
<single>writ.</single>
<multiple>writs.</multiple>
</term>
<term name="series-creator" form="short">
<single>cre.</single>
<multiple>cres.</multiple>
</term>
<term name="translator" form="short">
<single>trad.</single>
<multiple>trads.</multiple>
</term>
<!-- VERB ROLE FORMS -->
<term name="chair" form="verb">chaired by</term>
<term name="collection-editor" form="verb">edited by</term>
<term name="compiler" form="verb">compiled by</term>
<term name="container-author" form="verb">de</term>
<term name="contributor" form="verb">with</term>
<term name="curator" form="verb">curated by</term>
<term name="director" form="verb">dirigido por</term>
<term name="editor" form="verb">editado por</term>
<term name="editor-translator" form="verb">editado y traducido por</term>
<term name="editortranslator" form="verb">editado y traducido por</term>
<term name="editorial-director" form="verb">coordinado por</term>
<term name="executive-producer" form="verb">executive produced by</term>
<term name="guest" form="verb">with guest</term>
<term name="host" form="verb">hosted by</term>
<term name="illustrator" form="verb">ilustrado por</term>
<term name="interviewer" form="verb">entrevistado por</term>
<term name="narrator" form="verb">narrated by</term>
<term name="organizer" form="verb">organized by</term>
<term name="performer" form="verb">performed by</term>
<term name="producer" form="verb">produced by</term>
<term name="recipient" form="verb">a</term>
<term name="reviewed-author" form="verb">por</term>
<term name="script-writer" form="verb">written by</term>
<term name="series-creator" form="verb">created by</term>
<term name="translator" form="verb">traducido por</term>
<!-- SHORT VERB ROLE FORMS -->
<term name="collection-editor" form="verb-short">ed. by</term>
<term name="compiler" form="verb-short">comp. by</term>
<term name="contributor" form="verb-short">w.</term>
<term name="curator" form="verb-short">cur. by</term>
<term name="director" form="verb-short">dir.</term>
<term name="editor" form="verb-short">ed.</term>
<term name="editor-translator" form="verb-short">ed. y trad.</term>
<term name="editortranslator" form="verb-short">ed. y trad.</term>
<term name="editorial-director" form="verb-short">coord.</term>
<term name="executive-producer" form="verb-short">exec. prod. by</term>
<term name="guest" form="verb-short">w. guest</term>
<term name="host" form="verb-short">hosted by</term>
<term name="illustrator" form="verb-short">ilust.</term>
<term name="narrator" form="verb-short">narr. by</term>
<term name="organizer" form="verb-short">org. by</term>
<term name="performer" form="verb-short">perf. by</term>
<term name="producer" form="verb-short">prod. by</term>
<term name="script-writer" form="verb-short">writ. by</term>
<term name="series-creator" form="verb-short">cre. by</term>
<term name="translator" form="verb-short">trad.</term>
<!-- LONG MONTH FORMS -->
<term name="month-01">enero</term>
<term name="month-02">febrero</term>
<term name="month-03">marzo</term>
<term name="month-04">abril</term>
<term name="month-05">mayo</term>
<term name="month-06">junio</term>
<term name="month-07">julio</term>
<term name="month-08">agosto</term>
<term name="month-09">septiembre</term>
<term name="month-10">octubre</term>
<term name="month-11">noviembre</term>
<term name="month-12">diciembre</term>
<!-- SHORT MONTH FORMS -->
<term name="month-01" form="short">ene.</term>
<term name="month-02" form="short">feb.</term>
<term name="month-03" form="short">mar.</term>
<term name="month-04" form="short">abr.</term>
<term name="month-05" form="short">may</term>
<term name="month-06" form="short">jun.</term>
<term name="month-07" form="short">jul.</term>
<term name="month-08" form="short">ago.</term>
<term name="month-09" form="short">sep.</term>
<term name="month-10" form="short">oct.</term>
<term name="month-11" form="short">nov.</term>
<term name="month-12" form="short">dic.</term>
<!-- SEASONS -->
<term name="season-01">primavera</term>
<term name="season-02">verano</term>
<term name="season-03">otoño</term>
<term name="season-04">invierno</term>
</terms>
</locale>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-519
View File
@@ -1,519 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<style xmlns="http://purl.org/net/xbiblio/csl" class="in-text" version="1.0" demote-non-dropping-particle="sort-only">
<info>
<title>IEEE Reference Guide version 11.29.2023</title>
<title-short>Institute of Electrical and Electronics Engineers</title-short>
<id>http://www.zotero.org/styles/ieee</id>
<link href="http://www.zotero.org/styles/ieee" rel="self"/>
<link href="https://journals.ieeeauthorcenter.ieee.org/your-role-in-article-production/ieee-editorial-style-manual/" rel="documentation"/>
<author>
<name>Michael Berkowitz</name>
<email>mberkowi@gmu.edu</email>
</author>
<contributor>
<name>Julian Onions</name>
<email>julian.onions@gmail.com</email>
</contributor>
<contributor>
<name>Rintze Zelle</name>
<uri>http://twitter.com/rintzezelle</uri>
</contributor>
<contributor>
<name>Stephen Frank</name>
<uri>http://www.zotero.org/sfrank</uri>
</contributor>
<contributor>
<name>Sebastian Karcher</name>
</contributor>
<contributor>
<name>Giuseppe Silano</name>
<email>g.silano89@gmail.com</email>
<uri>http://giuseppesilano.net</uri>
</contributor>
<contributor>
<name>Patrick O'Brien</name>
</contributor>
<contributor>
<name>Brenton M. Wiernik</name>
</contributor>
<contributor>
<name>Oliver Couch</name>
<email>oliver.couch@gmail.com</email>
</contributor>
<contributor>
<name>Andrew Dunning</name>
<uri>https://orcid.org/0000-0003-0464-5036</uri>
</contributor>
<category citation-format="numeric"/>
<category field="engineering"/>
<category field="generic-base"/>
<summary>IEEE style as per the 2023 guidelines.</summary>
<updated>2024-03-27T11:41:27+00:00</updated>
<rights license="http://creativecommons.org/licenses/by-sa/3.0/">This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 License</rights>
</info>
<locale xml:lang="en">
<date form="text">
<date-part name="month" form="short" suffix=" "/>
<date-part name="day" form="numeric-leading-zeros" suffix=", "/>
<date-part name="year"/>
</date>
<terms>
<term name="chapter" form="short">ch.</term>
<term name="chapter-number" form="short">ch.</term>
<term name="presented at">presented at the</term>
<term name="available at">available</term>
<!-- always use three-letter abbreviations for months -->
<term name="month-06" form="short">Jun.</term>
<term name="month-07" form="short">Jul.</term>
<term name="month-09" form="short">Sep.</term>
</terms>
</locale>
<!-- Macros -->
<macro name="status">
<choose>
<if variable="page issue volume" match="none">
<text variable="status" text-case="capitalize-first" suffix="" font-weight="bold"/>
</if>
</choose>
</macro>
<macro name="edition">
<choose>
<if type="bill book chapter graphic legal_case legislation motion_picture paper-conference report song" match="any">
<choose>
<if is-numeric="edition">
<group delimiter=" ">
<number variable="edition" form="ordinal"/>
<text term="edition" form="short"/>
</group>
</if>
<else>
<text variable="edition" text-case="capitalize-first" suffix="."/>
</else>
</choose>
</if>
</choose>
</macro>
<macro name="issued">
<choose>
<if type="article-journal report" match="any">
<date variable="issued">
<date-part name="month" form="short" suffix=" "/>
<date-part name="year" form="long"/>
</date>
</if>
<else-if type="bill book chapter graphic legal_case legislation song thesis" match="any">
<date variable="issued">
<date-part name="year" form="long"/>
</date>
</else-if>
<else-if type="paper-conference" match="any">
<date variable="issued">
<date-part name="month" form="short"/>
<date-part name="year" prefix=" "/>
</date>
</else-if>
<else-if type="motion_picture" match="any">
<date variable="issued" form="text" prefix="(" suffix=")"/>
</else-if>
<else>
<date variable="issued" form="text"/>
</else>
</choose>
</macro>
<macro name="author">
<names variable="author">
<name and="text" et-al-min="7" et-al-use-first="1" initialize-with=". "/>
<label form="short" prefix=", " text-case="capitalize-first"/>
<et-al font-style="italic"/>
<substitute>
<names variable="editor"/>
<names variable="translator"/>
<text macro="director"/>
</substitute>
</names>
</macro>
<macro name="editor">
<names variable="editor">
<name initialize-with=". " delimiter=", " and="text"/>
<label form="short" prefix=", " text-case="capitalize-first"/>
</names>
</macro>
<macro name="director">
<names variable="director">
<name and="text" et-al-min="7" et-al-use-first="1" initialize-with=". "/>
<et-al font-style="italic"/>
</names>
</macro>
<macro name="locators">
<group delimiter=", ">
<text macro="edition"/>
<group delimiter=" ">
<text term="volume" form="short"/>
<number variable="volume" form="numeric"/>
</group>
<group delimiter=" ">
<number variable="number-of-volumes" form="numeric"/>
<text term="volume" form="short" plural="true"/>
</group>
<group delimiter=" ">
<text term="issue" form="short"/>
<number variable="issue" form="numeric"/>
</group>
</group>
</macro>
<macro name="title">
<choose>
<if type="bill book graphic legal_case legislation motion_picture song standard software" match="any">
<text variable="title" font-style="italic"/>
</if>
<else>
<text variable="title" quotes="true"/>
</else>
</choose>
</macro>
<macro name="publisher">
<choose>
<if type="bill book chapter graphic legal_case legislation motion_picture paper-conference song" match="any">
<group delimiter=": ">
<text variable="publisher-place"/>
<text variable="publisher"/>
</group>
</if>
<else>
<group delimiter=", ">
<text variable="publisher"/>
<text variable="publisher-place"/>
</group>
</else>
</choose>
</macro>
<macro name="event">
<choose>
<!-- Published Conference Paper -->
<if type="paper-conference speech" match="any">
<choose>
<if variable="container-title" match="any">
<group delimiter=" ">
<text term="in"/>
<text variable="container-title" font-style="italic"/>
</group>
</if>
<!-- Unpublished Conference Paper -->
<else>
<group delimiter=" ">
<text term="presented at"/>
<text variable="event"/>
</group>
</else>
</choose>
</if>
</choose>
</macro>
<macro name="access">
<choose>
<if type="webpage post post-weblog" match="any">
<!-- https://url.com/ (accessed Mon. DD, YYYY). -->
<choose>
<if variable="URL">
<group delimiter=". " prefix=" ">
<group delimiter=": ">
<text term="accessed" text-case="capitalize-first"/>
<date variable="accessed" form="text"/>
</group>
<text term="online" prefix="[" suffix="]" text-case="capitalize-first"/>
<group delimiter=": ">
<text term="available at" text-case="capitalize-first"/>
<text variable="URL"/>
</group>
</group>
</if>
</choose>
</if>
<else-if match="any" variable="DOI">
<!-- doi: 10.1000/xyz123. -->
<text variable="DOI" prefix=" doi: " suffix="."/>
</else-if>
<else-if variable="URL">
<!-- Accessed: Mon. DD, YYYY. [Medium]. Available: https://URL.com/ -->
<group delimiter=". " prefix=" " suffix=". ">
<!-- Accessed: Mon. DD, YYYY. -->
<group delimiter=": ">
<text term="accessed" text-case="capitalize-first"/>
<date variable="accessed" form="text"/>
</group>
<!-- [Online Video]. -->
<group prefix="[" suffix="]" delimiter=" ">
<choose>
<if variable="medium" match="any">
<text variable="medium" text-case="capitalize-first"/>
</if>
<else>
<text term="online" text-case="capitalize-first"/>
<choose>
<if type="motion_picture">
<text term="video" text-case="capitalize-first"/>
</if>
</choose>
</else>
</choose>
</group>
</group>
<!-- Available: https://URL.com/ -->
<group delimiter=": " prefix=" ">
<text term="available at" text-case="capitalize-first"/>
<text variable="URL"/>
</group>
</else-if>
</choose>
</macro>
<macro name="page">
<choose>
<if type="article-journal" variable="number" match="all">
<group delimiter=" ">
<text value="Art."/>
<text term="issue" form="short"/>
<text variable="number"/>
</group>
</if>
<else>
<group delimiter=" ">
<label variable="page" form="short"/>
<text variable="page"/>
</group>
</else>
</choose>
</macro>
<macro name="citation-locator">
<group delimiter=" ">
<choose>
<if locator="page">
<label variable="locator" form="short"/>
</if>
<else>
<label variable="locator" form="short" text-case="capitalize-first"/>
</else>
</choose>
<text variable="locator"/>
</group>
</macro>
<macro name="geographic-location">
<group delimiter=", " suffix=".">
<choose>
<if variable="publisher-place">
<text variable="publisher-place" text-case="title"/>
</if>
<else-if variable="event-place">
<text variable="event-place" text-case="title"/>
</else-if>
</choose>
</group>
</macro>
<!-- Series -->
<macro name="collection">
<choose>
<if variable="collection-title" match="any">
<text term="in" suffix=" "/>
<group delimiter=", " suffix=". ">
<text variable="collection-title"/>
<text variable="collection-number" prefix="no. "/>
<text variable="volume" prefix="vol. "/>
</group>
</if>
</choose>
</macro>
<!-- Citation -->
<citation>
<sort>
<key variable="citation-number"/>
</sort>
<layout delimiter=", ">
<group prefix="[" suffix="]" delimiter=", ">
<text variable="citation-number"/>
<text macro="citation-locator"/>
</group>
</layout>
</citation>
<!-- Bibliography -->
<bibliography entry-spacing="0" second-field-align="flush">
<layout>
<!-- Citation Number -->
<text variable="citation-number" prefix="[" suffix="]"/>
<!-- Author(s) -->
<text macro="author" suffix=", "/>
<!-- Rest of Citation -->
<choose>
<!-- Specific Formats -->
<if type="article-journal">
<group delimiter=", ">
<text macro="title"/>
<text variable="container-title" font-style="italic" form="short"/>
<text macro="locators"/>
<text macro="page"/>
<text macro="issued"/>
<text macro="status"/>
</group>
<choose>
<if variable="URL DOI" match="none">
<text value="."/>
</if>
<else>
<text value=","/>
</else>
</choose>
<text macro="access"/>
</if>
<else-if type="paper-conference speech" match="any">
<group delimiter=", " suffix=", ">
<text macro="title"/>
<text macro="event"/>
<text macro="editor"/>
</group>
<text macro="collection"/>
<group delimiter=", " suffix=".">
<text macro="publisher"/>
<text macro="issued"/>
<text macro="page"/>
<text macro="status"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="chapter">
<group delimiter=", " suffix=".">
<text macro="title"/>
<group delimiter=" ">
<text term="in" suffix=" "/>
<text variable="container-title" font-style="italic"/>
</group>
<text macro="locators"/>
<text macro="editor"/>
<text macro="collection"/>
<text macro="publisher"/>
<text macro="issued"/>
<group delimiter=" ">
<label variable="chapter-number" form="short"/>
<text variable="chapter-number"/>
</group>
<text macro="page"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="report">
<group delimiter=", " suffix=".">
<text macro="title"/>
<text macro="publisher"/>
<group delimiter=" ">
<text variable="genre"/>
<text variable="number"/>
</group>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="thesis">
<group delimiter=", " suffix=".">
<text macro="title"/>
<text variable="genre"/>
<text macro="publisher"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="software">
<group delimiter=". " suffix=".">
<text macro="title"/>
<text macro="issued" prefix="(" suffix=")"/>
<text variable="genre"/>
<text macro="publisher"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="article">
<group delimiter=", " suffix=".">
<text macro="title"/>
<text macro="issued"/>
<group delimiter=": ">
<text macro="publisher" font-style="italic"/>
<text variable="number"/>
</group>
</group>
<text macro="access"/>
</else-if>
<else-if type="webpage post-weblog post" match="any">
<group delimiter=", " suffix=".">
<text macro="title"/>
<text variable="container-title"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="patent">
<group delimiter=", ">
<text macro="title"/>
<text variable="number"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<!-- Online Video -->
<else-if type="motion_picture">
<text macro="geographic-location" suffix=". "/>
<group delimiter=", " suffix=".">
<text macro="title"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="standard">
<group delimiter=", " suffix=".">
<text macro="title"/>
<group delimiter=" ">
<text variable="genre"/>
<text variable="number"/>
</group>
<text macro="geographic-location"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<!-- Generic/Fallback Formats -->
<else-if type="bill book graphic legal_case legislation report song" match="any">
<group delimiter=", " suffix=". ">
<text macro="title"/>
<text macro="locators"/>
</group>
<text macro="collection"/>
<group delimiter=", " suffix=".">
<text macro="publisher"/>
<text macro="issued"/>
<text macro="page"/>
</group>
<text macro="access"/>
</else-if>
<else-if type="article-magazine article-newspaper broadcast interview manuscript map patent personal_communication song speech thesis webpage" match="any">
<group delimiter=", " suffix=".">
<text macro="title"/>
<text variable="container-title" font-style="italic"/>
<text macro="locators"/>
<text macro="publisher"/>
<text macro="page"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else-if>
<else>
<group delimiter=", " suffix=". ">
<text macro="title"/>
<text variable="container-title" font-style="italic"/>
<text macro="locators"/>
</group>
<text macro="collection"/>
<group delimiter=", " suffix=".">
<text macro="publisher"/>
<text macro="page"/>
<text macro="issued"/>
</group>
<text macro="access"/>
</else>
</choose>
</layout>
</bibliography>
</style>
-520
View File
@@ -1,520 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<style xmlns="http://purl.org/net/xbiblio/csl" class="in-text" delimiter-precedes-last="always" demote-non-dropping-particle="sort-only" initialize-with="" initialize-with-hyphen="false" name-as-sort-order="all" name-delimiter=", " names-delimiter=", " page-range-format="minimal" sort-separator=" " version="1.0">
<!-- This file was generated by the Style Variant Builder <https://github.com/citation-style-language/style-variant-builder>. To contribute changes, modify the template and regenerate variants. -->
<info>
<title>NLM/Vancouver: Citing Medicine 2nd edition (citation-sequence)</title>
<title-short>National Library of Medicine, ANSI/NISO Z39.29-2005 (R2010), ICMJE Recommendations/URMs (C-S)</title-short>
<id>http://www.zotero.org/styles/nlm-citation-sequence</id>
<link href="http://www.zotero.org/styles/nlm-citation-sequence" rel="self"/>
<link href="https://www.nlm.nih.gov/citingmedicine" rel="documentation"/>
<link href="https://www.nlm.nih.gov/bsd/uniform_requirements.html" rel="documentation"/>
<link href="https://www.icmje.org/recommendations/" rel="documentation"/>
<author>
<name>Michael Berkowitz</name>
<email>mberkowi@gmu.edu</email>
</author>
<author>
<name>Andrew Dunning</name>
<uri>https://orcid.org/0000-0003-0464-5036</uri>
</author>
<contributor>
<name>Petr Hlustik</name>
<uri>https://orcid.org/0000-0002-1951-0671</uri>
</contributor>
<contributor>
<name>Sebastian Karcher</name>
<uri>https://orcid.org/0000-0001-8249-7388</uri>
</contributor>
<contributor>
<name>Charles Parnot</name>
<uri>https://orcid.org/0000-0002-7346-5883</uri>
</contributor>
<contributor>
<name>Sean Takats</name>
<uri>https://orcid.org/0000-0002-7851-5069</uri>
</contributor>
<category citation-format="numeric"/>
<category field="generic-base"/>
<category field="medicine"/>
<category field="science"/>
<summary>Citing Medicine: The NLM Style Guide for Authors, Editors, and Publishers, 2nd edition (2015), based on ANSI/NISO Z39.29-2005 (R2010); citation-sequence system.</summary>
<updated>2026-02-18T15:24:08+00:00</updated>
<rights license="http://creativecommons.org/licenses/by-sa/3.0/">This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 License</rights>
</info>
<locale xml:lang="en">
<date delimiter=" " form="text">
<date-part name="year"/>
<date-part form="short" name="month" strip-periods="true"/>
<date-part name="day"/>
</date>
<terms>
<term name="available at">available from</term>
<term name="collection-editor">
<single>editor</single>
<multiple>editors</multiple>
</term>
<term form="short" name="month-06">Jun.</term>
<term form="short" name="month-07">Jul.</term>
<term form="short" name="month-09">Sep.</term>
<term name="presented at">presented at</term>
<term form="short" name="section">
<single>sect.</single>
<multiple>sects.</multiple>
</term>
<term form="short" name="supplement">
<single>suppl.</single>
<multiple>suppls.</multiple>
</term>
</terms>
</locale>
<locale xml:lang="fr">
<date delimiter=" " form="text">
<date-part name="day"/>
<date-part form="short" name="month" strip-periods="true"/>
<date-part name="year"/>
</date>
</locale>
<!-- Variable labels -->
<macro name="label-collection-number">
<group delimiter=" ">
<choose>
<if is-numeric="collection-number">
<label form="short" variable="collection-number"/>
</if>
</choose>
<text variable="collection-number"/>
</group>
</macro>
<macro name="label-edition">
<group delimiter=" ">
<choose>
<if is-numeric="edition">
<number form="ordinal" variable="edition"/>
<label form="short" variable="edition"/>
</if>
<else>
<text variable="edition"/>
</else>
</choose>
</group>
</macro>
<macro name="label-number-of-pages">
<group delimiter=" ">
<text variable="number-of-pages"/>
<choose>
<if is-numeric="number-of-pages">
<label form="short" plural="never" variable="number-of-pages"/>
</if>
</choose>
</group>
</macro>
<macro name="label-page">
<group delimiter=" ">
<label form="short" plural="never" variable="page"/>
<text variable="page"/>
</group>
</macro>
<macro name="label-part-number-capitalized">
<group delimiter=" ">
<choose>
<if is-numeric="part-number">
<!-- TODO: Replace with `part-number` label when CSL provides one -->
<text form="short" term="part" text-case="capitalize-first"/>
</if>
</choose>
<text variable="part-number"/>
</group>
</macro>
<macro name="label-supplement-number">
<group delimiter=" ">
<choose>
<if is-numeric="supplement-number">
<!-- TODO: Replace with `supplement-number` label when CSL provides one -->
<text form="short" strip-periods="true" term="supplement" text-case="capitalize-first"/>
</if>
</choose>
<text text-case="capitalize-first" variable="supplement-number"/>
</group>
</macro>
<macro name="label-volume-capitalized">
<group delimiter=" ">
<choose>
<if is-numeric="volume">
<label form="short" text-case="capitalize-first" variable="volume"/>
</if>
</choose>
<text variable="volume"/>
</group>
</macro>
<macro name="author">
<names variable="author">
<label prefix=", "/>
<substitute>
<names variable="editor-translator"/>
<names variable="editor translator"/>
<names variable="editor"/>
<names variable="collection-editor"/>
</substitute>
</names>
</macro>
<macro name="title">
<choose>
<if type="webpage" variable="container-title">
<!-- `webpage` listed under `container-title` (Citing Medicine, ch. 25) -->
<text variable="container-title"/>
</if>
<else>
<text variable="title"/>
</else>
</choose>
</macro>
<macro name="content-type">
<text variable="genre"/>
</macro>
<macro name="type-of-medium">
<choose>
<if variable="medium">
<text text-case="capitalize-first" variable="medium"/>
</if>
<else-if match="any" type="chapter entry-dictionary entry-encyclopedia paper-conference"/>
<else-if variable="URL">
<text term="internet" text-case="capitalize-first"/>
</else-if>
</choose>
</macro>
<macro name="container-preposition">
<choose>
<if match="any" type="chapter paper-conference entry-dictionary entry-encyclopedia">
<text term="in" text-case="capitalize-first"/>
</if>
</choose>
</macro>
<macro name="secondary-authors">
<names variable="editor">
<label prefix=", "/>
</names>
</macro>
<macro name="container-title">
<group delimiter=", ">
<choose>
<if type="webpage"/>
<else-if variable="container-title">
<group delimiter=". ">
<group delimiter=" ">
<choose>
<if match="any" type="article-journal review review-book">
<text form="short" strip-periods="true" variable="container-title"/>
</if>
<else>
<text variable="container-title"/>
</else>
</choose>
<choose>
<if type="article-journal" variable="DOI"/>
<else-if type="article-journal" variable="PMID"/>
<else-if type="article-journal" variable="PMCID"/>
<else-if variable="URL">
<text prefix="[" suffix="]" term="internet" text-case="capitalize-first"/>
</else-if>
</choose>
</group>
<text macro="label-edition"/>
</group>
</else-if>
<!-- TODO: add `event-name` and `event-place` -->
<else-if match="any" type="bill legislation">
<group delimiter=". ">
<text variable="container-title"/>
<group delimiter=" ">
<text form="short" term="section" text-case="capitalize-first"/>
<text variable="section"/>
</group>
</group>
<text variable="number"/>
</else-if>
<else-if type="speech">
<group delimiter=": ">
<group delimiter=" ">
<text text-case="capitalize-first" variable="genre"/>
<text term="presented at"/>
</group>
<text variable="event"/>
</group>
</else-if>
<else>
<group delimiter=", ">
<text macro="label-volume-capitalized"/>
<text variable="volume-title"/>
</group>
<group delimiter=", ">
<text macro="label-part-number-capitalized"/>
<text variable="part-title"/>
</group>
</else>
</choose>
</group>
</macro>
<macro name="place-of-publication">
<choose>
<if type="thesis">
<text prefix="[" suffix="]" variable="publisher-place"/>
</if>
<else-if type="speech"/>
<else>
<text variable="publisher-place"/>
</else>
</choose>
</macro>
<macro name="publisher">
<choose>
<!-- discard publisher for serial publications -->
<if match="none" type="article-journal article-magazine article-newspaper periodical post-weblog review review-book">
<group delimiter=": ">
<text macro="place-of-publication"/>
<text variable="publisher"/>
</group>
</if>
</choose>
</macro>
<macro name="date">
<group delimiter=" ">
<choose>
<if match="any" type="article-journal article-magazine article-newspaper periodical post-weblog review review-book">
<group delimiter=":">
<group delimiter=" ">
<date form="text" variable="issued"/>
<choose>
<if type="article-journal" variable="DOI"/>
<else-if type="article-journal" variable="PMID"/>
<else-if type="article-journal" variable="PMCID"/>
<else>
<text macro="date-of-citation"/>
</else>
</choose>
</group>
<choose>
<if type="article-newspaper">
<text variable="page"/>
</if>
</choose>
</group>
</if>
<else-if match="any" type="bill legislation">
<date form="text" variable="issued"/>
</else-if>
<else-if type="report">
<date date-parts="year-month" form="text" variable="issued"/>
<text macro="date-of-citation"/>
</else-if>
<else-if type="patent">
<group delimiter=", ">
<text variable="number"/>
<date date-parts="year" form="numeric" variable="issued"/>
</group>
<text macro="date-of-citation"/>
</else-if>
<else-if type="speech">
<group delimiter="; ">
<group delimiter=" ">
<date form="text" variable="issued"/>
<text macro="date-of-citation"/>
</group>
<text variable="event-place"/>
</group>
</else-if>
<else>
<date date-parts="year" form="numeric" variable="issued"/>
<text macro="date-of-citation"/>
</else>
</choose>
</group>
</macro>
<macro name="identifier-serial">
<choose>
<if match="any" type="article-journal article-magazine periodical post-weblog review review-book">
<group delimiter=":">
<group>
<text variable="collection-title"/>
<text variable="volume"/>
<group delimiter=" " prefix="(" suffix=")">
<text variable="issue"/>
<text macro="label-supplement-number"/>
</group>
</group>
<text macro="location-pagination-serial"/>
</group>
</if>
</choose>
</macro>
<macro name="date-of-citation">
<choose>
<if variable="URL">
<group delimiter=" " prefix="[" suffix="]">
<text term="cited"/>
<date form="text" variable="accessed"/>
</group>
</if>
</choose>
</macro>
<macro name="location-pagination-monographic">
<group delimiter=" ">
<choose>
<if match="any" type="article-journal article-magazine article-newspaper review review-book"/>
<else-if type="book">
<text macro="label-number-of-pages"/>
</else-if>
<else>
<text macro="label-page"/>
</else>
</choose>
</group>
</macro>
<macro name="location-pagination-serial">
<choose>
<if variable="number">
<text variable="number"/>
</if>
<else>
<text variable="page"/>
</else>
</choose>
</macro>
<macro name="webpage-part">
<choose>
<if type="webpage" variable="container-title">
<text variable="title"/>
</if>
</choose>
</macro>
<macro name="series">
<choose>
<if match="any" type="article-journal article-magazine article-newspaper periodical post-weblog review review-book"/>
<else-if variable="collection-title">
<group delimiter=". " prefix="(" suffix=")">
<names variable="collection-editor">
<label prefix=", "/>
</names>
<group delimiter="; ">
<text variable="collection-title"/>
<text macro="label-collection-number"/>
</group>
</group>
</else-if>
</choose>
</macro>
<macro name="report-number">
<choose>
<if type="report">
<group delimiter=": ">
<group delimiter=" ">
<text term="report" text-case="capitalize-first"/>
<label form="short" text-case="capitalize-first" variable="number"/>
</group>
<text variable="number"/>
</group>
</if>
</choose>
</macro>
<macro name="availability">
<group delimiter=". ">
<group delimiter=": ">
<text text-case="capitalize-first" value="located at"/>
<group delimiter="; ">
<group delimiter=", ">
<text variable="archive_collection"/>
<text variable="archive"/>
<text variable="archive-place"/>
</group>
<text variable="archive_location"/>
</group>
</group>
<group delimiter=" ">
<choose>
<if type="article-journal" variable="DOI"/>
<else-if type="article-journal" variable="PMID"/>
<else-if type="article-journal" variable="PMCID"/>
<else>
<group delimiter=": ">
<text term="available at" text-case="capitalize-first"/>
<text variable="URL"/>
</group>
</else>
</choose>
<text prefix="doi:" variable="DOI"/>
</group>
</group>
</macro>
<macro name="notes">
<group delimiter=". " suffix=".">
<group delimiter="; ">
<group delimiter=": ">
<text value="PubMed PMID"/>
<text variable="PMID"/>
</group>
<group delimiter=": ">
<text value="PubMed Central PMCID"/>
<text variable="PMCID"/>
</group>
</group>
<text variable="references"/>
</group>
</macro>
<citation collapse="citation-number">
<sort>
<key variable="citation-number"/>
</sort>
<layout delimiter="," prefix="(" suffix=")">
<text variable="citation-number"/>
</layout>
</citation>
<macro name="bibliography">
<group delimiter=" ">
<group delimiter=". " suffix=".">
<text macro="author"/>
<group delimiter=" ">
<text macro="title"/>
<text macro="content-type" prefix="[" suffix="]"/>
<choose>
<if type="webpage" variable="container-title">
<text macro="type-of-medium" prefix="[" suffix="]"/>
</if>
<else-if match="none" variable="container-title">
<text macro="type-of-medium" prefix="[" suffix="]"/>
</else-if>
</choose>
</group>
<choose>
<if match="none" variable="container-title">
<text macro="label-edition"/>
</if>
</choose>
<group delimiter=": ">
<text macro="container-preposition"/>
<group delimiter=". ">
<text macro="secondary-authors"/>
<text macro="container-title"/>
</group>
</group>
<group delimiter="; ">
<text macro="publisher"/>
<group delimiter=";">
<text macro="date"/>
<text macro="identifier-serial"/>
</group>
</group>
<text macro="location-pagination-monographic"/>
<text macro="webpage-part"/>
<text macro="series"/>
<text macro="report-number"/>
</group>
<text macro="availability"/>
<text macro="notes"/>
</group>
</macro>
<bibliography et-al-min="7" et-al-use-first="6" second-field-align="flush">
<layout>
<text suffix="." variable="citation-number"/>
<text macro="bibliography"/>
</layout>
</bibliography>
</style>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23 -2
View File
@@ -1,9 +1,20 @@
import { Link } from '@tanstack/react-router' import { Link, useNavigate } from '@tanstack/react-router'
import { Home, Menu, Network, X } from 'lucide-react' import { Home, LogOut, Menu, Network, X } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { supabaseBrowser } from '@/data/supabase/client'
export default function Header() { export default function Header() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const navigate = useNavigate()
const handleLogout = async () => {
try {
await supabaseBrowser().auth.signOut()
} finally {
void navigate({ to: '/login', replace: true })
}
}
return ( return (
<> <>
@@ -21,6 +32,16 @@ export default function Header() {
<img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" /> <img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" />
</Link> </Link>
</h1> </h1>
<button
onClick={handleLogout}
className="ml-auto inline-flex items-center gap-2 rounded-lg p-2 transition-colors hover:bg-gray-700"
aria-label="Logout"
title="Logout"
>
<LogOut size={20} />
<span className="hidden sm:inline">Salir</span>
</button>
</header> </header>
<aside <aside
@@ -1,19 +1,11 @@
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router' import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
import { Minus, Pencil, Plus, Sparkles } from 'lucide-react' import { Pencil, Sparkles } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react' import { useState, useEffect } from 'react'
import type { AsignaturaDetail } from '@/data' import type { AsignaturaDetail } from '@/data'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import {
Tooltip, Tooltip,
@@ -21,9 +13,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { usePlanAsignaturas } from '@/data'
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects' import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
import { columnParsers } from '@/lib/asignaturaColumnParsers'
export interface BibliografiaEntry { export interface BibliografiaEntry {
id: string id: string
@@ -51,15 +41,50 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) return typeof value === 'object' && value !== null && !Array.isArray(value)
} }
type CriterioEvaluacionRow = { function parseContenidoTematicoToPlainText(value: unknown): string {
criterio: string if (!Array.isArray(value)) return ''
porcentaje: number
const blocks: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const unidad =
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
? item.unidad
: undefined
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
if (!header) continue
const lines: Array<string> = [header]
const temas = Array.isArray(item.temas) ? item.temas : []
temas.forEach((tema, idx) => {
const temaNombre =
typeof tema === 'string'
? tema
: isRecord(tema) && typeof tema.nombre === 'string'
? tema.nombre
: ''
if (!temaNombre) return
if (unidad != null) {
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
} else {
lines.push(`${idx + 1}. ${temaNombre}`)
}
})
blocks.push(lines.join('\n'))
}
return blocks.join('\n\n').trimEnd()
} }
type CriterioEvaluacionRowDraft = { const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
id: string contenido_tematico: parseContenidoTematicoToPlainText,
criterio: string
porcentaje: string // allow empty while editing
} }
export const Route = createFileRoute( export const Route = createFileRoute(
@@ -72,12 +97,8 @@ export default function AsignaturaDetailPage() {
const { asignaturaId } = useParams({ const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: asignaturaApi } = useSubject(asignaturaId) const { data: asignaturaApi } = useSubject(asignaturaId)
const { data: asignaturasApi, isLoading: loadingAsig } =
usePlanAsignaturas(planId)
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null) const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
const updateAsignatura = useUpdateAsignatura() const updateAsignatura = useUpdateAsignatura()
@@ -98,70 +119,24 @@ export default function AsignaturaDetailPage() {
}, },
}) })
} }
const asignaturaSeriada = useMemo(() => {
if (!asignaturaApi?.prerrequisito_asignatura_id || !asignaturasApi)
return null
return asignaturasApi.find(
(asig) => asig.id === asignaturaApi.prerrequisito_asignatura_id,
)
}, [asignaturaApi, asignaturasApi])
const requisitosFormateados = useMemo(() => {
if (!asignaturaSeriada) return []
return [
{
type: 'Pre-requisito',
code: asignaturaSeriada.codigo,
name: asignaturaSeriada.nombre,
id: asignaturaSeriada.id, // Guardamos el ID para el select
},
]
}, [asignaturaSeriada])
const handleUpdatePrerrequisito = (newId: string | null) => {
updateAsignatura.mutate({
asignaturaId,
patch: {
prerrequisito_asignatura_id: newId,
},
})
}
/* ---------- sincronizar API ---------- */ /* ---------- sincronizar API ---------- */
useEffect(() => { useEffect(() => {
console.log(requisitosFormateados)
if (asignaturaApi) setAsignatura(asignaturaApi) if (asignaturaApi) setAsignatura(asignaturaApi)
}, [asignaturaApi, requisitosFormateados]) }, [asignaturaApi])
return ( return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
<DatosGenerales
pre={requisitosFormateados}
availableSubjects={asignaturasApi}
onPersistDato={handlePersistDatoGeneral}
/>
)
} }
function DatosGenerales({ function DatosGenerales({
onPersistDato, onPersistDato,
pre,
availableSubjects,
}: { }: {
onPersistDato: (clave: string, value: string) => void onPersistDato: (clave: string, value: string) => void
}) { }) {
const { asignaturaId, planId } = useParams({ const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
const navigate = useNavigate()
const { data: data, isLoading: isLoading } = useSubject(asignaturaId) const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
const updateAsignatura = useUpdateAsignatura()
const evaluationCardRef = useRef<HTMLDivElement | null>(null)
const [evaluationForceEditToken, setEvaluationForceEditToken] =
useState<number>(0)
const [evaluationHighlightToken, setEvaluationHighlightToken] =
useState<number>(0)
// 1. Extraemos la definición de la estructura (los metadatos) // 1. Extraemos la definición de la estructura (los metadatos)
const definicionRaw = data?.estructuras_asignatura?.definicion const definicionRaw = data?.estructuras_asignatura?.definicion
@@ -179,56 +154,6 @@ function DatosGenerales({
const valoresActuales = isRecord(datosRaw) const valoresActuales = isRecord(datosRaw)
? (datosRaw as Record<string, any>) ? (datosRaw as Record<string, any>)
: {} : {}
const criteriosEvaluacion: Array<CriterioEvaluacionRow> = useMemo(() => {
const raw = (data as any)?.criterios_de_evaluacion
console.log(raw)
if (!Array.isArray(raw)) return []
const rows: Array<CriterioEvaluacionRow> = []
for (const item of raw) {
if (!isRecord(item)) continue
const criterio = typeof item.criterio === 'string' ? item.criterio : ''
const porcentajeNum =
typeof item.porcentaje === 'number'
? item.porcentaje
: typeof item.porcentaje === 'string'
? Number(item.porcentaje)
: NaN
if (!criterio.trim()) continue
if (!Number.isFinite(porcentajeNum)) continue
const porcentaje = Math.trunc(porcentajeNum)
if (porcentaje < 1 || porcentaje > 100) continue
rows.push({ criterio: criterio.trim(), porcentaje: porcentaje })
}
return rows
}, [data])
const openEvaluationEditor = () => {
evaluationCardRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
const now = Date.now()
setEvaluationForceEditToken(now)
setEvaluationHighlightToken(now)
}
const persistCriteriosEvaluacion = async (
rows: Array<CriterioEvaluacionRow>,
) => {
await updateAsignatura.mutateAsync({
asignaturaId: asignaturaId as any,
patch: {
criterios_de_evaluacion: rows,
} as any,
})
}
if (isLoading) return <p>Cargando información...</p> if (isLoading) return <p>Cargando información...</p>
return ( return (
@@ -284,29 +209,10 @@ function DatosGenerales({
clave={key} clave={key}
title={cardTitle} title={cardTitle}
initialContent={currentContent} initialContent={currentContent}
xColumn={xColumn}
placeholder={placeholder} placeholder={placeholder}
description={description} description={description}
onPersist={({ clave, value }) => onPersist={(clave, value) => onPersistDato(clave, value)}
onPersistDato(String(clave ?? key), String(value ?? ''))
}
onClickEditButton={({ startEditing }) => {
switch (xColumn) {
case 'contenido_tematico': {
navigate({
to: '/planes/$planId/asignaturas/$asignaturaId/contenido',
params: { planId, asignaturaId },
})
return
}
case 'criterios_de_evaluacion': {
openEvaluationEditor()
return
}
default: {
startEditing()
}
}
}}
/> />
) )
}, },
@@ -318,39 +224,32 @@ function DatosGenerales({
<div className="space-y-6"> <div className="space-y-6">
{/* Tarjeta de Requisitos */} {/* Tarjeta de Requisitos */}
<InfoCard <InfoCard
asignaturaId={asignaturaId}
title="Requisitos y Seriación" title="Requisitos y Seriación"
type="requirements" type="requirements"
initialContent={pre} initialContent={[
// Pasamos las materias del plan para el Select (excluyendo la actual) {
availableSubjects={ type: 'Pre-requisito',
availableSubjects?.filter( code: 'PA-301',
(a) => name: 'Programación Avanzada',
a.id !== asignaturaId &&
a.numero_ciclo < data?.numero_ciclo &&
a.numero_ciclo,
) || []
}
onPersist={({ value }) => {
updateAsignatura.mutate({
asignaturaId,
patch: {
prerrequisito_asignatura_id: value, // value ya viene como ID o null desde handleSave
}, },
}) {
}} type: 'Co-requisito',
code: 'MAT-201',
name: 'Matemáticas Discretas',
},
]}
/> />
{/* Tarjeta de Evaluación */} {/* Tarjeta de Evaluación */}
<InfoCard <InfoCard
asignaturaId={asignaturaId}
title="Sistema de Evaluación" title="Sistema de Evaluación"
type="evaluation" type="evaluation"
initialContent={criteriosEvaluacion} initialContent={[
containerRef={evaluationCardRef} { label: 'Exámenes parciales', value: '30%' },
forceEditToken={evaluationForceEditToken} { label: 'Proyecto integrador', value: '35%' },
highlightToken={evaluationHighlightToken} { label: 'Prácticas de laboratorio', value: '20%' },
onPersist={({ value }) => persistCriteriosEvaluacion(value)} { label: 'Participación', value: '15%' },
]}
/> />
</div> </div>
</div> </div>
@@ -366,20 +265,11 @@ interface InfoCardProps {
initialContent: any initialContent: any
placeholder?: string placeholder?: string
description?: string description?: string
xColumn?: string
required?: boolean // Nueva prop para el asterisco required?: boolean // Nueva prop para el asterisco
type?: 'text' | 'requirements' | 'evaluation' type?: 'text' | 'requirements' | 'evaluation'
onEnhanceAI?: (content: any) => void onEnhanceAI?: (content: any) => void
onPersist?: (payload: { onPersist?: (clave: string, value: string) => void
type: NonNullable<InfoCardProps['type']>
clave?: string
value: any
}) => void | Promise<void>
onClickEditButton?: (helpers: { startEditing: () => void }) => void
containerRef?: React.RefObject<HTMLDivElement | null>
forceEditToken?: number
highlightToken?: number
availableSubjects?: any
} }
function InfoCard({ function InfoCard({
@@ -389,23 +279,14 @@ function InfoCard({
initialContent, initialContent,
placeholder, placeholder,
description, description,
xColumn,
required, required,
type = 'text', type = 'text',
onPersist, onPersist,
onClickEditButton,
containerRef,
forceEditToken,
highlightToken,
availableSubjects,
}: InfoCardProps) { }: InfoCardProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [isHighlighted, setIsHighlighted] = useState(false)
const [data, setData] = useState(initialContent) const [data, setData] = useState(initialContent)
const [tempText, setTempText] = useState(initialContent) const [tempText, setTempText] = useState(initialContent)
const [evalRows, setEvalRows] = useState<Array<CriterioEvaluacionRowDraft>>(
[],
)
const navigate = useNavigate() const navigate = useNavigate()
const { planId } = useParams({ const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
@@ -414,157 +295,38 @@ function InfoCard({
useEffect(() => { useEffect(() => {
setData(initialContent) setData(initialContent)
setTempText(initialContent) setTempText(initialContent)
console.log(data) }, [initialContent])
console.log(initialContent)
if (type === 'evaluation') {
const raw = Array.isArray(initialContent) ? initialContent : []
const rows: Array<CriterioEvaluacionRowDraft> = raw
.map((r: any): CriterioEvaluacionRowDraft | null => {
const criterio = typeof r?.criterio === 'string' ? r.criterio : ''
const porcentajeNum =
typeof r?.porcentaje === 'number'
? r.porcentaje
: typeof r?.porcentaje === 'string'
? Number(r.porcentaje)
: NaN
const porcentaje = Number.isFinite(porcentajeNum)
? String(Math.trunc(porcentajeNum))
: ''
return {
id: crypto.randomUUID(),
criterio,
porcentaje,
}
})
.filter(Boolean) as Array<CriterioEvaluacionRowDraft>
setEvalRows(rows)
}
}, [initialContent, type])
useEffect(() => {
if (!forceEditToken) return
setIsEditing(true)
}, [forceEditToken])
useEffect(() => {
if (!highlightToken) return
setIsHighlighted(true)
const t = window.setTimeout(() => setIsHighlighted(false), 900)
return () => window.clearTimeout(t)
}, [highlightToken])
const handleSave = () => { const handleSave = () => {
console.log('clave, valor:', clave, String(tempText ?? '')) console.log('clave, valor:', clave, String(tempText ?? ''))
console.log(clave)
console.log(tempText)
if (type === 'evaluation') {
const cleaned: Array<CriterioEvaluacionRow> = []
for (const r of evalRows) {
const criterio = String(r.criterio).trim()
const porcentajeStr = String(r.porcentaje).trim()
if (!criterio) continue
if (!porcentajeStr) continue
const n = Number(porcentajeStr)
if (!Number.isFinite(n)) continue
const porcentaje = Math.trunc(n)
if (porcentaje < 1 || porcentaje > 100) continue
cleaned.push({ criterio, porcentaje })
}
setData(cleaned)
setEvalRows(
cleaned.map((x) => ({
id: crypto.randomUUID(),
criterio: x.criterio,
porcentaje: String(x.porcentaje),
})),
)
setIsEditing(false)
void onPersist?.({ type, clave, value: cleaned })
return
}
if (type === 'requirements') {
console.log('entre aqui ')
// Si tempText es un array y tiene elementos, tomamos el ID del primero
// Si es "none" o está vacío, mandamos null (para limpiar la seriación)
const prerequisiteId =
Array.isArray(tempText) && tempText.length > 0 ? tempText[0].id : null
setData(tempText) // Actualiza la vista local
setIsEditing(false)
// Mandamos el ID específico a la base de datos
void onPersist?.({
type,
clave: 'prerrequisito_asignatura_id', // Forzamos la columna correcta
value: prerequisiteId,
})
return
}
setData(tempText) setData(tempText)
setIsEditing(false) setIsEditing(false)
if (type === 'text') { if (type === 'text' && clave && onPersist) {
void onPersist?.({ type, clave, value: String(tempText ?? '') }) onPersist(clave, String(tempText ?? ''))
} }
} }
const handleIARequest = (campoClave: string) => { const handleIARequest = (campoClave: string) => {
console.log(campoClave) console.log(placeholder)
let targetClave = campoClave
if (type === 'evaluation' && !targetClave) {
targetClave = 'criterios_de_evaluacion'
}
if (targetClave === 'contenido') {
targetClave = 'contenido_tematico'
}
console.log(targetClave)
console.log(asignaturaId)
// Añadimos un timestamp a la state para forzar que la navegación
// genere una nueva ubicación incluso si la ruta y los params son iguales.
navigate({ navigate({
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura', to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
params: { planId, asignaturaId: asignaturaId! }, params: { planId, asignaturaId: asignaturaId! },
state: { state: {
activeTab: 'ia', activeTab: 'ia',
prefillCampo: targetClave, prefillCampo: campoClave,
prefillContenido: data, prefillContenido: data,
_ts: Date.now(), _ts: Date.now(),
} as any, } as any,
}) })
} }
const evaluationTotal = useMemo(() => {
if (type !== 'evaluation') return 0
return evalRows.reduce((acc, r) => {
const v = String(r.porcentaje).trim()
if (!v) return acc
const n = Number(v)
if (!Number.isFinite(n)) return acc
const porcentaje = Math.trunc(n)
if (porcentaje < 1 || porcentaje > 100) return acc
return acc + porcentaje
}, 0)
}, [type, evalRows])
return ( return (
<div ref={containerRef as any}> <Card className="overflow-hidden transition-all hover:border-slate-300">
<Card
className={
'overflow-hidden transition-all hover:border-slate-300 ' +
(isHighlighted ? 'ring-primary/40 ring-2' : '')
}
>
<TooltipProvider> <TooltipProvider>
<CardHeader className="border-b bg-slate-50/50 px-5 py-3"> <CardHeader className="border-b bg-slate-50/50 px-5 py-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -592,21 +354,19 @@ function InfoCard({
{!isEditing && ( {!isEditing && (
<div className="flex gap-1"> <div className="flex gap-1">
{type !== 'requirements' && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-blue-500 hover:bg-blue-100" className="h-8 w-8 text-blue-500 hover:bg-blue-100"
onClick={() => handleIARequest(clave)} onClick={() => clave && handleIARequest(clave)}
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Mejorar con IA</TooltipContent> <TooltipContent>Mejorar con IA</TooltipContent>
</Tooltip> </Tooltip>
)}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -615,14 +375,19 @@ function InfoCard({
size="icon" size="icon"
className="h-8 w-8 text-slate-400" className="h-8 w-8 text-slate-400"
onClick={() => { onClick={() => {
const startEditing = () => setIsEditing(true) // Si esta InfoCard proviene de una columna externa (ej: contenido_tematico),
// redirigimos a la pestaña de Contenido en vez de editar inline.
if (onClickEditButton) { if (xColumn === 'contenido_tematico') {
onClickEditButton({ startEditing }) // 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 return
} }
startEditing() setIsEditing(true)
}} }}
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
@@ -639,167 +404,17 @@ function InfoCard({
<CardContent className="pt-4"> <CardContent className="pt-4">
{isEditing ? ( {isEditing ? (
<div className="space-y-3"> <div className="space-y-3">
{/* Condicionales de edición según el tipo */}
{type === 'requirements' ? (
<div className="space-y-3">
<label className="text-xs font-medium text-slate-500">
Materia de Seriación
</label>
<Select
value={
Array.isArray(tempText) && tempText.length > 0
? tempText[0].id
: 'none'
}
onValueChange={(val) => {
console.log(availableSubjects)
const selected = availableSubjects?.find(
(s) => s.id === val,
)
if (val === 'none' || !selected) {
console.log('guardando')
setTempText([])
} else {
console.log('hola')
setTempText([
{
id: selected.id,
type: 'Pre-requisito',
code: selected.codigo,
name: selected.nombre,
},
])
}
}}
>
<SelectTrigger className="w-full">
<div className="flex-1 truncate text-left">
<SelectValue placeholder="Selecciona una materia" />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
Ninguna (Sin seriación)
</SelectItem>
{availableSubjects?.map((asig) => (
<SelectItem
key={asig.id}
value={asig.id}
className="max-w-[300px] sm:max-w-[500px]"
>
<span className="block truncate">
{asig.codigo} - {asig.nombre}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : type === 'evaluation' ? (
<div className="space-y-3">
<div className="space-y-2">
{evalRows.map((row) => (
<div
key={row.id}
className="grid grid-cols-[2fr_1fr_1ch_32px] items-center gap-2"
>
<Input
value={row.criterio}
placeholder="Criterio"
onChange={(e) => {
const nextCriterio = e.target.value
setEvalRows((prev) =>
prev.map((r) =>
r.id === row.id
? { ...r, criterio: nextCriterio }
: r,
),
)
}}
/>
<Input
value={row.porcentaje}
placeholder="%"
type="number"
onChange={(e) => {
const raw = e.target.value
if (raw !== '' && !/^\d+$/.test(raw)) return
setEvalRows((prev) => {
const next = prev.map((r) =>
r.id === row.id ? { ...r, porcentaje: raw } : r,
)
const total = next.reduce(
(acc, r) => acc + (Number(r.porcentaje) || 0),
0,
)
return total > 100 ? prev : next
})
}}
/>
<div className="text-sm text-slate-600">%</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:bg-red-50"
onClick={() =>
setEvalRows((prev) =>
prev.filter((r) => r.id !== row.id),
)
}
>
<Minus className="h-4 w-4" />
</Button>
</div>
))}
</div>
<div className="flex items-center justify-between">
<span
className={`text-sm ${evaluationTotal === 100 ? 'text-muted-foreground' : 'text-destructive font-semibold'}`}
>
Total: {evaluationTotal}/100
</span>
<Button
variant="ghost"
size="sm"
className="text-emerald-700 hover:bg-emerald-50"
onClick={() =>
setEvalRows((prev) => [
...prev,
{
id: crypto.randomUUID(),
criterio: '',
porcentaje: '',
},
])
}
>
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
</Button>
</div>
</div>
) : (
<Textarea <Textarea
value={tempText} value={tempText}
placeholder={placeholder} placeholder={placeholder}
onChange={(e) => setTempText(e.target.value)} onChange={(e) => setTempText(e.target.value)}
className="min-h-30 text-sm leading-relaxed" className="min-h-30 text-sm leading-relaxed"
/> />
)}
{/* Botones de acción comunes */}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => { onClick={() => setIsEditing(false)}
setIsEditing(false)
// Lógica de reset si es necesario...
}}
> >
Cancelar Cancelar
</Button> </Button>
@@ -807,14 +422,12 @@ function InfoCard({
size="sm" size="sm"
className="bg-[#00a878] hover:bg-[#008f66]" className="bg-[#00a878] hover:bg-[#008f66]"
onClick={handleSave} onClick={handleSave}
disabled={type === 'evaluation' && evaluationTotal > 100}
> >
Guardar Guardar
</Button> </Button>
</div> </div>
</div> </div>
) : ( ) : (
/* Modo Visualización */
<div className="text-sm leading-relaxed text-slate-600"> <div className="text-sm leading-relaxed text-slate-600">
{type === 'text' && {type === 'text' &&
(data ? ( (data ? (
@@ -828,7 +441,6 @@ function InfoCard({
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div>
) )
} }
@@ -854,11 +466,7 @@ function RequirementsView({ items }: { items: Array<any> }) {
} }
// Vista de Evaluación // Vista de Evaluación
function EvaluationView({ items }: { items: Array<CriterioEvaluacionRow> }) { function EvaluationView({ items }: { items: Array<any> }) {
const porcentajeTotal = items.reduce(
(total, item) => total + Number(item.porcentaje),
0,
)
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{items.map((item, i) => ( {items.map((item, i) => (
@@ -866,15 +474,10 @@ function EvaluationView({ items }: { items: Array<CriterioEvaluacionRow> }) {
key={i} key={i}
className="flex justify-between border-b border-slate-50 pb-1.5 text-sm italic" className="flex justify-between border-b border-slate-50 pb-1.5 text-sm italic"
> >
<span className="text-slate-500">{item.criterio}</span> <span className="text-slate-500">{item.label}</span>
<span className="font-bold text-blue-600">{item.porcentaje}%</span> <span className="font-bold text-blue-600">{item.value}</span>
</div> </div>
))} ))}
{porcentajeTotal < 100 && (
<p className="text-destructive text-sm font-medium">
El porcentaje total es menor a 100%.
</p>
)}
</div> </div>
) )
} }
@@ -1,7 +1,7 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */ /* 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 */ /* eslint-disable jsx-a11y/no-static-element-interactions */
import { useNavigate, useParams } from '@tanstack/react-router' import { useParams } from '@tanstack/react-router'
import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react' import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
@@ -54,8 +54,7 @@ export interface BibliografiaEntry {
} }
export function BibliographyItem() { export function BibliographyItem() {
const navigate = useNavigate() const { asignaturaId } = useParams({
const { planId, asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
@@ -69,9 +68,13 @@ export function BibliographyItem() {
const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId) const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId)
// --- 3. Estados de UI (Solo para diálogos y edición) --- // --- 3. Estados de UI (Solo para diálogos y edición) ---
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false) const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [newEntryType, setNewEntryType] = useState<'BASICA' | 'COMPLEMENTARIA'>(
'BASICA',
)
console.log('Datos actuales en el front:', bibliografia) console.log('Datos actuales en el front:', bibliografia)
// --- 4. Derivación de datos (Se calculan en cada render) --- // --- 4. Derivación de datos (Se calculan en cada render) ---
@@ -82,6 +85,20 @@ export function BibliographyItem() {
// --- Handlers Conectados a la Base de Datos --- // --- Handlers Conectados a la Base de Datos ---
const handleAddManual = (cita: string) => {
crearBibliografia(
{
asignatura_id: asignaturaId,
tipo: newEntryType,
cita,
tipo_fuente: 'MANUAL',
},
{
onSuccess: () => setIsAddDialogOpen(false),
},
)
}
const handleAddFromLibrary = ( const handleAddFromLibrary = (
resource: any, resource: any,
tipo: 'BASICA' | 'COMPLEMENTARIA', tipo: 'BASICA' | 'COMPLEMENTARIA',
@@ -126,7 +143,7 @@ export function BibliographyItem() {
return ( return (
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500"> <div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500">
<div className="flex flex-col gap-4 border-b pb-4 md:flex-row md:items-center md:justify-between"> <div className="flex items-center justify-between border-b pb-4">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight text-slate-900"> <h2 className="text-2xl font-bold tracking-tight text-slate-900">
Bibliografía Bibliografía
@@ -162,17 +179,20 @@ export function BibliographyItem() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Button <Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
onClick={() => <DialogTrigger asChild>
navigate({ <Button variant="outline">
to: `/planes/${planId}/asignaturas/${asignaturaId}/bibliografia/nueva`, <Plus className="mr-2 h-4 w-4" /> Añadir manual
resetScroll: false,
})
}
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
>
<Plus className="mr-2 h-4 w-4" /> Agregar Bibliografía
</Button> </Button>
</DialogTrigger>
<DialogContent>
<AddManualDialog
tipo={newEntryType}
onTypeChange={setNewEntryType}
onAdd={handleAddManual}
/>
</DialogContent>
</Dialog>
</div> </div>
</div> </div>
@@ -344,6 +364,49 @@ function BibliografiaCard({
) )
} }
function AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
const [cita, setCita] = useState('')
return (
<div className="space-y-4 py-4">
<DialogHeader>
<DialogTitle>Referencia Manual</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Tipo
</label>
<Select value={tipo} onValueChange={onTypeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BASICA">Básica</SelectItem>
<SelectItem value="COMPLEMENTARIA">Complementaria</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Cita APA
</label>
<Textarea
value={cita}
onChange={(e) => setCita(e.target.value)}
placeholder="Autor, A. (Año). Título..."
className="min-h-[120px]"
/>
</div>
<Button
onClick={() => onAdd(cita)}
disabled={!cita.trim()}
className="w-full bg-blue-600"
>
Añadir a la lista
</Button>
</div>
)
}
function LibrarySearchDialog({ resources, onSelect, existingIds }: any) { function LibrarySearchDialog({ resources, onSelect, existingIds }: any) {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA') const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA')
@@ -1,5 +1,3 @@
import { DragDropProvider } from '@dnd-kit/react'
import { isSortable, useSortable } from '@dnd-kit/react/sortable'
import { useParams } from '@tanstack/react-router' import { useParams } from '@tanstack/react-router'
import { import {
Plus, Plus,
@@ -13,7 +11,7 @@ import {
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api' import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
import type { FocusEvent, KeyboardEvent, ReactNode } from 'react' import type { FocusEvent, KeyboardEvent } from 'react'
import { import {
AlertDialog, AlertDialog,
@@ -52,95 +50,6 @@ export interface UnidadTematica {
temas: Array<Tema> temas: Array<Tema>
} }
function createClientId(prefix: string) {
try {
const c = (globalThis as any).crypto
if (c && typeof c.randomUUID === 'function')
return `${prefix}-${c.randomUUID()}`
} catch {
// ignore
}
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function arrayMove<T>(array: Array<T>, fromIndex: number, toIndex: number) {
const next = array.slice()
const startIndex = fromIndex < 0 ? next.length + fromIndex : fromIndex
if (startIndex < 0 || startIndex >= next.length) return next
const endIndex = toIndex < 0 ? next.length + toIndex : toIndex
const [item] = next.splice(startIndex, 1)
next.splice(endIndex, 0, item)
return next
}
function renumberUnidades(unidades: Array<UnidadTematica>) {
return unidades.map((u, idx) => ({ ...u, numero: idx + 1 }))
}
function InsertUnidadOverlay({
onInsert,
position,
}: {
onInsert: () => void
position: 'top' | 'bottom'
}) {
return (
<div
className={cn(
'pointer-events-auto absolute right-0 left-0 z-30 flex justify-center',
// Match the `space-y-4` gap so the hover target is *between* units.
position === 'top' ? '-top-4 h-4' : '-bottom-4 h-4',
)}
>
<Button
type="button"
variant="outline"
size="sm"
className="bg-background/95 border-border/60 hover:bg-background cursor-pointer opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
onInsert()
}}
>
<Plus className="mr-2 h-3 w-3" /> Nueva unidad
</Button>
</div>
)
}
function SortableUnidad({
id,
index,
registerContainer,
children,
}: {
id: string
index: number
registerContainer: (el: HTMLDivElement | null) => void
children: (args: { handleRef: (el: HTMLElement | null) => void }) => ReactNode
}) {
const { ref, handleRef, isDragSource, isDropTarget } = useSortable({
id,
index,
})
return (
<div
ref={(el) => {
ref(el)
registerContainer(el)
}}
className={cn(
'group relative',
isDragSource && 'opacity-80',
isDropTarget && 'ring-primary/20 ring-2',
)}
>
{children({ handleRef })}
</div>
)
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) return typeof value === 'object' && value !== null && !Array.isArray(value)
} }
@@ -191,18 +100,20 @@ function mapContenidoItem(value: unknown, index: number): ContenidoApi | null {
if (Array.isArray(value.temas)) { if (Array.isArray(value.temas)) {
temas = value.temas temas = value.temas
.map(mapTemaValue) .map(mapTemaValue)
.filter((x): x is ContenidoTemaApi => x !== null) .filter((t): t is ContenidoTemaApi => t !== null)
} else if (typeof value.temas === 'string' && value.temas.trim()) {
temas = value.temas
.split(/\r?\n|,/)
.map((t) => t.trim())
.filter(Boolean)
} }
return { return { unidad, titulo, temas }
...value,
unidad,
titulo,
temas,
}
} }
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> { function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
if (value == null) return []
if (typeof value === 'string') { if (typeof value === 'string') {
try { try {
return mapContenidoTematicoFromDb(JSON.parse(value)) return mapContenidoTematicoFromDb(JSON.parse(value))
@@ -281,16 +192,7 @@ export function ContenidoTematico() {
const [temaDraftHoras, setTemaDraftHoras] = useState('') const [temaDraftHoras, setTemaDraftHoras] = useState('')
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0) const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
const didInitExpandedUnitsRef = useRef(false)
const unidadesRef = useRef<Array<UnidadTematica>>([])
useEffect(() => {
unidadesRef.current = unidades
}, [unidades])
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => { const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
// A partir del primer guardado, ya respetamos lo que el usuario deje expandido.
didInitExpandedUnitsRef.current = true
const payload = serializeUnidadesToApi(nextUnidades) const payload = serializeUnidadesToApi(nextUnidades)
await updateContenido.mutateAsync({ await updateContenido.mutateAsync({
subjectId: asignaturaId, subjectId: asignaturaId,
@@ -344,17 +246,10 @@ export function ContenidoTematico() {
}) })
} }
const parseHorasEstimadas = (raw: string): number => {
const normalized = raw.trim().replace(',', '.')
const parsed = Number.parseFloat(normalized)
if (!Number.isFinite(parsed)) return 0
return parsed
}
const commitEditTema = () => { const commitEditTema = () => {
if (!editingTema) return if (!editingTema) return
const horasEstimadas = parseHorasEstimadas(temaDraftHoras) const parsedHoras = Number.parseInt(temaDraftHoras, 10)
const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
const next = unidades.map((u) => { const next = unidades.map((u) => {
if (u.id !== editingTema.unitId) return u if (u.id !== editingTema.unitId) return u
@@ -408,110 +303,28 @@ export function ContenidoTematico() {
data ? data.contenido_tematico : undefined, data ? data.contenido_tematico : undefined,
) )
// 1. EL ESCUDO: Comparamos si nuestro estado local ya tiene esta info exacta const transformed = contenido.map((u, idx) => ({
// (Esto ocurre justo después de arrastrar, ya que actualizamos la UI antes que la BD) id: `u-${u.unidad || idx + 1}`,
const currentPayload = JSON.stringify(
serializeUnidadesToApi(unidadesRef.current),
)
// Normalizamos la data de la BD para que tenga exactamente la misma forma que el payload
const incomingPayload = JSON.stringify(
contenido.map((u, idx) => ({
unidad: u.unidad || idx + 1,
titulo: u.titulo || 'Sin título',
temas: Array.isArray(u.temas)
? u.temas.map((t) => {
if (typeof t === 'string') {
return {
nombre: t,
horasEstimadas: 0,
descripcion: undefined,
}
}
return {
nombre: t.nombre || 'Tema',
horasEstimadas: t.horasEstimadas ?? 0,
descripcion: t.descripcion,
}
})
: [],
})),
)
// Si los datos son idénticos, abortamos el useEffect.
// ¡Nuestros IDs locales se salvan y no hay parpadeos!
if (currentPayload === incomingPayload && unidadesRef.current.length > 0) {
return
}
// 2. Si llegamos aquí, es la carga inicial o alguien más editó la BD desde otro lado.
// Reciclamos IDs buscando por CONTENIDO (nombre), NUNCA POR ÍNDICE.
const prevUnidades = [...unidadesRef.current]
const transformed = contenido.map((u, idx) => {
const dbTitulo = u.titulo || 'Sin título'
// Buscamos si ya existe una unidad con este mismo título
const existingUnitIndex = prevUnidades.findIndex(
(prev) => prev.nombre === dbTitulo,
)
let unidadId
let existingUnit = null
if (existingUnitIndex !== -1) {
existingUnit = prevUnidades[existingUnitIndex]
unidadId = existingUnit.id
prevUnidades.splice(existingUnitIndex, 1) // Lo sacamos de la lista para no repetirlo
} else {
unidadId = createClientId(`u-${u.unidad || idx + 1}`)
}
return {
id: unidadId,
numero: u.unidad || idx + 1, numero: u.unidad || idx + 1,
nombre: dbTitulo, nombre: u.titulo || 'Sin título',
temas: Array.isArray(u.temas) temas: Array.isArray(u.temas)
? u.temas.map((t: any, tidx: number) => { ? u.temas.map((t: any, tidx: number) => ({
const dbTemaNombre = id: `t-${u.unidad || idx + 1}-${tidx + 1}`,
typeof t === 'string' ? t : t?.nombre || 'Tema' nombre: typeof t === 'string' ? t : t?.nombre || 'Tema',
horasEstimadas: t?.horasEstimadas || 0,
// Reciclamos subtemas por nombre también }))
const existingTema = existingUnit?.temas.find(
(prevT) => prevT.nombre === dbTemaNombre,
)
const temaId = existingTema
? existingTema.id
: createClientId(`t-${u.unidad || idx + 1}-${tidx + 1}`)
return {
id: temaId,
nombre: dbTemaNombre,
horasEstimadas:
coerceNumber(
typeof t === 'string' ? undefined : t?.horasEstimadas,
) ?? 0,
}
})
: [], : [],
} }))
})
setUnidades(transformed) setUnidades(transformed)
// Mantener las unidades ya expandidas si existen; si no, expandir la primera.
setExpandedUnits((prev) => { setExpandedUnits((prev) => {
const validIds = new Set(transformed.map((u) => u.id)) const validIds = new Set(transformed.map((u) => u.id))
const filtered = new Set( const filtered = new Set(
Array.from(prev).filter((id) => validIds.has(id)), Array.from(prev).filter((id) => validIds.has(id)),
) )
if (filtered.size > 0) return filtered
// Expandir la primera unidad solo una vez al llegar a la ruta. return transformed.length > 0 ? new Set([transformed[0].id]) : new Set()
// Luego, no auto-expandimos de nuevo (aunque `data` cambie).
if (!didInitExpandedUnitsRef.current && transformed.length > 0) {
return filtered.size > 0 ? filtered : new Set([transformed[0].id])
}
return filtered
}) })
}, [data]) }, [data])
@@ -540,7 +353,7 @@ export function ContenidoTematico() {
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos) // 3. Cálculo de horas (ahora dinámico basado en los nuevos datos)
const totalHoras = unidades.reduce( const totalHoras = unidades.reduce(
(acc, u) => (acc, u) =>
acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas ?? 0), 0), acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0),
0, 0,
) )
@@ -551,22 +364,16 @@ export function ContenidoTematico() {
setExpandedUnits(newExpanded) setExpandedUnits(newExpanded)
} }
const insertUnidadAt = (insertIndex: number) => { const addUnidad = () => {
const newId = createClientId('u') const newNumero = unidades.length + 1
const newId = `u-${newNumero}`
const newUnidad: UnidadTematica = { const newUnidad: UnidadTematica = {
id: newId, id: newId,
nombre: 'Nueva Unidad', nombre: 'Nueva Unidad',
numero: 0, numero: newNumero,
temas: [], temas: [],
} }
const next = [...unidades, newUnidad]
const clampedIndex = Math.max(0, Math.min(insertIndex, unidades.length))
const next = renumberUnidades([
...unidades.slice(0, clampedIndex),
newUnidad,
...unidades.slice(clampedIndex),
])
setUnidades(next) setUnidades(next)
setExpandedUnits((prev) => { setExpandedUnits((prev) => {
const n = new Set(prev) const n = new Set(prev)
@@ -575,40 +382,10 @@ export function ContenidoTematico() {
}) })
setPendingScrollUnitId(newId) setPendingScrollUnitId(newId)
// Abrir edición del título inmediatamente
setEditingUnit(newId) setEditingUnit(newId)
setUnitDraftNombre(newUnidad.nombre) setUnitDraftNombre(newUnidad.nombre)
setUnitOriginalNombre(newUnidad.nombre) setUnitOriginalNombre(newUnidad.nombre)
void persistUnidades(next)
}
const handleReorderEnd = (event: any) => {
if (event?.canceled) return
const source = event?.operation?.source
if (!source) return
// Type-guard nativo de dnd-kit para asegurar que el elemento tiene metadata de orden
if (!isSortable(source)) return
// Extraemos las posiciones exactas calculadas por dnd-kit
const { initialIndex, index } = source.sortable
// Si lo soltó en la misma posición de la que salió, cancelamos
if (initialIndex === index) return
setUnidades((prev) => {
// Hacemos el movimiento usando los índices directos
const moved = arrayMove(prev, initialIndex, index)
const next = renumberUnidades(moved)
// Disparamos la persistencia hacia Supabase
void persistUnidades(next).catch((err) => {
console.error('No se pudo guardar el orden de unidades', err)
})
return next
})
} }
// --- Lógica de Temas --- // --- Lógica de Temas ---
@@ -674,31 +451,15 @@ export function ContenidoTematico() {
</div> </div>
</div> </div>
<DragDropProvider onDragEnd={handleReorderEnd}>
<div className="space-y-4"> <div className="space-y-4">
{unidades.map((unidad, index) => ( {unidades.map((unidad) => (
<SortableUnidad <div
key={unidad.id} key={unidad.id}
id={unidad.id} ref={(el) => {
index={index}
registerContainer={(el) => {
if (el) unitContainerRefs.current.set(unidad.id, el) if (el) unitContainerRefs.current.set(unidad.id, el)
else unitContainerRefs.current.delete(unidad.id) else unitContainerRefs.current.delete(unidad.id)
}} }}
> >
{({ handleRef }) => (
<>
{index === 0 && (
<InsertUnidadOverlay
position="top"
onInsert={() => insertUnidadAt(index)}
/>
)}
<InsertUnidadOverlay
position="bottom"
onInsert={() => insertUnidadAt(index + 1)}
/>
<Card className="overflow-hidden border-slate-200 shadow-sm"> <Card className="overflow-hidden border-slate-200 shadow-sm">
<Collapsible <Collapsible
open={expandedUnits.has(unidad.id)} open={expandedUnits.has(unidad.id)}
@@ -706,19 +467,9 @@ export function ContenidoTematico() {
> >
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3"> <CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <GripVertical className="h-4 w-4 cursor-grab text-slate-300" />
ref={handleRef as any}
className="inline-flex cursor-grab touch-none items-center text-slate-300"
aria-label="Reordenar unidad"
>
<GripVertical className="h-4 w-4" />
</span>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button <Button variant="ghost" size="sm" className="h-auto p-0">
variant="ghost"
size="sm"
className="h-auto cursor-pointer p-0"
>
{expandedUnits.has(unidad.id) ? ( {expandedUnits.has(unidad.id) ? (
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
) : ( ) : (
@@ -734,9 +485,7 @@ export function ContenidoTematico() {
<Input <Input
ref={unitTitleInputRef} ref={unitTitleInputRef}
value={unitDraftNombre} value={unitDraftNombre}
onChange={(e) => onChange={(e) => setUnitDraftNombre(e.target.value)}
setUnitDraftNombre(e.target.value)
}
onBlur={() => { onBlur={() => {
if (cancelNextBlurRef.current) { if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false cancelNextBlurRef.current = false
@@ -769,7 +518,7 @@ export function ContenidoTematico() {
)} )}
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
<span className="flex cursor-default items-center gap-1 text-xs font-medium text-slate-400"> <span className="flex items-center gap-1 text-xs font-medium text-slate-400">
<Clock className="h-3 w-3" />{' '} <Clock className="h-3 w-3" />{' '}
{unidad.temas.reduce( {unidad.temas.reduce(
(sum, t) => sum + (t.horasEstimadas || 0), (sum, t) => sum + (t.horasEstimadas || 0),
@@ -780,12 +529,9 @@ export function ContenidoTematico() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 cursor-pointer text-slate-400 hover:text-red-500" className="h-8 w-8 text-slate-400 hover:text-red-500"
onClick={() => onClick={() =>
setDeleteDialog({ setDeleteDialog({ type: 'unidad', id: unidad.id })
type: 'unidad',
id: unidad.id,
})
} }
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@@ -808,14 +554,10 @@ export function ContenidoTematico() {
} }
draftNombre={temaDraftNombre} draftNombre={temaDraftNombre}
draftHoras={temaDraftHoras} draftHoras={temaDraftHoras}
onBeginEdit={() => onBeginEdit={() => beginEditTema(unidad.id, tema.id)}
beginEditTema(unidad.id, tema.id)
}
onDraftNombreChange={setTemaDraftNombre} onDraftNombreChange={setTemaDraftNombre}
onDraftHorasChange={setTemaDraftHoras} onDraftHorasChange={setTemaDraftHoras}
onEditorBlurCapture={ onEditorBlurCapture={handleTemaEditorBlurCapture}
handleTemaEditorBlurCapture
}
onEditorKeyDownCapture={ onEditorKeyDownCapture={
handleTemaEditorKeyDownCapture handleTemaEditorKeyDownCapture
} }
@@ -834,7 +576,7 @@ export function ContenidoTematico() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="mt-2 w-full cursor-pointer justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700" className="mt-2 w-full justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => addTema(unidad.id)} onClick={() => addTema(unidad.id)}
> >
<Plus className="mr-2 h-3 w-3" /> Añadir subtema <Plus className="mr-2 h-3 w-3" /> Añadir subtema
@@ -844,12 +586,23 @@ export function ContenidoTematico() {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</Card> </Card>
</> </div>
)}
</SortableUnidad>
))} ))}
</div> </div>
</DragDropProvider>
<div className="flex justify-center pt-2">
<Button
variant="outline"
className="gap-2"
onClick={(e) => {
// Evita que Enter vuelva a disparar el click sobre el botón.
e.currentTarget.blur()
addUnidad()
}}
>
<Plus className="h-4 w-4" /> Nueva unidad
</Button>
</div>
<DeleteConfirmDialog <DeleteConfirmDialog
dialog={deleteDialog} dialog={deleteDialog}
@@ -914,9 +667,6 @@ function TemaRow({
<Input <Input
type="number" type="number"
value={draftHoras} value={draftHoras}
min={0}
max={200}
step={0.5}
onChange={(e) => onDraftHorasChange(e.target.value)} onChange={(e) => onDraftHorasChange(e.target.value)}
className="h-8 w-16 bg-white" className="h-8 w-16 bg-white"
/> />
@@ -925,7 +675,7 @@ function TemaRow({
<> <>
<button <button
type="button" type="button"
className="flex flex-1 cursor-pointer items-center gap-3 text-left" className="flex flex-1 items-center gap-3 text-left"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onBeginEdit() onBeginEdit()
@@ -940,7 +690,7 @@ function TemaRow({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 cursor-pointer text-slate-400 hover:text-blue-600" className="h-7 w-7 text-slate-400 hover:text-blue-600"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onBeginEdit() onBeginEdit()
@@ -951,7 +701,7 @@ function TemaRow({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 cursor-pointer text-slate-400 hover:text-red-500" className="h-7 w-7 text-slate-400 hover:text-red-500"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onDelete() onDelete()
@@ -18,8 +18,7 @@ import { Card } from '@/components/ui/card'
interface DocumentoSEPTabProps { interface DocumentoSEPTabProps {
pdfUrl: string | null pdfUrl: string | null
isLoading: boolean isLoading: boolean
onDownloadPdf: () => void onDownload: () => void
onDownloadWord: () => void
onRegenerate: () => void onRegenerate: () => void
isRegenerating: boolean isRegenerating: boolean
} }
@@ -27,8 +26,7 @@ interface DocumentoSEPTabProps {
export function DocumentoSEPTab({ export function DocumentoSEPTab({
pdfUrl, pdfUrl,
isLoading, isLoading,
onDownloadPdf, onDownload,
onDownloadWord,
onRegenerate, onRegenerate,
isRegenerating, isRegenerating,
}: DocumentoSEPTabProps) { }: DocumentoSEPTabProps) {
@@ -54,23 +52,25 @@ export function DocumentoSEPTab({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{pdfUrl && !isLoading && (
<Button variant="outline" onClick={onDownload}>
<Download className="mr-2 h-4 w-4" />
Descargar
</Button>
)}
<AlertDialog <AlertDialog
open={showConfirmDialog} open={showConfirmDialog}
onOpenChange={setShowConfirmDialog} onOpenChange={setShowConfirmDialog}
> >
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button disabled={isRegenerating}>
variant="outline"
size="sm"
className="gap-2"
disabled={isRegenerating}
>
{isRegenerating ? ( {isRegenerating ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
<RefreshCw className="h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
)} )}
{isRegenerating ? 'Generando...' : 'Regenerar'} {isRegenerating ? 'Generando...' : 'Regenerar documento'}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
@@ -91,31 +91,11 @@ export function DocumentoSEPTab({
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{pdfUrl && !isLoading && (
<>
<Button
size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={onDownloadWord}
>
<Download className="h-4 w-4" /> Descargar Word
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={onDownloadPdf}
>
<Download className="h-4 w-4" /> Descargar PDF
</Button>
</>
)}
</div> </div>
</div> </div>
{/* PDF Preview */} {/* PDF Preview */}
<Card className="h-200 overflow-hidden"> <Card className="h-[800px] overflow-hidden">
{isLoading ? ( {isLoading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin" /> <Loader2 className="h-10 w-10 animate-spin" />
File diff suppressed because it is too large Load Diff
@@ -1,209 +0,0 @@
import { Check, Loader2, BookOpen, Clock, ListChecks } from 'lucide-react'
import { useState } from 'react'
import type { IASugerencia } from '@/types/asignatura'
import { Button } from '@/components/ui/button'
import {
useUpdateAsignatura,
useSubject,
useUpdateSubjectRecommendation,
} from '@/data'
import { cn } from '@/lib/utils'
interface ImprovementCardProps {
sug: IASugerencia
asignaturaId: string
onApplied: (campoKey: string) => void
isSelected?: boolean
}
export function ImprovementCard({
sug,
asignaturaId,
onApplied,
}: ImprovementCardProps) {
const { data: asignatura } = useSubject(asignaturaId)
const updateAsignatura = useUpdateAsignatura()
const updateRecommendation = useUpdateSubjectRecommendation()
const [isApplying, setIsApplying] = useState(false)
const handleApply = async () => {
if (!asignatura) return
setIsApplying(true)
try {
// 1. Identificar a qué columna debe ir el guardado
let patchData = {}
if (sug.campoKey === 'contenido_tematico') {
// Se guarda directamente en la columna contenido_tematico
patchData = { contenido_tematico: sug.valorSugerido }
} else if (sug.campoKey === 'criterios_de_evaluacion') {
// Se guarda directamente en la columna criterios_de_evaluacion
patchData = { criterios_de_evaluacion: sug.valorSugerido }
} else {
// Otros campos (ciclo, fines, etc.) se siguen guardando en el JSON de la columna 'datos'
patchData = {
datos: {
...asignatura.datos,
[sug.campoKey]: sug.valorSugerido,
},
}
}
// 2. Ejecutar la actualización con la estructura correcta
await updateAsignatura.mutateAsync({
asignaturaId: asignaturaId as any,
patch: patchData as any,
})
// 3. Marcar la recomendación como aplicada
await updateRecommendation.mutateAsync({
mensajeId: sug.messageId,
campoAfectado: sug.campoKey,
})
console.log(sug.campoKey)
onApplied(sug.campoKey)
} catch (error) {
console.error('Error al aplicar mejora:', error)
} finally {
setIsApplying(false)
}
}
// --- FUNCIÓN PARA RENDERIZAR EL CONTENIDO DE FORMA SEGURA ---
const renderContenido = (valor: any) => {
// Si no es un array, es texto simple
if (!Array.isArray(valor)) {
return <p className="italic">"{String(valor)}"</p>
}
// --- CASO 1: CONTENIDO TEMÁTICO (Detectamos si el primer objeto tiene 'unidad') ---
if (valor[0]?.hasOwnProperty('unidad')) {
return (
<div className="space-y-3">
{valor.map((u: any, idx: number) => (
<div
key={idx}
className="rounded-md border border-teal-100 bg-white p-2 shadow-sm"
>
<div className="mb-1 flex items-center gap-2 border-b border-slate-50 pb-1 text-[11px] font-bold text-teal-800">
<BookOpen size={12} /> Unidad {u.unidad}: {u.titulo}
</div>
<ul className="space-y-1">
{u.temas?.map((t: any, tidx: number) => (
<li
key={tidx}
className="flex items-start justify-between gap-2 text-[10px] text-slate-600"
>
<span className="leading-tight"> {t.nombre}</span>
<span className="flex shrink-0 items-center gap-0.5 font-mono text-slate-400">
<Clock size={10} /> {t.horasEstimadas}h
</span>
</li>
))}
</ul>
</div>
))}
</div>
)
}
// --- CASO 2: CRITERIOS DE EVALUACIÓN (Detectamos si tiene 'criterio') ---
if (valor[0]?.hasOwnProperty('criterio')) {
return (
<div className="space-y-2">
<div className="mb-1 flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase">
<ListChecks size={12} /> Desglose de evaluación
</div>
{valor.map((c: any, idx: number) => (
<div
key={idx}
className="flex items-center justify-between gap-3 rounded-md border border-slate-100 bg-white p-2 shadow-sm"
>
<span className="text-[11px] leading-tight text-slate-700">
{c.criterio}
</span>
<div className="flex shrink-0 items-center gap-1 rounded-full border border-orange-100 bg-orange-50 px-2 py-0.5 text-[10px] font-bold text-orange-600">
{c.porcentaje}%
</div>
</div>
))}
{/* Opcional: Suma total para verificar que de 100% */}
<div className="pt-1 text-right text-[9px] font-medium text-slate-400">
Total:{' '}
{valor.reduce(
(acc: number, curr: any) => acc + (curr.porcentaje || 0),
0,
)}
%
</div>
</div>
)
}
// Caso por defecto (Array genérico)
return (
<pre className="text-[10px]">
{/* JSON.stringify(valor, null, 2)*/ 'hola'}
</pre>
)
}
// --- ESTADO APLICADO ---
if (sug.aceptada) {
return (
<div className="flex flex-col rounded-xl border border-slate-100 bg-white p-3 opacity-80 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-4">
<span className="text-sm font-bold text-slate-800">
{sug.campoNombre}
</span>
<div className="flex items-center gap-1.5 rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-400">
<Check size={14} />
Aplicado
</div>
</div>
<div className="rounded-lg border border-teal-100 bg-teal-50/30 p-3 text-xs leading-relaxed text-slate-500">
{renderContenido(sug.valorSugerido)}
</div>
</div>
)
}
// --- ESTADO PENDIENTE ---
return (
<div className="group flex flex-col rounded-xl border border-teal-100 bg-white p-3 shadow-sm transition-all hover:border-teal-200">
<div className="mb-3 flex items-center justify-between gap-4">
<span className="max-w-[150px] truncate rounded-lg border border-teal-100 bg-teal-50/50 px-2.5 py-1 text-[10px] font-bold tracking-wider text-teal-700 uppercase">
{sug.campoNombre}
</span>
<Button
size="sm"
disabled={isApplying || !asignatura}
className="h-8 w-auto bg-teal-600 px-4 text-xs font-semibold shadow-sm hover:bg-teal-700"
onClick={handleApply}
>
{isApplying ? (
<Loader2 size={14} className="mr-1.5 animate-spin" />
) : (
<Check size={14} className="mr-1.5" />
)}
{isApplying ? 'Aplicando...' : 'Aplicar mejora'}
</Button>
</div>
<div
className={cn(
'rounded-lg border border-dashed border-slate-200 bg-slate-50/50 p-3 text-xs leading-relaxed text-slate-600',
!Array.isArray(sug.valorSugerido) && 'line-clamp-4 italic',
)}
>
{renderContenido(sug.valorSugerido)}
</div>
</div>
)
}
+34 -4
View File
@@ -1,18 +1,44 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
// import { supabase } from '@/lib/supabase'
import { LoginInput } from '../ui/LoginInput' import { LoginInput } from '../ui/LoginInput'
import { SubmitButton } from '../ui/SubmitButton' import { SubmitButton } from '../ui/SubmitButton'
import { throwIfError } from '@/data/api/_helpers'
import { qk } from '@/data/query/keys'
import { supabaseBrowser } from '@/data/supabase/client'
export function ExternalLoginForm() { export function ExternalLoginForm() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const qc = useQueryClient()
const navigate = useNavigate({ from: '/login' })
const supabase = supabaseBrowser()
const submit = async () => { const submit = async () => {
/* await supabase.auth.signInWithPassword({ setIsLoading(true)
setError(null)
try {
const { error } = await supabase.auth.signInWithPassword({
email, email,
password, password,
})*/ })
throwIfError(error)
qc.invalidateQueries({ queryKey: qk.session() })
qc.invalidateQueries({ queryKey: qk.auth })
await navigate({ to: '/dashboard', replace: true })
} catch (e: unknown) {
const anyErr = e as any
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
} finally {
setIsLoading(false)
}
} }
return ( return (
@@ -34,7 +60,11 @@ export function ExternalLoginForm() {
value={password} value={password}
onChange={setPassword} onChange={setPassword}
/> />
<SubmitButton /> {error ? <p className="text-sm text-red-600">{error}</p> : null}
<SubmitButton
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
disabled={isLoading}
/>
</form> </form>
) )
} }
+36 -5
View File
@@ -1,18 +1,45 @@
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react' import { useState } from 'react'
// import { supabase } from '@/lib/supabase'
import { LoginInput } from '../ui/LoginInput' import { LoginInput } from '../ui/LoginInput'
import { SubmitButton } from '../ui/SubmitButton' import { SubmitButton } from '../ui/SubmitButton'
import { throwIfError } from '@/data/api/_helpers'
import { qk } from '@/data/query/keys'
import { supabaseBrowser } from '@/data/supabase/client'
export function InternalLoginForm() { export function InternalLoginForm() {
const [clave, setClave] = useState('') const [clave, setClave] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const qc = useQueryClient()
const navigate = useNavigate({ from: '/login' })
const supabase = supabaseBrowser()
const submit = async () => { const submit = async () => {
/* await supabase.auth.signInWithPassword({ setIsLoading(true)
email: `${clave}@ulsa.mx`, setError(null)
try {
const email = clave.includes('@') ? clave : `${clave}@ulsa.mx`
const { error } = await supabase.auth.signInWithPassword({
email,
password, password,
})*/ })
throwIfError(error)
qc.invalidateQueries({ queryKey: qk.session() })
qc.invalidateQueries({ queryKey: qk.auth })
await navigate({ to: '/dashboard', replace: true })
} catch (e: unknown) {
const anyErr = e as any
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
} finally {
setIsLoading(false)
}
} }
return ( return (
@@ -30,7 +57,11 @@ export function InternalLoginForm() {
value={password} value={password}
onChange={setPassword} onChange={setPassword}
/> />
<SubmitButton /> {error ? <p className="text-sm text-red-600">{error}</p> : null}
<SubmitButton
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
disabled={isLoading}
/>
</form> </form>
) )
} }
@@ -8,7 +8,6 @@ export const ImprovementCard = ({
suggestions, suggestions,
onApply, onApply,
planId, planId,
dbMessageId,
currentDatos, currentDatos,
activeChatId, activeChatId,
onApplySuccess, onApplySuccess,
@@ -17,7 +16,6 @@ export const ImprovementCard = ({
onApply?: (key: string, value: string) => void onApply?: (key: string, value: string) => void
planId: string planId: string
currentDatos: any currentDatos: any
dbMessageId: string
activeChatId: any activeChatId: any
onApplySuccess?: (key: string) => void onApplySuccess?: (key: string) => void
}) => { }) => {
@@ -55,11 +53,9 @@ export const ImprovementCard = ({
setLocalApplied((prev) => [...prev, key]) setLocalApplied((prev) => [...prev, key])
if (onApplySuccess) onApplySuccess(key) if (onApplySuccess) onApplySuccess(key)
if (activeChatId) {
// --- CAMBIO AQUÍ: Ahora enviamos el ID del mensaje ---
if (dbMessageId) {
updateAppliedStatus.mutate({ updateAppliedStatus.mutate({
conversacionId: dbMessageId, // Cambiamos el nombre de la propiedad si es necesario conversacionId: activeChatId,
campoAfectado: key, campoAfectado: key,
}) })
} }
+4 -3
View File
@@ -1,13 +1,14 @@
interface Props { interface Props {
text?: string text?: string
disabled?: boolean
} }
export function SubmitButton({ text = 'Iniciar sesión' }: Props) { export function SubmitButton({ text = 'Iniciar sesión', disabled }: Props) {
return ( return (
<button <button
type="submit" type="submit"
className="w-full bg-[#7b0f1d] text-white py-2 rounded-lg disabled={disabled}
font-semibold hover:opacity-90 transition" className="w-full rounded-lg bg-[#7b0f1d] py-2 font-semibold text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
> >
{text} {text}
</button> </button>
-43
View File
@@ -1,43 +0,0 @@
import * as React from "react"
import { CircleIcon } from "lucide-react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+6 -12
View File
@@ -1,24 +1,18 @@
import * as React from 'react' import * as React from "react"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils"
const Textarea = React.forwardRef< function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
HTMLTextAreaElement,
React.ComponentProps<'textarea'>
>(({ className, ...props }, ref) => {
return ( return (
<textarea <textarea
ref={ref}
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className, className
)} )}
{...props} {...props}
/> />
) )
}) }
Textarea.displayName = 'Textarea'
export { Textarea } export { Textarea }
@@ -5,24 +5,16 @@ export function WizardResponsiveHeader({
wizard, wizard,
methods, methods,
titleOverrides, titleOverrides,
hiddenStepIds,
}: { }: {
wizard: any wizard: any
methods: any methods: any
titleOverrides?: Record<string, string> titleOverrides?: Record<string, string>
hiddenStepIds?: Array<string>
}) { }) {
const hidden = new Set(hiddenStepIds ?? []) const idx = wizard.utils.getIndex(methods.current.id)
const visibleSteps = (wizard.steps as Array<any>).filter( const totalSteps = wizard.steps.length
(s) => s && !hidden.has(s.id), const currentIndex = idx + 1
) const hasNextStep = idx < totalSteps - 1
const nextStep = wizard.steps[currentIndex]
const idx = visibleSteps.findIndex((s) => s.id === methods.current.id)
const safeIdx = idx >= 0 ? idx : 0
const totalSteps = visibleSteps.length
const currentIndex = Math.min(safeIdx + 1, totalSteps)
const hasNextStep = safeIdx < totalSteps - 1
const nextStep = visibleSteps[safeIdx + 1]
const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title
@@ -53,11 +45,10 @@ export function WizardResponsiveHeader({
<div className="hidden sm:block"> <div className="hidden sm:block">
<wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2"> <wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
{visibleSteps.map((step: any, visibleIdx: number) => ( {wizard.steps.map((step: any) => (
<wizard.Stepper.Step <wizard.Stepper.Step
key={step.id} key={step.id}
of={step.id} of={step.id}
icon={visibleIdx + 1}
className="whitespace-nowrap" className="whitespace-nowrap"
> >
<wizard.Stepper.Title> <wizard.Stepper.Title>
+26 -165
View File
@@ -100,7 +100,7 @@ export async function library_search(payload: {
export async function create_conversation(planId: string) { export async function create_conversation(planId: string) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke( const { data, error } = await supabase.functions.invoke(
'create-chat-conversation/plan/conversations', 'create-chat-conversation/conversations',
{ {
method: 'POST', method: 'POST',
body: { body: {
@@ -149,7 +149,7 @@ export async function ai_plan_chat_v2(payload: {
}): Promise<{ reply: string; meta?: any }> { }): Promise<{ reply: string; meta?: any }> {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke( const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/plan/${payload.conversacionId}/messages`, `create-chat-conversation/conversations/${payload.conversacionId}/messages`,
{ {
method: 'POST', method: 'POST',
body: { body: {
@@ -175,22 +175,6 @@ export async function getConversationByPlan(planId: string) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return data ?? [] return data ?? []
} }
export async function getMessagesByConversation(conversationId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('plan_mensajes_ia')
.select('*')
.eq('conversacion_plan_id', conversationId)
.order('fecha_creacion', { ascending: true }) // Ascendente para que el chat fluya en orden cronológico
if (error) {
console.error('Error al obtener mensajes:', error.message)
throw error
}
return data ?? []
}
export async function update_conversation_title( export async function update_conversation_title(
conversacionId: string, conversacionId: string,
@@ -210,168 +194,45 @@ export async function update_conversation_title(
} }
export async function update_recommendation_applied_status( export async function update_recommendation_applied_status(
mensajeId: string, // Ahora es más eficiente usar el ID del mensaje directamente conversacionId: string,
campoAfectado: string, campoAfectado: string,
) { ) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
// 1. Obtener la propuesta actual de ese mensaje específico // 1. Obtener el estado actual del JSON
const { data: msgData, error: fetchError } = await supabase const { data: conv, error: fetchError } = await supabase
.from('plan_mensajes_ia') .from('conversaciones_plan')
.select('propuesta') .select('conversacion_json')
.eq('id', mensajeId) .eq('id', conversacionId)
.single() .single()
if (fetchError) throw fetchError if (fetchError) throw fetchError
if (!msgData?.propuesta) if (!conv.conversacion_json) throw new Error('No se encontró la conversación')
throw new Error('No se encontró la propuesta en el mensaje')
const propuestaActual = msgData.propuesta as any // 2. Transformar el JSON para marcar como aplicada la recomendación específica
// Usamos una transformación inmutable para evitar efectos secundarios
// 2. Modificar el array de recommendations dentro de la propuesta const nuevoJson = (conv.conversacion_json as Array<any>).map((msg) => {
// Mantenemos el resto de la propuesta (prompt, respuesta, etc.) intacto if (msg.user === 'assistant' && Array.isArray(msg.recommendations)) {
const nuevaPropuesta = { return {
...propuestaActual, ...msg,
recommendations: (propuestaActual.recommendations || []).map((rec: any) => recommendations: msg.recommendations.map((rec: any) =>
rec.campo_afectado === campoAfectado ? { ...rec, aplicada: true } : rec, rec.campo_afectado === campoAfectado
? { ...rec, aplicada: true }
: rec,
), ),
} }
// 3. Actualizar la base de datos con el nuevo objeto JSON
const { error: updateError } = await supabase
.from('plan_mensajes_ia')
.update({ propuesta: nuevaPropuesta })
.eq('id', mensajeId)
if (updateError) throw updateError
return true
}
// --- FUNCIONES DE ASIGNATURA ---
export async function create_subject_conversation(subjectId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
'create-chat-conversation/asignatura/conversations', // Ruta corregida
{
method: 'POST',
body: {
asignatura_id: subjectId,
instanciador: 'alex',
},
},
)
if (error) throw error
return data // Retorna { conversation_asignatura: { id, ... } }
}
export async function ai_subject_chat_v2(payload: {
conversacionId: string
content: string
campos?: Array<string>
}) {
const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/asignatura/${payload.conversacionId}/messages`, // Ruta corregida
{
method: 'POST',
body: {
content: payload.content,
campos: payload.campos || [],
},
},
)
if (error) throw error
return data
}
export async function getConversationBySubject(subjectId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_asignatura') // Tabla corregida
.select('*')
.eq('asignatura_id', subjectId)
.order('creado_en', { ascending: false })
if (error) throw error
return data ?? []
}
export async function getMessagesBySubjectConversation(conversationId: string) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('asignatura_mensajes_ia' as any)
.select('*')
.eq('conversacion_asignatura_id', conversationId)
.order('fecha_creacion', { ascending: true })
if (error) throw error
return data ?? []
}
export async function update_subject_recommendation_applied(
mensajeId: string,
campoAfectado: string,
) {
const supabase = supabaseBrowser()
// 1. Obtener propuesta actual
const { data: msgData, error: fetchError } = await supabase
.from('asignatura_mensajes_ia')
.select('propuesta')
.eq('id', mensajeId)
.single()
if (fetchError) throw fetchError
const propuestaActual = msgData?.propuesta as any
// 2. Marcar como aplicada
const nuevaPropuesta = {
...propuestaActual,
recommendations: (propuestaActual.recommendations || []).map((rec: any) =>
rec.campo_afectado === campoAfectado ? { ...rec, aplicada: true } : rec,
),
} }
return msg
})
// 3. Update // 3. Actualizar la base de datos con el nuevo JSON
const { error: updateError } = await supabase const { data, error: updateError } = await supabase
.from('asignatura_mensajes_ia') .from('conversaciones_plan')
.update({ propuesta: nuevaPropuesta }) .update({ conversacion_json: nuevoJson })
.eq('id', mensajeId)
if (updateError) throw updateError
return true
}
export async function update_subject_conversation_status(
conversacionId: string,
nuevoEstado: 'ARCHIVADA' | 'ACTIVA',
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_asignatura')
.update({ estado: nuevoEstado })
.eq('id', conversacionId) .eq('id', conversacionId)
.select() .select()
.single() .single()
if (error) throw error if (updateError) throw updateError
return data
}
export async function update_subject_conversation_name(
conversacionId: string,
nuevoNombre: string,
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_asignatura')
.update({ nombre: nuevoNombre }) // Asumiendo que la columna es 'titulo' según tu código previo, o cambia a 'nombre'
.eq('id', conversacionId)
.select()
.single()
if (error) throw error
return data return data
} }
+10 -69
View File
@@ -1,86 +1,27 @@
// document.api.ts // document.api.ts
import { supabaseBrowser } from '../supabase/client' const DOCUMENT_PDF_URL =
import { invokeEdge } from '../supabase/invokeEdge' 'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
import { requireData, throwIfError } from './_helpers'
import type { Tables } from '@/types/supabase'
const EDGE = {
carbone_io_wrapper: 'carbone-io-wrapper',
} as const
interface GeneratePdfParams { interface GeneratePdfParams {
plan_estudio_id: string plan_estudio_id: string
convertTo?: 'pdf'
}
interface GeneratePdfParamsAsignatura {
asignatura_id: string
convertTo?: 'pdf'
} }
export async function fetchPlanPdf({ export async function fetchPlanPdf({
plan_estudio_id, plan_estudio_id,
convertTo,
}: GeneratePdfParams): Promise<Blob> { }: GeneratePdfParams): Promise<Blob> {
return await invokeEdge<Blob>( const response = await fetch(DOCUMENT_PDF_URL, {
EDGE.carbone_io_wrapper, method: 'POST',
{
action: 'downloadReport',
plan_estudio_id,
body: convertTo ? { convertTo } : {},
},
{
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
responseType: 'blob', body: JSON.stringify({ plan_estudio_id }),
}, })
)
}
export async function fetchAsignaturaPdf({ if (!response.ok) {
asignatura_id, throw new Error('Error al generar el PDF')
convertTo,
}: GeneratePdfParamsAsignatura): Promise<Blob> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('asignaturas')
.select('*')
.eq('id', asignatura_id)
.single()
throwIfError(error)
const row = requireData(
data as Pick<
Tables<'asignaturas'>,
'datos' | 'contenido_tematico' | 'criterios_de_evaluacion'
>,
'Asignatura no encontrada',
)
const body: Record<string, unknown> = {
data: row,
} }
if (convertTo) body.convertTo = convertTo
return await invokeEdge<Blob>( // n8n devuelve el archivo → lo tratamos como blob
EDGE.carbone_io_wrapper, return await response.blob()
{
action: 'downloadReport',
asignatura_id,
body: {
...body,
},
},
{
headers: {
'Content-Type': 'application/json',
},
responseType: 'blob',
},
)
} }
+1 -1
View File
@@ -207,7 +207,7 @@ export async function plan_asignaturas_list(
const { data, error } = await supabase const { data, error } = await supabase
.from('asignaturas') .from('asignaturas')
.select( .select(
'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,prerrequisito_asignatura_id', 'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en',
) )
.eq('plan_estudio_id', planId) .eq('plan_estudio_id', planId)
.order('numero_ciclo', { ascending: true, nullsFirst: false }) .order('numero_ciclo', { ascending: true, nullsFirst: false })
+13 -88
View File
@@ -19,7 +19,7 @@ import type {
AsignaturaSugerida, AsignaturaSugerida,
DataAsignaturaSugerida, DataAsignaturaSugerida,
} from '@/features/asignaturas/nueva/types' } from '@/features/asignaturas/nueva/types'
import type { Database, Tables, TablesInsert } from '@/types/supabase' import type { Database, TablesInsert } from '@/types/supabase'
const EDGE = { const EDGE = {
generate_subject_suggestions: 'generate-subject-suggestions', generate_subject_suggestions: 'generate-subject-suggestions',
@@ -29,9 +29,6 @@ const EDGE = {
subjects_clone_from_existing: 'subjects_clone_from_existing', subjects_clone_from_existing: 'subjects_clone_from_existing',
subjects_import_from_file: 'subjects_import_from_file', subjects_import_from_file: 'subjects_import_from_file',
// Bibliografía
buscar_bibliografia: 'buscar-bibliografia',
subjects_update_fields: 'subjects_update_fields', subjects_update_fields: 'subjects_update_fields',
subjects_update_bibliografia: 'subjects_update_bibliografia', subjects_update_bibliografia: 'subjects_update_bibliografia',
@@ -39,82 +36,6 @@ const EDGE = {
subjects_get_document: 'subjects_get_document', subjects_get_document: 'subjects_get_document',
} as const } as const
export type BuscarBibliografiaRequest = {
searchTerms: {
q: string
}
google: {
orderBy?: 'newest' | 'relevance'
langRestrict?: string
startIndex?: number
[k: string]: unknown
}
openLibrary: {
language?: string
page?: number
sort?: string
[k: string]: unknown
}
}
export type GoogleBooksVolume = {
kind?: 'books#volume'
id: string
etag?: string
selfLink?: string
volumeInfo?: {
title?: string
subtitle?: string
authors?: Array<string>
publisher?: string
publishedDate?: string
description?: string
industryIdentifiers?: Array<{ type?: string; identifier?: string }>
pageCount?: number
categories?: Array<string>
language?: string
previewLink?: string
infoLink?: string
canonicalVolumeLink?: string
imageLinks?: {
smallThumbnail?: string
thumbnail?: string
small?: string
medium?: string
large?: string
extraLarge?: string
}
}
searchInfo?: {
textSnippet?: string
}
[k: string]: unknown
}
export type OpenLibraryDoc = Record<string, unknown>
export type EndpointResult =
| { endpoint: 'google'; item: GoogleBooksVolume }
| { endpoint: 'open_library'; item: OpenLibraryDoc }
export async function buscar_bibliografia(
input: BuscarBibliografiaRequest,
): Promise<Array<EndpointResult>> {
const q = input.searchTerms.q
if (typeof q !== 'string' || q.trim().length < 1) {
throw new Error('q es requerido')
}
return await invokeEdge<Array<EndpointResult>>(
EDGE.buscar_bibliografia,
input,
{ headers: { 'Content-Type': 'application/json' } },
)
}
export type ContenidoTemaApi = export type ContenidoTemaApi =
| string | string
| { | {
@@ -171,7 +92,7 @@ export type PlanEstudioInSubject = Pick<
export type EstructuraAsignaturaInSubject = Pick< export type EstructuraAsignaturaInSubject = Pick<
EstructuraAsignatura, EstructuraAsignatura,
'id' | 'nombre' | 'definicion' 'id' | 'nombre' | 'version' | 'definicion'
> >
/** /**
@@ -191,12 +112,12 @@ export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
.from('asignaturas') .from('asignaturas')
.select( .select(
` `
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,prerrequisito_asignatura_id, id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
planes_estudio( planes_estudio(
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)) carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
), ),
estructuras_asignatura(id,nombre,definicion) estructuras_asignatura(id,nombre,version,definicion)
`, `,
) )
.eq('id', subjectId) .eq('id', subjectId)
@@ -232,7 +153,7 @@ export async function subjects_bibliografia_list(
const { data, error } = await supabase const { data, error } = await supabase
.from('bibliografia_asignatura') .from('bibliografia_asignatura')
.select( .select(
'id,asignatura_id,tipo,cita,referencia_biblioteca,referencia_en_linea,creado_por,creado_en,actualizado_en', 'id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en',
) )
.eq('asignatura_id', subjectId) .eq('asignatura_id', subjectId)
.order('tipo', { ascending: true }) .order('tipo', { ascending: true })
@@ -542,9 +463,13 @@ export async function lineas_delete(lineaId: string) {
return lineaId return lineaId
} }
export async function bibliografia_insert( export async function bibliografia_insert(entry: {
entry: TablesInsert<'bibliografia_asignatura'>, asignatura_id: string
): Promise<Tables<'bibliografia_asignatura'>> { tipo: 'BASICA' | 'COMPLEMENTARIA'
cita: string
tipo_fuente: 'MANUAL' | 'BIBLIOTECA'
biblioteca_item_id?: string | null
}) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase const { data, error } = await supabase
.from('bibliografia_asignatura') .from('bibliografia_asignatura')
@@ -553,7 +478,7 @@ export async function bibliografia_insert(
.single() .single()
if (error) throw error if (error) throw error
return data as Tables<'bibliografia_asignatura'> return data
} }
export async function bibliografia_update( export async function bibliografia_update(
+7 -205
View File
@@ -1,9 +1,9 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { import {
ai_plan_chat_v2, ai_plan_chat_v2,
ai_plan_improve, ai_plan_improve,
ai_subject_chat,
ai_subject_improve, ai_subject_improve,
create_conversation, create_conversation,
get_chat_history, get_chat_history,
@@ -12,18 +12,10 @@ import {
update_conversation_status, update_conversation_status,
update_recommendation_applied_status, update_recommendation_applied_status,
update_conversation_title, update_conversation_title,
getMessagesByConversation,
update_subject_conversation_status,
update_subject_recommendation_applied,
getMessagesBySubjectConversation,
getConversationBySubject,
ai_subject_chat_v2,
create_subject_conversation,
update_subject_conversation_name,
} from '../api/ai.api' } from '../api/ai.api'
import { supabaseBrowser } from '../supabase/client'
import type { UUID } from 'node:crypto' // eslint-disable-next-line node/prefer-node-protocol
import type { UUID } from 'crypto'
export function useAIPlanImprove() { export function useAIPlanImprove() {
return useMutation({ mutationFn: ai_plan_improve }) return useMutation({ mutationFn: ai_plan_improve })
@@ -96,61 +88,6 @@ export function useConversationByPlan(planId: string | null) {
}) })
} }
export function useMessagesByChat(conversationId: string | null) {
const queryClient = useQueryClient()
const supabase = supabaseBrowser()
const query = useQuery({
queryKey: ['conversation-messages', conversationId],
queryFn: () => {
if (!conversationId) throw new Error('Conversation ID is required')
return getMessagesByConversation(conversationId)
},
enabled: !!conversationId,
placeholderData: (previousData) => previousData,
})
useEffect(() => {
if (!conversationId) return
// Suscribirse a cambios en los mensajes de ESTA conversación
const channel = supabase
.channel(`realtime-messages-${conversationId}`)
.on(
'postgres_changes',
{
event: '*', // Escuchamos INSERT y UPDATE
schema: 'public',
table: 'plan_mensajes_ia',
filter: `conversacion_plan_id=eq.${conversationId}`,
},
(payload) => {
// Opción A: Invalidar la query para que React Query haga refetch (más seguro)
queryClient.invalidateQueries({
queryKey: ['conversation-messages', conversationId],
})
/* Opción B: Actualización manual del caché (más rápido/fluido)
if (payload.eventType === 'INSERT') {
queryClient.setQueryData(['conversation-messages', conversationId], (old: any) => [...old, payload.new])
} else if (payload.eventType === 'UPDATE') {
queryClient.setQueryData(['conversation-messages', conversationId], (old: any) =>
old.map((m: any) => m.id === payload.new.id ? payload.new : m)
)
}
*/
},
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [conversationId, queryClient, supabase])
return query
}
export function useUpdateRecommendationApplied() { export function useUpdateRecommendationApplied() {
const qc = useQueryClient() const qc = useQueryClient()
@@ -180,6 +117,10 @@ export function useAISubjectImprove() {
return useMutation({ mutationFn: ai_subject_improve }) return useMutation({ mutationFn: ai_subject_improve })
} }
export function useAISubjectChat() {
return useMutation({ mutationFn: ai_subject_chat })
}
export function useLibrarySearch() { export function useLibrarySearch() {
return useMutation({ mutationFn: library_search }) return useMutation({ mutationFn: library_search })
} }
@@ -196,142 +137,3 @@ export function useUpdateConversationTitle() {
}, },
}) })
} }
// Asignaturas
export function useAISubjectChat() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (payload: {
subjectId: UUID
content: string
campos?: Array<string>
conversacionId?: string
}) => {
let currentId = payload.conversacionId
// 1. Si no hay ID, creamos la conversación de asignatura
if (!currentId) {
const response = await create_subject_conversation(payload.subjectId)
currentId = response.conversation_asignatura.id
}
// 2. Enviamos mensaje al endpoint de asignatura
const result = await ai_subject_chat_v2({
conversacionId: currentId!,
content: payload.content,
campos: payload.campos,
})
return { ...result, conversacionId: currentId }
},
onSuccess: (data) => {
// Invalidamos mensajes para que se refresque el chat
qc.invalidateQueries({
queryKey: ['subject-messages', data.conversacionId],
})
},
})
}
export function useConversationBySubject(subjectId: string | null) {
return useQuery({
queryKey: ['conversation-by-subject', subjectId],
queryFn: () => getConversationBySubject(subjectId!),
enabled: !!subjectId,
})
}
export function useMessagesBySubjectChat(conversationId: string | null) {
const queryClient = useQueryClient()
const query = useQuery({
queryKey: ['subject-messages', conversationId],
queryFn: async () => {
if (!conversationId) throw new Error('Conversation ID is required')
return getMessagesBySubjectConversation(conversationId)
},
enabled: !!conversationId,
placeholderData: (previousData) => previousData,
})
useEffect(() => {
if (!conversationId) return
const supabase = supabaseBrowser()
// Suscripción a cambios en la tabla específica para esta conversación
const channel = supabase
.channel(`subject_messages_${conversationId}`)
.on(
'postgres_changes',
{
event: 'UPDATE', // Solo nos interesan las actualizaciones (cuando pasa de PROCESANDO a COMPLETADO)
schema: 'public',
table: 'asignatura_mensajes_ia',
filter: `conversacion_asignatura_id=eq.${conversationId}`,
},
(payload) => {
// Si el mensaje se completó o dio error, invalidamos la caché para traer los datos nuevos
if (
payload.new.estado === 'COMPLETADO' ||
payload.new.estado === 'ERROR'
) {
queryClient.invalidateQueries({
queryKey: ['subject-messages', conversationId],
})
}
},
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [conversationId, queryClient])
return query
}
export function useUpdateSubjectRecommendation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (payload: { mensajeId: string; campoAfectado: string }) =>
update_subject_recommendation_applied(
payload.mensajeId,
payload.campoAfectado,
),
onSuccess: () => {
// Refrescamos los mensajes para ver el check de "aplicado"
qc.invalidateQueries({ queryKey: ['subject-messages'] })
},
})
}
export function useUpdateSubjectConversationStatus() {
const qc = useQueryClient()
return useMutation({
mutationFn: (payload: { id: string; estado: 'ARCHIVADA' | 'ACTIVA' }) =>
update_subject_conversation_status(payload.id, payload.estado),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['conversation-by-subject'] })
},
})
}
export function useUpdateSubjectConversationName() {
const qc = useQueryClient()
return useMutation({
mutationFn: (payload: { id: string; nombre: string }) =>
update_subject_conversation_name(payload.id, payload.nombre),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['conversation-by-subject'] })
// También invalidamos los mensajes si el título se muestra en la cabecera
qc.invalidateQueries({ queryKey: ['subject-messages'] })
},
})
}
+117 -31
View File
@@ -1,59 +1,145 @@
import { useEffect } from "react"; import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect } from 'react'
import { supabaseBrowser } from "../supabase/client";
import { qk } from "../query/keys"; import { throwIfError } from '../api/_helpers'
import { throwIfError } from "../api/_helpers"; import { qk } from '../query/keys'
import { supabaseBrowser } from '../supabase/client'
export function useSession() { export function useSession() {
const supabase = supabaseBrowser(); const supabase = supabaseBrowser()
const qc = useQueryClient(); const qc = useQueryClient()
const query = useQuery({ const query = useQuery({
queryKey: qk.session(), queryKey: qk.session(),
queryFn: async () => { queryFn: async () => {
const { data, error } = await supabase.auth.getSession(); const { data, error } = await supabase.auth.getSession()
throwIfError(error); throwIfError(error)
return data.session ?? null; return data.session ?? null
}, },
staleTime: Infinity, staleTime: Infinity,
}); })
useEffect(() => { useEffect(() => {
const { data } = supabase.auth.onAuthStateChange(() => { const { data } = supabase.auth.onAuthStateChange(() => {
qc.invalidateQueries({ queryKey: qk.session() }); qc.invalidateQueries({ queryKey: qk.session() })
qc.invalidateQueries({ queryKey: qk.meProfile() }); qc.invalidateQueries({ queryKey: qk.meProfile() })
qc.invalidateQueries({ queryKey: qk.auth }); qc.invalidateQueries({ queryKey: qk.meAccess() })
}); qc.invalidateQueries({ queryKey: qk.auth })
})
return () => data.subscription.unsubscribe(); return () => data.subscription.unsubscribe()
}, [supabase, qc]); }, [supabase, qc])
return query; return query
} }
export function useMeProfile() { export function useMeProfile() {
const supabase = supabaseBrowser(); const supabase = supabaseBrowser()
return useQuery({ return useQuery({
queryKey: qk.meProfile(), queryKey: qk.meProfile(),
queryFn: async () => { queryFn: async () => {
const { data: u, error: uErr } = await supabase.auth.getUser(); const { data: u, error: uErr } = await supabase.auth.getUser()
throwIfError(uErr); throwIfError(uErr)
const userId = u.user?.id; const userId = u.user?.id
if (!userId) return null; if (!userId) return null
const { data, error } = await supabase const { data, error } = await supabase
.from("usuarios_app") .from('usuarios_app')
.select("id,nombre_completo,email,externo,creado_en,actualizado_en") .select('id,nombre_completo,email,externo,creado_en,actualizado_en')
.eq("id", userId) .eq('id', userId)
.single(); .single()
// si aún no existe perfil en usuarios_app, permite null (tu seed/trigger puede crearlo) // si aún no existe perfil en usuarios_app, permite null (tu seed/trigger puede crearlo)
if (error && (error as any).code === "PGRST116") return null; if (error && (error as any).code === 'PGRST116') return null
throwIfError(error); throwIfError(error)
return data ?? null; return data ?? null
}, },
staleTime: 60_000, staleTime: 60_000,
}); })
}
export type MeAccessRole = {
assignmentId: string
rolId: string
clave: string
nombre: string
descripcion: string | null
facultadId: string | null
carreraId: string | null
}
export type MeAccess = {
userId: string
roles: Array<MeAccessRole>
permissions: Array<string>
}
/**
* Database-first RBAC: obtiene roles del usuario desde tablas app (NO desde JWT).
*
* Nota: el esquema actual modela roles con `usuarios_roles` -> `roles`.
*/
export function useMeAccess() {
const supabase = supabaseBrowser()
return useQuery({
queryKey: qk.meAccess(),
queryFn: async (): Promise<MeAccess | null> => {
const { data: u, error: uErr } = await supabase.auth.getUser()
throwIfError(uErr)
const userId = u.user?.id
if (!userId) return null
const { data, error } = await supabase
.from('usuarios_roles')
.select(
'id,rol_id,facultad_id,carrera_id,roles(id,clave,nombre,descripcion)',
)
.eq('usuario_id', userId)
throwIfError(error)
const roles: Array<MeAccessRole> = (data ?? [])
.map((row: any) => {
const rol = row.roles
if (!rol) return null
return {
assignmentId: row.id,
rolId: rol.id,
clave: rol.clave,
nombre: rol.nombre,
descripcion: rol.descripcion ?? null,
facultadId: row.facultad_id ?? null,
carreraId: row.carrera_id ?? null,
} satisfies MeAccessRole
})
.filter(Boolean) as Array<MeAccessRole>
// Por ahora, los permisos granulares se derivan de claves de rol.
// Si luego existe una tabla `roles_permisos`, aquí se expande a permisos reales.
const permissions = Array.from(new Set(roles.map((r) => r.clave)))
return {
userId,
roles,
permissions,
}
},
staleTime: 30_000,
refetchOnWindowFocus: true,
})
}
export function useAuth() {
const session = useSession()
const meProfile = useMeProfile()
const meAccess = useMeAccess()
return {
session,
meProfile,
meAccess,
}
} }
+1
View File
@@ -2,6 +2,7 @@ export const qk = {
auth: ['auth'] as const, auth: ['auth'] as const,
session: () => ['auth', 'session'] as const, session: () => ['auth', 'session'] as const,
meProfile: () => ['auth', 'meProfile'] as const, meProfile: () => ['auth', 'meProfile'] as const,
meAccess: () => ['auth', 'meAccess'] as const,
facultades: () => ['meta', 'facultades'] as const, facultades: () => ['meta', 'facultades'] as const,
carreras: (facultadId?: string | null) => carreras: (facultadId?: string | null) =>
+42 -5
View File
@@ -1,8 +1,44 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import {
MutationCache,
QueryCache,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import { qk } from './keys'
import type React from 'react'
function isRlsViolationError(error: unknown): boolean {
const anyErr = error as any
const code = anyErr?.code
const status = anyErr?.status ?? anyErr?.response?.status
console.log('Checking RLS violation error:', { code, status })
// Supabase/PostgREST suele devolver 403 (Forbidden) o código PG 42501 (insufficient_privilege)
return status === 403 || code === '42501'
}
export function getContext() { export function getContext() {
const queryClient = new QueryClient( const queryClientRef: { current: QueryClient | null } = { current: null }
{
const handleAuthzDesync = (error: unknown) => {
if (!isRlsViolationError(error)) return
// Forzar resincronización “database-first” del rol/permisos
console.log('RLS violation detected, invalidating queries...')
queryClientRef.current?.invalidateQueries({ queryKey: qk.meAccess() })
}
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
handleAuthzDesync(error)
},
}),
mutationCache: new MutationCache({
onError: (error) => {
handleAuthzDesync(error)
},
}),
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 30_000, staleTime: 30_000,
@@ -13,8 +49,9 @@ export function getContext() {
retry: 0, retry: 0,
}, },
}, },
} })
)
queryClientRef.current = queryClient
return { return {
queryClient, queryClient,
} }
+1 -72
View File
@@ -12,7 +12,6 @@ import type { SupabaseClient } from '@supabase/supabase-js'
export type EdgeInvokeOptions = { export type EdgeInvokeOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
headers?: Record<string, string> headers?: Record<string, string>
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer'
} }
export class EdgeFunctionError extends Error { export class EdgeFunctionError extends Error {
@@ -27,55 +26,6 @@ export class EdgeFunctionError extends Error {
} }
} }
// Soporta base64 puro o data:...;base64,...
function decodeBase64ToUint8Array(input: string): Uint8Array {
const trimmed = input.trim()
const base64 = trimmed.startsWith('data:')
? trimmed.slice(trimmed.indexOf(',') + 1)
: trimmed
const bin = atob(base64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return bytes
}
function stripDataUrlPrefix(input: string): string {
const trimmed = input.trim()
if (!trimmed.startsWith('data:')) return trimmed
const commaIdx = trimmed.indexOf(',')
return commaIdx >= 0 ? trimmed.slice(commaIdx + 1) : trimmed
}
function looksLikeBase64(s: string): boolean {
const t = stripDataUrlPrefix(s).replace(/\s+/g, '').replace(/=+$/g, '')
// base64 típico: solo chars permitidos y longitud razonable
if (t.length < 64) return false
return /^[A-Za-z0-9+/]+$/.test(t)
}
function startsWithZip(bytes: Uint8Array): boolean {
return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b // "PK"
}
function startsWithPdf(bytes: Uint8Array): boolean {
return (
bytes.length >= 5 &&
bytes[0] === 0x25 &&
bytes[1] === 0x50 &&
bytes[2] === 0x44 &&
bytes[3] === 0x46 &&
bytes[4] === 0x2d
) // "%PDF-"
}
function binaryStringToUint8Array(input: string): Uint8Array {
const bytes = new Uint8Array(input.length)
for (let i = 0; i < input.length; i++) bytes[i] = input.charCodeAt(i) & 0xff
return bytes
}
export async function invokeEdge<TOut>( export async function invokeEdge<TOut>(
functionName: string, functionName: string,
body?: body?:
@@ -92,16 +42,10 @@ export async function invokeEdge<TOut>(
): Promise<TOut> { ): Promise<TOut> {
const supabase = client ?? supabaseBrowser() const supabase = client ?? supabaseBrowser()
// Nota: algunas versiones/defs de @supabase/supabase-js no tipan `responseType` const { data, error } = await supabase.functions.invoke(functionName, {
// aunque el runtime lo soporte. Usamos `any` para no bloquear el uso de Blob.
const invoke: any = (supabase.functions as any).invoke.bind(
supabase.functions,
)
const { data, error } = await invoke(functionName, {
body, body,
method: opts.method ?? 'POST', method: opts.method ?? 'POST',
headers: opts.headers, headers: opts.headers,
responseType: opts.responseType,
}) })
if (error) { if (error) {
@@ -160,20 +104,5 @@ export async function invokeEdge<TOut>(
throw new EdgeFunctionError(message, functionName, status, details) throw new EdgeFunctionError(message, functionName, status, details)
} }
if (opts.responseType === 'blob') {
const anyData: unknown = data
if (anyData instanceof Blob) {
return anyData as TOut
}
throw new EdgeFunctionError(
'La Edge Function no devolvió un binario (Blob) válido.',
functionName,
undefined,
{ receivedType: typeof anyData, received: anyData },
)
}
return data as TOut return data as TOut
} }
File diff suppressed because it is too large Load Diff
-78
View File
@@ -1,78 +0,0 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function parseContenidoTematicoToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const blocks: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const unidad =
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
? item.unidad
: undefined
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
if (!header) continue
const lines: Array<string> = [header]
const temas = Array.isArray(item.temas) ? item.temas : []
temas.forEach((tema, idx) => {
const temaNombre =
typeof tema === 'string'
? tema
: isRecord(tema) && typeof tema.nombre === 'string'
? tema.nombre
: ''
if (!temaNombre) return
if (unidad != null) {
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
} else {
lines.push(`${idx + 1}. ${temaNombre}`)
}
})
blocks.push(lines.join('\n'))
}
return blocks.join('\n\n').trimEnd()
}
export function parseCriteriosEvaluacionToPlainText(value: unknown): string {
if (!Array.isArray(value)) return ''
const lines: Array<string> = []
for (const item of value) {
if (!isRecord(item)) continue
const label = typeof item.criterio === 'string' ? item.criterio.trim() : ''
const valueNum =
typeof item.porcentaje === 'number'
? item.porcentaje
: typeof item.porcentaje === 'string'
? Number(item.porcentaje)
: NaN
if (!label) continue
if (!Number.isFinite(valueNum)) continue
const v = Math.trunc(valueNum)
if (v < 1 || v > 100) continue
lines.push(`${label}: ${v}%`)
}
return lines.join('\n')
}
export const columnParsers: Partial<
Record<string, (value: unknown) => string>
> = {
contenido_tematico: parseContenidoTematicoToPlainText,
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
}
+2
View File
@@ -6,6 +6,7 @@ import reportWebVitals from './reportWebVitals.ts'
import { routeTree } from './routeTree.gen' import { routeTree } from './routeTree.gen'
import * as TanStackQueryProvider from '@/data/query/queryClient.tsx' import * as TanStackQueryProvider from '@/data/query/queryClient.tsx'
import { supabaseBrowser } from '@/data/supabase/client'
import './styles.css' import './styles.css'
@@ -16,6 +17,7 @@ const router = createRouter({
routeTree, routeTree,
context: { context: {
...TanStackQueryProviderContext, ...TanStackQueryProviderContext,
supabase: supabaseBrowser(),
}, },
defaultPreload: 'intent', defaultPreload: 'intent',
scrollRestoration: true, scrollRestoration: true,
+6 -62
View File
@@ -31,8 +31,6 @@ import { Route as PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport } from
import { Route as PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/contenido' 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 PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/bibliografia'
import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva' import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/bibliografia/index'
import { Route as PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
const LoginRoute = LoginRouteImport.update({ const LoginRoute = LoginRouteImport.update({
id: '/login', id: '/login',
@@ -158,18 +156,6 @@ const PlanesPlanIdDetalleAsignaturasNuevaRoute =
path: '/nueva', path: '/nueva',
getParentRoute: () => PlanesPlanIdDetalleAsignaturasRoute, getParentRoute: () => PlanesPlanIdDetalleAsignaturasRoute,
} as any) } as any)
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute =
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute,
} as any)
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute =
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRouteImport.update({
id: '/nueva',
path: '/nueva',
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -188,14 +174,12 @@ export interface FileRoutesByFullPath {
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute '/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren '/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute '/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute '/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute '/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute '/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute '/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
@@ -212,13 +196,12 @@ export interface FileRoutesByTo {
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute '/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute '/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute '/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute '/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute '/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute '/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -238,14 +221,12 @@ export interface FileRoutesById {
'/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute '/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute '/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute '/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren '/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute '/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute '/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute '/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute '/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute '/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -272,8 +253,6 @@ export interface FileRouteTypes {
| '/planes/$planId/asignaturas/$asignaturaId/historial' | '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura' | '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId/' | '/planes/$planId/asignaturas/$asignaturaId/'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
@@ -290,13 +269,12 @@ export interface FileRouteTypes {
| '/planes/$planId/mapa' | '/planes/$planId/mapa'
| '/planes/$planId' | '/planes/$planId'
| '/planes/$planId/asignaturas/nueva' | '/planes/$planId/asignaturas/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
| '/planes/$planId/asignaturas/$asignaturaId/contenido' | '/planes/$planId/asignaturas/$asignaturaId/contenido'
| '/planes/$planId/asignaturas/$asignaturaId/documento' | '/planes/$planId/asignaturas/$asignaturaId/documento'
| '/planes/$planId/asignaturas/$asignaturaId/historial' | '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura' | '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId' | '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
id: id:
| '__root__' | '__root__'
| '/' | '/'
@@ -321,8 +299,6 @@ export interface FileRouteTypes {
| '/planes/$planId/asignaturas/$asignaturaId/historial' | '/planes/$planId/asignaturas/$asignaturaId/historial'
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura' | '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
| '/planes/$planId/asignaturas/$asignaturaId/' | '/planes/$planId/asignaturas/$asignaturaId/'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -491,20 +467,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRouteImport preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRouteImport
parentRoute: typeof PlanesPlanIdDetalleAsignaturasRoute parentRoute: typeof PlanesPlanIdDetalleAsignaturasRoute
} }
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/': {
id: '/planes/$planId/asignaturas/$asignaturaId/bibliografia/'
path: '/'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/bibliografia/'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
}
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva': {
id: '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
path: '/nueva'
fullPath: '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRouteImport
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
}
} }
} }
@@ -559,26 +521,8 @@ const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = {
const PlanesPlanIdDetalleRouteWithChildren = const PlanesPlanIdDetalleRouteWithChildren =
PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren) PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren)
interface PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren {
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
}
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren =
{
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute:
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute,
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute:
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute,
}
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren =
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute._addFileChildren(
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren,
)
interface PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren { interface PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren {
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
@@ -589,7 +533,7 @@ interface PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren {
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren = const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren =
{ {
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute:
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren, PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute,
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute: PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute:
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute, PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute,
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute: PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute:
+76 -2
View File
@@ -1,22 +1,59 @@
import { TanStackDevtools } from '@tanstack/react-devtools' import { TanStackDevtools } from '@tanstack/react-devtools'
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' import {
Outlet,
createRootRouteWithContext,
redirect,
useNavigate,
useRouterState,
} from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { useEffect } from 'react'
import Header from '../components/Header' import Header from '../components/Header'
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools' import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
import type { Database } from '@/types/supabase'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { QueryClient } from '@tanstack/react-query' import type { QueryClient } from '@tanstack/react-query'
import { NotFoundPage } from '@/components/ui/NotFoundPage' import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { throwIfError } from '@/data/api/_helpers'
import { useSession } from '@/data/hooks/useAuth'
import { qk } from '@/data/query/keys'
interface MyRouterContext { interface MyRouterContext {
queryClient: QueryClient queryClient: QueryClient
supabase: SupabaseClient<Database>
} }
export const Route = createRootRouteWithContext<MyRouterContext>()({ export const Route = createRootRouteWithContext<MyRouterContext>()({
beforeLoad: async ({ context, location }) => {
const pathname = location.pathname
const isLogin = pathname === '/login'
const isIndex = pathname === '/'
const session = await context.queryClient.ensureQueryData({
queryKey: qk.session(),
queryFn: async () => {
const { data, error } = await context.supabase.auth.getSession()
throwIfError(error)
return data.session ?? null
},
staleTime: Infinity,
})
if (!session && !isLogin) {
throw redirect({ to: '/login' })
}
if (session && (isLogin || isIndex)) {
throw redirect({ to: '/dashboard' })
}
},
component: () => ( component: () => (
<> <>
<Header /> <AuthSync />
<MaybeHeader />
<Outlet /> <Outlet />
<TanStackDevtools <TanStackDevtools
config={{ config={{
@@ -60,3 +97,40 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
) )
}, },
}) })
function MaybeHeader() {
const pathname = useRouterState({
select: (s) => s.location.pathname,
})
if (pathname === '/login') return null
return <Header />
}
function AuthSync() {
const { data: session, isLoading } = useSession()
// Mantiene roles/permisos sincronizados con la BD (database-first)
// useMeAccess()
const navigate = useNavigate()
const pathname = useRouterState({
select: (s) => s.location.pathname,
})
// Reaccionar a cambios de sesión (login/logout) sin depender solo de beforeLoad.
// Nota: beforeLoad sigue siendo la línea de defensa en navegación/refresh.
useEffect(() => {
if (isLoading) return
if (!session && pathname !== '/login') {
void navigate({ to: '/login', replace: true })
return
}
if (session && pathname === '/login') {
void navigate({ to: '/dashboard', replace: true })
}
}, [isLoading, session, pathname, navigate])
return null
}
@@ -73,7 +73,7 @@ const mapAsignaturas = (
estado: asig.estado, estado: asig.estado,
hd: asig.horas_academicas ?? 0, hd: asig.horas_academicas ?? 0,
hi: asig.horas_independientes ?? 0, hi: asig.horas_independientes ?? 0,
prerrequisito_asignatura_id: asig.prerrequisito_asignatura_id ?? null, prerrequisitos: [],
})) }))
} }
@@ -218,31 +218,27 @@ function AsignaturasPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-slate-50/50"> <TableRow className="bg-slate-50/50">
<TableHead className="w-30 px-6 py-4">Clave</TableHead> <TableHead className="w-30">Clave</TableHead>
<TableHead className="px-6 py-4">Nombre</TableHead> <TableHead>Nombre</TableHead>
<TableHead className="px-6 py-4 text-center">Créditos</TableHead> <TableHead className="text-center">Créditos</TableHead>
<TableHead className="px-6 py-4 text-center">Ciclo</TableHead> <TableHead className="text-center">Ciclo</TableHead>
<TableHead className="px-6 py-4">Línea Curricular</TableHead> <TableHead>Línea Curricular</TableHead>
<TableHead className="px-6 py-4">Tipo</TableHead> <TableHead>Tipo</TableHead>
<TableHead className="px-6 py-4">Estado</TableHead> <TableHead>Estado</TableHead>
<TableHead className="w-12.5 px-6 py-4"></TableHead> <TableHead className="w-12.5"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredAsignaturas.length === 0 ? ( {filteredAsignaturas.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={8} className="h-40 px-6 py-8 text-center"> <TableCell colSpan={8} className="h-40 text-center">
<div className="text-muted-foreground flex flex-col items-center justify-center gap-3"> <div className="text-muted-foreground flex flex-col items-center justify-center">
<BookOpen className="h-10 w-10 opacity-20" /> <BookOpen className="mb-2 h-10 w-10 opacity-20" />
<div> <p className="font-medium">No se encontraron asignaturas</p>
<p className="font-medium"> <p className="text-xs">
No se encontraron asignaturas
</p>
<p className="mt-1 text-xs">
Intenta cambiar los filtros de búsqueda Intenta cambiar los filtros de búsqueda
</p> </p>
</div> </div>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
@@ -255,25 +251,25 @@ function AsignaturasPage() {
to: '/planes/$planId/asignaturas/$asignaturaId', to: '/planes/$planId/asignaturas/$asignaturaId',
params: { params: {
planId, planId,
asignaturaId: asignatura.id, asignaturaId: asignatura.id, // 👈 puede ser índice, consecutivo o slug
}, },
state: { state: {
realId: asignatura.id, realId: asignatura.id, // 👈 ID largo oculto
asignaturaId: asignatura.id, asignaturaId: asignatura.id,
} as any, } as any,
}) })
} }
> >
<TableCell className="px-6 py-4 font-mono text-xs font-bold text-slate-400"> <TableCell className="font-mono text-xs font-bold text-slate-400">
{asignatura.clave} {asignatura.clave}
</TableCell> </TableCell>
<TableCell className="px-6 py-4 font-semibold text-slate-700"> <TableCell className="font-semibold text-slate-700">
{asignatura.nombre} {asignatura.nombre}
</TableCell> </TableCell>
<TableCell className="px-6 py-4 text-center font-medium"> <TableCell className="text-center font-medium">
{asignatura.creditos} {asignatura.creditos}
</TableCell> </TableCell>
<TableCell className="px-6 py-4 text-center"> <TableCell className="text-center">
{asignatura.ciclo ? ( {asignatura.ciclo ? (
<Badge variant="outline" className="font-normal"> <Badge variant="outline" className="font-normal">
Ciclo {asignatura.ciclo} Ciclo {asignatura.ciclo}
@@ -282,10 +278,10 @@ function AsignaturasPage() {
<span className="text-slate-300"></span> <span className="text-slate-300"></span>
)} )}
</TableCell> </TableCell>
<TableCell className="px-6 py-4 text-sm text-slate-600"> <TableCell className="text-sm text-slate-600">
{getLineaNombre(asignatura.lineaCurricularId)} {getLineaNombre(asignatura.lineaCurricularId)}
</TableCell> </TableCell>
<TableCell className="px-6 py-4"> <TableCell>
<Badge <Badge
variant="outline" variant="outline"
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`} className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
@@ -293,7 +289,7 @@ function AsignaturasPage() {
{tipoConfig[asignatura.tipo].label} {tipoConfig[asignatura.tipo].label}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="px-6 py-4"> <TableCell>
<Badge <Badge
variant="outline" variant="outline"
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`} className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
@@ -301,7 +297,7 @@ function AsignaturasPage() {
{statusConfig[asignatura.estado].label} {statusConfig[asignatura.estado].label}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="px-6 py-4"> <TableCell>
<div className="opacity-0 transition-opacity group-hover:opacity-100"> <div className="opacity-0 transition-opacity group-hover:opacity-100">
<ChevronRight className="h-5 w-5 text-slate-400" /> <ChevronRight className="h-5 w-5 text-slate-400" />
</div> </div>
@@ -8,11 +8,10 @@ import {
Clock, Clock,
FileJson, FileJson,
} from 'lucide-react' } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { usePlan } from '@/data'
import { fetchPlanPdf } from '@/data/api/document.api' import { fetchPlanPdf } from '@/data/api/document.api'
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
@@ -21,41 +20,30 @@ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
function RouteComponent() { function RouteComponent() {
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' }) const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
const { data: plan } = usePlan(planId)
const [pdfUrl, setPdfUrl] = useState<string | null>(null) const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const isMountedRef = useRef<boolean>(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const planFileBaseName = sanitizeFileBaseName(plan?.nombre ?? 'plan_estudios')
const loadPdfPreview = useCallback(async () => { const loadPdfPreview = useCallback(async () => {
try { try {
if (isMountedRef.current) setIsLoading(true) setIsLoading(true)
const pdfBlob = await fetchPlanPdf({ const pdfBlob = await fetchPlanPdf({ plan_estudio_id: planId })
plan_estudio_id: planId,
convertTo: 'pdf',
})
if (!isMountedRef.current) return
const url = window.URL.createObjectURL(pdfBlob) const url = window.URL.createObjectURL(pdfBlob)
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current) // Limpiar URL anterior si existe para evitar fugas de memoria
pdfUrlRef.current = url if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
setPdfUrl(url) setPdfUrl(url)
} catch (error) { } catch (error) {
console.error('Error cargando preview:', error) console.error('Error cargando preview:', error)
} finally { } finally {
if (isMountedRef.current) setIsLoading(false) setIsLoading(false)
} }
}, [planId]) }, [planId])
useEffect(() => { useEffect(() => {
isMountedRef.current = true
loadPdfPreview() loadPdfPreview()
return () => { return () => {
isMountedRef.current = false if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
} }
}, [loadPdfPreview]) }, [loadPdfPreview])
@@ -63,13 +51,12 @@ function RouteComponent() {
try { try {
const pdfBlob = await fetchPlanPdf({ const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId, plan_estudio_id: planId,
convertTo: 'pdf',
}) })
const url = window.URL.createObjectURL(pdfBlob) const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.download = `${planFileBaseName}.pdf` link.download = 'plan_estudios.pdf'
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
@@ -80,27 +67,6 @@ function RouteComponent() {
alert('No se pudo generar el PDF') alert('No se pudo generar el PDF')
} }
} }
const handleDownloadWord = async () => {
try {
const docBlob = await fetchPlanPdf({
plan_estudio_id: planId,
})
const url = window.URL.createObjectURL(docBlob)
const link = document.createElement('a')
link.href = url
link.download = `${planFileBaseName}.docx`
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => window.URL.revokeObjectURL(url), 1000)
} catch (error) {
console.error(error)
alert('No se pudo generar el Word')
}
}
return ( return (
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6"> <div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
{/* HEADER DE ACCIONES */} {/* HEADER DE ACCIONES */}
@@ -122,17 +88,12 @@ function RouteComponent() {
> >
<RefreshCcw size={16} /> Regenerar <RefreshCcw size={16} /> Regenerar
</Button> </Button>
<Button <Button variant="outline" size="sm" className="gap-2">
size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={handleDownloadWord}
>
<Download size={16} /> Descargar Word <Download size={16} /> Descargar Word
</Button> </Button>
<Button <Button
variant="outline"
size="sm" size="sm"
className="gap-2" className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={handleDownloadPdf} onClick={handleDownloadPdf}
> >
<Download size={16} /> Descargar PDF <Download size={16} /> Descargar PDF
@@ -178,7 +139,7 @@ function RouteComponent() {
)} )}
</div> </div>
<CardContent className="flex min-h-200 justify-center bg-slate-500 p-0"> <CardContent className="flex min-h-[800px] justify-center bg-slate-500 p-0">
{isLoading ? ( {isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 text-white"> <div className="flex flex-col items-center justify-center gap-4 text-white">
<RefreshCcw size={40} className="animate-spin opacity-50" /> <RefreshCcw size={40} className="animate-spin opacity-50" />
@@ -188,7 +149,7 @@ function RouteComponent() {
/* 3. VISOR DE PDF REAL */ /* 3. VISOR DE PDF REAL */
<iframe <iframe
src={`${pdfUrl}#toolbar=0&navpanes=0`} src={`${pdfUrl}#toolbar=0&navpanes=0`}
className="h-250 w-full max-w-250 border-none shadow-2xl" className="h-[1000px] w-full max-w-[1000px] border-none shadow-2xl"
title="PDF Preview" title="PDF Preview"
/> />
) : ( ) : (
@@ -202,24 +163,6 @@ function RouteComponent() {
) )
} }
function sanitizeFileBaseName(input: string): string {
const text = String(input)
const withoutControlChars = Array.from(text)
.filter((ch) => {
const code = ch.charCodeAt(0)
return code >= 32 && code !== 127
})
.join('')
const cleaned = withoutControlChars
.replace(/[<>:"/\\|?*]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/g, '')
return (cleaned || 'documento').slice(0, 150)
}
// Componente pequeño para las tarjetas de estado superior // Componente pequeño para las tarjetas de estado superior
function StatusCard({ function StatusCard({
icon, icon,
@@ -139,16 +139,13 @@ function RouteComponent() {
</div> </div>
<div className="relative space-y-0"> <div className="relative space-y-0">
<div className="absolute top-0 bottom-0 left-6 w-px bg-slate-200 md:left-9" /> <div className="absolute top-0 bottom-0 left-9 w-px bg-slate-200" />
{historyEvents.length === 0 ? ( {historyEvents.length === 0 ? (
<div className="ml-20 py-10 text-slate-500">No hay registros.</div> <div className="ml-20 py-10 text-slate-500">No hay registros.</div>
) : ( ) : (
historyEvents.map((event) => ( historyEvents.map((event) => (
<div <div key={event.id} className="group relative flex gap-6 pb-8">
key={event.id} <div className="relative z-10 flex h-18 flex-col items-center">
className="group relative flex gap-3 pb-8 md:gap-6"
>
<div className="relative z-10 flex flex-col items-center">
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600"> <div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600">
{event.icon} {event.icon}
</div> </div>
@@ -156,10 +153,10 @@ function RouteComponent() {
<Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200"> <Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-1">
{/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */} {/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */}
<div className="flex flex-col justify-between gap-2 md:flex-row md:items-center"> <div className="flex items-center justify-between">
<div className="flex flex-wrap items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-bold text-slate-800"> <span className="text-sm font-bold text-slate-800">
{event.type} {event.type}
</span> </span>
@@ -175,9 +172,9 @@ function RouteComponent() {
</div> </div>
{/* Grupo de elementos alineados a la derecha */} {/* Grupo de elementos alineados a la derecha */}
<div className="flex flex-wrap items-center gap-3 text-slate-500 md:gap-4"> <div className="flex items-center gap-4 text-slate-500">
{/* Usuario e Icono */} {/* Usuario e Icono */}
<div className="flex items-center gap-1.5 text-xs"> <div className="flex items-center gap-2 text-xs">
<User className="h-3.5 w-3.5" /> <User className="h-3.5 w-3.5" />
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{event.user} {event.user}
@@ -187,14 +184,14 @@ function RouteComponent() {
{/* Botón Ver Cambios */} {/* Botón Ver Cambios */}
<button <button
onClick={() => openCompareModal(event)} onClick={() => openCompareModal(event)}
className="group/btn flex items-center gap-1.5 text-xs font-medium text-teal-600 md:text-slate-500 md:hover:text-teal-600" className="group/btn flex items-center gap-1.5 text-xs transition-colors hover:text-teal-600"
> >
<Eye className="h-4 w-4 text-slate-400 group-hover/btn:text-teal-600" /> <Eye className="h-4 w-4 text-slate-400 group-hover/btn:text-teal-600" />
<span>Ver cambios</span> <span>Ver cambios</span>
</button> </button>
{/* Fecha exacta (Solo visible en desktop para no amontonar) */} {/* Fecha exacta (Solo visible en desktop para no amontonar) */}
<span className="hidden text-[11px] text-slate-400 lg:block"> <span className="hidden text-[11px] text-slate-400 md:block">
{format(event.date, 'yyyy-MM-dd HH:mm')} {format(event.date, 'yyyy-MM-dd HH:mm')}
</span> </span>
</div> </div>
@@ -236,7 +233,7 @@ function RouteComponent() {
)) ))
)} )}
{historyEvents.length > 0 && ( {historyEvents.length > 0 && (
<div className="mt-10 ml-12 flex flex-col gap-3 border-t pt-4 md:ml-20 md:flex-row md:items-center md:justify-between"> <div className="mt-10 ml-20 flex items-center justify-between border-t pt-4">
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
Mostrando {rawData.length} de {totalRecords} cambios Mostrando {rawData.length} de {totalRecords} cambios
</p> </p>
+179 -507
View File
@@ -13,9 +13,8 @@ import {
X, X,
MessageSquarePlus, MessageSquarePlus,
Archive, Archive,
Loader2,
Sparkles,
RotateCcw, RotateCcw,
Loader2,
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
@@ -23,25 +22,15 @@ import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/
import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard' import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA' import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer' import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { import {
useAIPlanChat, useAIPlanChat,
useConversationByPlan, useConversationByPlan,
useMessagesByChat,
useUpdateConversationStatus, useUpdateConversationStatus,
useUpdateConversationTitle, useUpdateConversationTitle,
useUpdatePlanFields,
useUpdateRecommendationApplied,
} from '@/data' } from '@/data'
import { usePlan } from '@/data/hooks/usePlans' import { usePlan } from '@/data/hooks/usePlans'
@@ -108,14 +97,12 @@ function RouteComponent() {
const [openIA, setOpenIA] = useState(false) const [openIA, setOpenIA] = useState(false)
const { mutateAsync: sendChat, isPending: isLoading } = useAIPlanChat() const { mutateAsync: sendChat, isPending: isLoading } = useAIPlanChat()
const { mutate: updateStatusMutation } = useUpdateConversationStatus() const { mutate: updateStatusMutation } = useUpdateConversationStatus()
const [isSyncing, setIsSyncing] = useState(false)
const [activeChatId, setActiveChatId] = useState<string | undefined>( const [activeChatId, setActiveChatId] = useState<string | undefined>(
undefined, undefined,
) )
const { data: lastConversation, isLoading: isLoadingConv } = const { data: lastConversation, isLoading: isLoadingConv } =
useConversationByPlan(planId) useConversationByPlan(planId)
const { data: mensajesDelChat, isLoading: isLoadingMessages } =
useMessagesByChat(activeChatId ?? null) // Si es undefined, pasa null
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>( const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[], [],
) )
@@ -131,7 +118,6 @@ function RouteComponent() {
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null) const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const isInitialLoad = useRef(true)
const [showArchived, setShowArchived] = useState(false) const [showArchived, setShowArchived] = useState(false)
const [editingChatId, setEditingChatId] = useState<string | null>(null) const [editingChatId, setEditingChatId] = useState<string | null>(null)
const editableRef = useRef<HTMLSpanElement>(null) const editableRef = useRef<HTMLSpanElement>(null)
@@ -141,16 +127,6 @@ function RouteComponent() {
null, null,
) )
const [filterQuery, setFilterQuery] = useState('') const [filterQuery, setFilterQuery] = useState('')
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
const [isActionsOpen, setIsActionsOpen] = useState(false)
const [selectedImprovements, setSelectedImprovements] = useState<
Array<string>
>([])
const updatePlan = useUpdatePlanFields()
const updateAppliedStatus = useUpdateRecommendationApplied()
const availableFields = useMemo(() => { const availableFields = useMemo(() => {
const definicion = data?.estructuras_plan const definicion = data?.estructuras_plan
?.definicion as EstructuraDefinicion ?.definicion as EstructuraDefinicion
@@ -173,33 +149,41 @@ function RouteComponent() {
) )
}, [availableFields, filterQuery, selectedFields]) }, [availableFields, filterQuery, selectedFields])
const activeChatData = useMemo(() => {
return lastConversation?.find((chat: any) => chat.id === activeChatId)
}, [lastConversation, activeChatId])
const chatMessages = useMemo(() => { const chatMessages = useMemo(() => {
if (!activeChatId || !mensajesDelChat) return [] // 1. Si no hay ID o no hay data del chat, retornamos vacío
if (!activeChatId || !activeChatData) return []
// flatMap nos permite devolver 2 elementos (pregunta y respuesta) por cada registro de la BD const json = (activeChatData.conversacion_json ||
return mensajesDelChat.flatMap((msg: any) => { []) as unknown as Array<ChatMessageJSON>
const messages = []
// 1. Mensaje del Usuario // 2. Verificamos que 'json' sea realmente un array antes de mapear
messages.push({ if (!Array.isArray(json)) return []
id: `${msg.id}-user`,
role: 'user',
content: msg.mensaje,
selectedFields: msg.campos || [], // Aquí están tus campos
})
// 2. Mensaje del Asistente (si hay respuesta) return json.map((msg, index: number) => {
if (msg.respuesta) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
// Extraemos las recomendaciones de la nueva estructura: msg.propuesta.recommendations if (!msg?.user) {
const rawRecommendations = msg.propuesta?.recommendations || [] return {
id: `err-${index}`,
messages.push({
id: `${msg.id}-ai`,
dbMessageId: msg.id,
role: 'assistant', role: 'assistant',
content: msg.respuesta, content: '',
isRefusal: msg.is_refusal, suggestions: [],
suggestions: rawRecommendations.map((rec: any) => { }
}
const isAssistant = msg.user === 'assistant'
return {
id: `${activeChatId}-${index}`,
role: isAssistant ? 'assistant' : 'user',
content: isAssistant ? msg.message || '' : msg.prompt || '', // Agregamos fallback a string vacío
isRefusal: isAssistant && msg.refusal === true,
suggestions:
isAssistant && msg.recommendations
? msg.recommendations.map((rec) => {
const fieldConfig = availableFields.find( const fieldConfig = availableFields.find(
(f) => f.key === rec.campo_afectado, (f) => f.key === rec.campo_afectado,
) )
@@ -211,117 +195,26 @@ function RouteComponent() {
newValue: rec.texto_mejora, newValue: rec.texto_mejora,
applied: rec.aplicada, applied: rec.aplicada,
} }
}),
}) })
: [],
} }
return messages
}) })
}, [mensajesDelChat, activeChatId, availableFields]) }, [activeChatData, activeChatId, availableFields])
// En tu componente principal (el padre) const scrollToBottom = () => {
const handleApplyMultiple = async (
sugerencias: Array<any>,
dbMessageId: string,
) => {
if (!planId || !data?.datos || sugerencias.length === 0) return
setIsSending(true)
try {
const datosActualizados = { ...data.datos }
for (const sug of sugerencias) {
const key = sug.key
const newValue = sug.newValue
const currentValue = datosActualizados[key]
if (
typeof currentValue === 'object' &&
currentValue !== null &&
'description' in currentValue
) {
datosActualizados[key] = { ...currentValue, description: newValue }
} else {
datosActualizados[key] = newValue
}
}
await updatePlan.mutateAsync({
planId: planId as any,
patch: { datos: datosActualizados },
})
for (const sug of sugerencias) {
try {
await updateAppliedStatus.mutateAsync({
conversacionId: dbMessageId,
campoAfectado: sug.key,
})
console.log(sug.key)
removeSelectedField(sug.key)
} catch (err) {
console.error(
`Error al marcar aplicada la sugerencia: ${sug.key}`,
err,
)
// Si una falla, el bucle sigue con las demás
}
}
setSelectedImprovements([])
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['plan', planId] }),
queryClient.invalidateQueries({ queryKey: ['conversation-messages'] }),
])
} catch (error) {
console.error('Error crítico en aplicación masiva:', error)
} finally {
setIsSending(false)
}
}
const toggleImprovementSelection = (sugKey: string) => {
setSelectedImprovements((prev) =>
prev.includes(sugKey)
? prev.filter((key) => key !== sugKey)
: [...prev, sugKey],
)
}
const toggleAllFromMessage = (suggestions: Array<any>) => {
const pending = suggestions.filter((s) => !s.applied)
const allKeys = pending.map((s) => s.key)
const allSelected = allKeys.every((key) =>
selectedImprovements.includes(key),
)
if (allSelected) {
setSelectedImprovements((prev) =>
prev.filter((key) => !allKeys.includes(key)),
)
} else {
setSelectedImprovements((prev) =>
Array.from(new Set([...prev, ...allKeys])),
)
}
}
const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
if (scrollRef.current) { if (scrollRef.current) {
// Buscamos el viewport interno del ScrollArea de Radix
const scrollContainer = scrollRef.current.querySelector( const scrollContainer = scrollRef.current.querySelector(
'[data-radix-scroll-area-viewport]', '[data-radix-scroll-area-viewport]',
) )
if (scrollContainer) { if (scrollContainer) {
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: scrollContainer.scrollHeight, top: scrollContainer.scrollHeight,
behavior, behavior: 'smooth',
}) })
} }
} }
} }
const { activeChats, archivedChats } = useMemo(() => { const { activeChats, archivedChats } = useMemo(() => {
const allChats = lastConversation || [] const allChats = lastConversation || []
return { return {
@@ -333,57 +226,46 @@ function RouteComponent() {
}, [lastConversation]) }, [lastConversation])
useEffect(() => { useEffect(() => {
if (chatMessages.length > 0) { scrollToBottom()
if (isInitialLoad.current) { }, [chatMessages, isLoading])
// Si es el primer render con mensajes, vamos al final al instante
scrollToBottom('instant')
isInitialLoad.current = false
} else {
// Si ya estaba cargado y llegan nuevos, hacemos el smooth
scrollToBottom('smooth')
}
}
}, [chatMessages])
// 2. Resetear el flag cuando cambies de chat activo
useEffect(() => {
isInitialLoad.current = true
}, [activeChatId])
useEffect(() => { useEffect(() => {
if (isLoadingConv || isSending) return // 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),
)
const currentChatExists = activeChats.some( // Solo actualizamos el estado si hubo un cambio real (para evitar bucles infinitos)
if (camposActualizados.length !== selectedFields.length) {
setSelectedFields(camposActualizados)
}
}, [input, selectedFields])
useEffect(() => {
if (isLoadingConv || !lastConversation) return
const isChatStillActive = activeChats.some(
(chat) => chat.id === activeChatId, (chat) => chat.id === activeChatId,
) )
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome' const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
// 1. Si el chat que teníamos seleccionado ya no existe (ej. se archivó) // Caso A: El chat actual ya no es válido (fue archivado o borrado)
if (activeChatId && !currentChatExists && !isCreationMode) { if (activeChatId && !isChatStillActive && !isCreationMode) {
setActiveChatId(undefined) setActiveChatId(undefined)
setMessages([]) setMessages([])
return return // Salimos para evitar ejecuciones extra en este render
} }
// 2. Auto-selección inicial: Solo si no hay ID, no estamos creando y hay chats // Caso B: No hay chat seleccionado y hay chats disponibles (Auto-selección al cargar)
if ( if (!activeChatId && activeChats.length > 0 && !isCreationMode) {
!activeChatId &&
activeChats.length > 0 &&
!isCreationMode &&
chatMessages.length === 0
) {
setActiveChatId(activeChats[0].id) setActiveChatId(activeChats[0].id)
} }
}, [
activeChats,
activeChatId,
isLoadingConv,
isSending,
messages.length,
chatMessages.length,
messages,
])
// Caso C: Si la lista de chats está vacía y no estamos creando uno, limpiar por si acaso
if (activeChats.length === 0 && activeChatId && !isCreationMode) {
setActiveChatId(undefined)
}
}, [activeChats, activeChatId, isLoadingConv, messages.length])
useEffect(() => { useEffect(() => {
const state = routerState.location.state as any const state = routerState.location.state as any
if (!state?.campo_edit || availableFields.length === 0) return if (!state?.campo_edit || availableFields.length === 0) return
@@ -396,7 +278,7 @@ function RouteComponent() {
setInput((prev) => setInput((prev) =>
injectFieldsIntoInput(prev || 'Mejora este campo:', [field]), injectFieldsIntoInput(prev || 'Mejora este campo:', [field]),
) )
}, [availableFields, routerState.location.state]) }, [availableFields])
const createNewChat = () => { const createNewChat = () => {
setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo
@@ -408,7 +290,7 @@ function RouteComponent() {
}, },
]) ])
setInput('') setInput('')
// setSelectedFields([]) setSelectedFields([])
} }
const archiveChat = (e: React.MouseEvent, id: string) => { const archiveChat = (e: React.MouseEvent, id: string) => {
@@ -470,16 +352,13 @@ function RouteComponent() {
input: string, input: string,
fields: Array<SelectedField>, fields: Array<SelectedField>,
) => { ) => {
// 1. Limpiamos cualquier rastro anterior de la etiqueta (por si acaso) const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim()
// Esta regex ahora también limpia si el texto termina de forma natural
const cleaned = input.replace(/[:\s]+[^:]*$/, '').trim()
if (fields.length === 0) return cleaned if (fields.length === 0) return cleaned
const fieldLabels = fields.map((f) => f.label).join(', ') const fieldLabels = fields.map((f) => f.label).join(', ')
// 2. Devolvemos un formato natural: "Mejora este campo: Nombre del Campo" return `${cleaned}\n[Campos: ${fieldLabels}]`
return `${cleaned}: ${fieldLabels}`
} }
const toggleField = (field: SelectedField) => { const toggleField = (field: SelectedField) => {
@@ -509,63 +388,46 @@ function RouteComponent() {
const handleSend = async (promptOverride?: string) => { const handleSend = async (promptOverride?: string) => {
const rawText = promptOverride || input const rawText = promptOverride || input
if (!rawText.trim() && selectedFields.length === 0) return
if (isSending || (!rawText.trim() && selectedFields.length === 0)) return if (isSending || (!rawText.trim() && selectedFields.length === 0)) return
const currentFields = [...selectedFields] const currentFields = [...selectedFields]
const finalContent = buildPrompt(rawText, currentFields) const finalPrompt = buildPrompt(rawText, currentFields)
setIsSending(true) setIsSending(true)
setOptimisticMessage(finalContent) setOptimisticMessage(rawText)
setInput('') setInput('')
// setSelectedFields([]) setSelectedArchivoIds([])
setSelectedRepositorioIds([])
setUploadedFiles([])
try { try {
const payload = { const payload: any = {
planId: planId as any, planId: planId,
content: finalContent, content: finalPrompt,
conversacionId: activeChatId, conversacionId: activeChatId || undefined,
campos: }
currentFields.length > 0
? currentFields.map((f) => f.key) if (currentFields.length > 0) {
: undefined, payload.campos = currentFields.map((f) => f.key)
} }
const response = await sendChat(payload) const response = await sendChat(payload)
setIsSyncing(true)
if (response.conversacionId && response.conversacionId !== activeChatId) { if (response.conversacionId && response.conversacionId !== activeChatId) {
setActiveChatId(response.conversacionId) setActiveChatId(response.conversacionId)
} }
// ESPERAMOS a que la caché se actualice antes de quitar el "isSending" await queryClient.invalidateQueries({
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId], queryKey: ['conversation-by-plan', planId],
}), })
queryClient.invalidateQueries({ setOptimisticMessage(null)
queryKey: ['conversation-messages', response.conversacionId],
}),
])
} catch (error) { } catch (error) {
console.error('Error:', error) console.error('Error en el chat:', error)
setOptimisticMessage(null) // Aquí sí podrías usar un toast o un mensaje de error temporal
} finally { } finally {
// Solo ahora quitamos los indicadores de carga // 5. CRÍTICO: Detener el estado de carga SIEMPRE
setIsSending(false) setIsSending(false)
// setOptimisticMessage(null)
}
}
useEffect(() => {
if (!isSyncing || !mensajesDelChat || mensajesDelChat.length === 0) return
// Forzamos el tipo a 'any' o a tu interfaz de mensaje para saltarnos la unión de tipos compleja
const ultimoMensajeDB = mensajesDelChat[mensajesDelChat.length - 1] as any
// Ahora la validación es directa y no debería dar avisos de "unnecessary"
if (ultimoMensajeDB?.respuesta) {
setIsSyncing(false)
setOptimisticMessage(null) setOptimisticMessage(null)
} }
}, [mensajesDelChat, isSyncing]) }
const totalReferencias = useMemo(() => { const totalReferencias = useMemo(() => {
return ( return (
@@ -576,85 +438,63 @@ function RouteComponent() {
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles]) }, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
const removeSelectedField = (fieldKey: string) => { const removeSelectedField = (fieldKey: string) => {
console.log(fieldKey)
setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey)) setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
} }
return ( return (
<div className="flex h-[calc(100vh-80px)] w-full flex-col gap-4 p-4 md:h-[calc(100vh-160px)] md:max-h-[calc(100vh-160px)] md:flex-row md:overflow-hidden"> <div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
{/* --- HEADER MÓVIL (Solo visible en < md) --- */} {/* --- PANEL IZQUIERDO: HISTORIAL --- */}
<div className="flex shrink-0 items-center justify-between rounded-lg border bg-white p-2 shadow-sm md:hidden"> <div className="flex w-64 flex-col border-r pr-4">
<Button <div className="mb-4">
variant="ghost" <div className="mb-4 flex items-center justify-between px-2">
size="sm"
onClick={() => setIsHistoryOpen(true)}
>
<Archive size={18} className="mr-2" /> Historial
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setIsActionsOpen(true)}
>
<Lightbulb size={18} className="mr-2 text-orange-500" /> Acciones
</Button>
</div>
{/* --- PANEL IZQUIERDO: HISTORIAL (Escritorio) --- */}
<div className="hidden w-64 flex-col border-r pr-4 md:flex">
{/* ... (Tu código actual del historial de chats) ... */}
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase"> <h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
Chats Chats
</h2> </h2>
{/* Botón de toggle archivados movido aquí arriba */}
<button
onClick={() => setShowArchived(!showArchived)}
className={`rounded-md p-1.5 transition-colors ${
showArchived
? 'bg-teal-50 text-teal-600'
: 'text-slate-400 hover:bg-slate-100'
}`}
title={showArchived ? 'Ver chats activos' : 'Ver archivados'}
>
<Archive size={16} />
</button>
</div>
<Button <Button
onClick={createNewChat} onClick={createNewChat}
variant="outline" variant="outline"
className="mt-2 mb-4 w-full justify-start gap-2" className="mb-4 w-full justify-start gap-2 border-slate-200 hover:bg-teal-50 hover:text-teal-700"
> >
<MessageSquarePlus size={18} /> Nuevo chat <MessageSquarePlus size={18} /> Nuevo chat
</Button> </Button>
</div>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="space-y-1 pr-2"> <div className="space-y-1">
{' '}
{/* Agregamos un pr-2 para que el scrollbar no tape botones */}
{!showArchived ? ( {!showArchived ? (
activeChats.map((chat) => ( activeChats.map((chat) => (
<div <div
key={chat.id} key={chat.id}
onClick={() => setActiveChatId(chat.id)} onClick={() => setActiveChatId(chat.id)}
className={`group relative flex w-full items-center overflow-hidden rounded-lg px-3 py-3 text-sm transition-colors ${ className={`group relative flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-sm transition-colors ${
activeChatId === chat.id activeChatId === chat.id
? 'bg-slate-100 font-medium text-slate-900' ? 'bg-slate-100 font-medium text-slate-900'
: 'text-slate-600 hover:bg-slate-50' : 'text-slate-600 hover:bg-slate-50'
}`} }`}
> >
{/* LADO IZQUIERDO: Icono + Texto */}
<div
className="flex min-w-0 flex-1 items-center gap-3 transition-all duration-200"
style={{
// Aplicamos la máscara solo cuando el mouse está encima para que se note el desvanecimiento
// donde aparecen los botones
maskImage:
'linear-gradient(to right, black 70%, transparent 95%)',
WebkitMaskImage:
'linear-gradient(to right, black 70%, transparent 95%)',
}}
>
{/* pr-12 reserva espacio para los botones absolutos */}
<FileText size={16} className="shrink-0 opacity-40" /> <FileText size={16} className="shrink-0 opacity-40" />
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild className="min-w-0 flex-1">
<div className="min-w-0 flex-1">
<span <span
ref={ ref={editingChatId === chat.id ? editableRef : null}
editingChatId === chat.id ? editableRef : null
}
contentEditable={editingChatId === chat.id} contentEditable={editingChatId === chat.id}
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
className={`block truncate outline-none ${ className={`truncate pr-14 transition-all outline-none ${
editingChatId === chat.id editingChatId === chat.id
? 'max-h-20 min-w-[100px] cursor-text overflow-y-auto rounded bg-white px-1 break-all shadow-sm ring-1 ring-teal-500' ? 'min-w-[50px] cursor-text rounded bg-white px-1 ring-1 ring-teal-500'
: 'cursor-pointer' : 'cursor-pointer'
}`} }`}
onDoubleClick={(e) => { onDoubleClick={(e) => {
@@ -664,64 +504,52 @@ function RouteComponent() {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
e.currentTarget.blur() const newTitle = e.currentTarget.textContent || ''
updateTitleMutation(
{ id: chat.id, nombre: newTitle },
{
onSuccess: () => setEditingChatId(null),
},
)
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
setEditingChatId(null) setEditingChatId(null)
e.currentTarget.textContent =
chat.nombre || '' e.currentTarget.textContent = chat.nombre || ''
} }
}} }}
onBlur={(e) => { onBlur={(e) => {
if (editingChatId === chat.id) { if (editingChatId === chat.id) {
const newTitle = const newTitle = e.currentTarget.textContent || ''
e.currentTarget.textContent.trim() || '' if (newTitle !== chat.nombre) {
if (newTitle && newTitle !== chat.nombre) { updateTitleMutation({ id: chat.id, nombre: newTitle })
updateTitleMutation({
id: chat.id,
nombre: newTitle,
})
} }
setEditingChatId(null) setEditingChatId(null)
} }
}} }}
onClick={(e) => {
if (editingChatId === chat.id) e.stopPropagation()
}}
> >
{chat.nombre || {chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`}
`Chat ${chat.creado_en.split('T')[0]}`}
</span> </span>
</div>
</TooltipTrigger>
{editingChatId !== chat.id && (
<TooltipContent
side="right"
className="max-w-[280px] break-all"
>
{chat.nombre || 'Conversación'}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
{/* LADO DERECHO: Acciones ABSOLUTAS */} {/* ACCIONES */}
<div <div className="absolute right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100">
className={`absolute top-1/2 right-2 z-20 flex -translate-y-1/2 items-center gap-1 rounded-md px-1 opacity-0 transition-opacity group-hover:opacity-100 ${
activeChatId === chat.id ? 'bg-slate-100' : 'bg-slate-50'
}`}
>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setEditingChatId(chat.id) setEditingChatId(chat.id)
// Pequeño timeout para asegurar que el DOM se actualice antes de enfocar
setTimeout(() => editableRef.current?.focus(), 50) setTimeout(() => editableRef.current?.focus(), 50)
}} }}
className="rounded-md p-1 text-slate-400 transition-colors hover:text-teal-600" className="p-1 text-slate-400 hover:text-teal-600"
> >
<Send size={12} className="rotate-45" /> <Send size={12} className="rotate-45" />
</button> </button>
<button <button
onClick={(e) => archiveChat(e, chat.id)} onClick={(e) => archiveChat(e, chat.id)}
className="rounded-md p-1 text-slate-400 transition-colors hover:text-amber-600" className="p-1 text-slate-400 hover:text-amber-600"
> >
<Archive size={14} /> <Archive size={14} />
</button> </button>
@@ -729,26 +557,24 @@ function RouteComponent() {
</div> </div>
)) ))
) : ( ) : (
/* Sección de archivados (Simplificada para mantener consistencia) */ /* ... Resto del código de archivados (sin cambios) ... */
<div className="animate-in fade-in slide-in-from-left-2 px-1"> <div className="animate-in fade-in slide-in-from-left-2">
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase"> <p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
Archivados Archivados
</p> </p>
{archivedChats.map((chat) => ( {archivedChats.map((chat) => (
<div <div
key={chat.id} key={chat.id}
className="group relative mb-1 flex w-full items-center overflow-hidden rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400" className="group relative mb-1 flex w-full items-center gap-3 rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400"
> >
<div className="flex min-w-0 flex-1 items-center gap-3 pr-10">
<Archive size={14} className="shrink-0 opacity-30" /> <Archive size={14} className="shrink-0 opacity-30" />
<span className="block truncate"> <span className="truncate pr-8">
{chat.nombre || {chat.nombre ||
`Archivado ${chat.creado_en.split('T')[0]}`} `Archivado ${chat.creado_en.split('T')[0]}`}
</span> </span>
</div>
<button <button
onClick={(e) => unarchiveChat(e, chat.id)} onClick={(e) => unarchiveChat(e, chat.id)}
className="absolute top-1/2 right-2 shrink-0 -translate-y-1/2 rounded bg-slate-100 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-teal-600" className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600"
> >
<RotateCcw size={14} /> <RotateCcw size={14} />
</button> </button>
@@ -759,11 +585,10 @@ function RouteComponent() {
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>
{/* PANEL DE CHAT PRINCIPAL */}
{/* --- PANEL DE CHAT PRINCIPAL (Centro) --- */} <div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm md:h-full md:flex-[3]">
{/* NUEVO: Barra superior de campos seleccionados */} {/* NUEVO: Barra superior de campos seleccionados */}
<div className="z-10 shrink-0 border-b bg-white p-3"> <div className="shrink-0 border-b bg-white p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase"> <span className="text-[10px] font-bold text-slate-400 uppercase">
Mejorar con IA Mejorar con IA
@@ -784,9 +609,9 @@ function RouteComponent() {
</div> </div>
{/* CONTENIDO DEL CHAT */} {/* CONTENIDO DEL CHAT */}
<div className="relative flex min-h-0 flex-1 flex-col"> <div className="relative min-h-0 flex-1">
<ScrollArea ref={scrollRef} className="h-full w-full"> <ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6"> <div className="mx-auto max-w-3xl space-y-6 p-6">
{!activeChatId && {!activeChatId &&
chatMessages.length === 0 && chatMessages.length === 0 &&
!optimisticMessage ? ( !optimisticMessage ? (
@@ -805,97 +630,42 @@ function RouteComponent() {
</div> </div>
) : ( ) : (
<> <>
{chatMessages.map((msg: any) => { {chatMessages.map((msg: any) => (
const isAI = msg.role === 'assistant'
const isUser = msg.role === 'user'
// IMPORTANTE: Asegúrate de que msg.id contenga la info de procesamiento o pásala en el map
const isProcessing = msg.isProcessing
return (
<div <div
key={msg.id} key={msg.id}
className={`flex max-w-[85%] flex-col ${ className={`flex max-w-[85%] flex-col ${
isUser ? 'ml-auto items-end' : 'items-start' msg.role === 'user'
? 'ml-auto items-end'
: 'items-start'
}`} }`}
> >
<div <div
className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${ className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${
isUser msg.role === 'user'
? 'rounded-tr-none bg-teal-600 text-white' ? 'rounded-tr-none bg-teal-600 text-white'
: `rounded-tl-none border bg-white text-slate-700 ${ : `rounded-tl-none border bg-white text-slate-700 ${
// --- LÓGICA DE REFUSAL ---
msg.isRefusal msg.isRefusal
? 'border-red-200 bg-red-50/50 ring-1 ring-red-100' ? 'border-red-200 bg-red-50/50 ring-1 ring-red-100'
: 'border-slate-100' : 'border-slate-100'
}` }`
}`} }`}
> >
{/* Aviso de Refusal */} {/* Icono opcional de advertencia si es refusal */}
{msg.isRefusal && ( {msg.isRefusal && (
<div className="mb-1 flex items-center gap-1 text-[10px] font-bold text-red-500 uppercase"> <div className="mb-1 flex items-center gap-1 text-[10px] font-bold text-red-500 uppercase">
<span>Aviso del Asistente</span> <span>Aviso del Asistente</span>
</div> </div>
)} )}
{/* CONTENIDO CORRECTO: Usamos msg.content */} {msg.content}
{isAI && isProcessing ? (
<div className="flex items-center gap-2 py-1">
<div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" />
<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 [animation-delay:-0.3s]" />
</div>
</div>
) : (
msg.content // <--- CAMBIO CLAVE
)}
{/* Recomendaciones */} {!msg.isRefusal &&
{isAI && msg.suggestions?.length > 0 && ( msg.suggestions &&
<div className="mt-4 w-full space-y-3 rounded-xl border border-slate-100 bg-slate-50/50 p-3"> msg.suggestions.length > 0 && (
<div className="flex items-center justify-between px-1"> <div className="mt-4">
<span className="text-[10px] font-bold text-slate-400 uppercase">
Sugerencias de mejora
</span>
{/* Botón Seleccionar Todo */}
{msg.suggestions.some(
(s: any) => !s.applied,
) && (
<Button
variant="ghost"
className="h-6 px-2 text-[10px] text-teal-600 hover:bg-teal-100"
onClick={() =>
toggleAllFromMessage(msg.suggestions)
}
>
{msg.suggestions
.filter((s: any) => !s.applied)
.every((s: any) =>
selectedImprovements.includes(s.key),
)
? 'Desmarcar todo'
: 'Seleccionar todo'}
</Button>
)}
</div>
{msg.suggestions.map((sug: any) => (
<div key={sug.key} className="flex gap-2">
{!sug.applied && (
<input
type="checkbox"
className="mt-4 h-4 w-4 shrink-0 rounded border-slate-300 accent-teal-600"
checked={selectedImprovements.includes(
sug.key,
)}
onChange={() =>
toggleImprovementSelection(sug.key)
}
/>
)}
<div className="flex-1">
<ImprovementCard <ImprovementCard
suggestions={[sug]} // Pasamos solo una para que la card actúe individualmente suggestions={msg.suggestions}
dbMessageId={msg.dbMessageId}
planId={planId} planId={planId}
currentDatos={data?.datos} currentDatos={data?.datos}
activeChatId={activeChatId} activeChatId={activeChatId}
@@ -904,72 +674,34 @@ function RouteComponent() {
} }
/> />
</div> </div>
)}
</div>
</div> </div>
))} ))}
{/* Botón de Acción Masiva específico para este mensaje */} {optimisticMessage && (
{msg.suggestions.some((s: any) => <div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end">
selectedImprovements.includes(s.key), <div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm">
) && ( {optimisticMessage}
<Button
size="sm"
disabled={isSending}
className="w-full bg-teal-600 py-1 text-xs font-bold text-white hover:bg-teal-700"
onClick={() => {
const seleccionadas =
msg.suggestions.filter((s: any) =>
selectedImprovements.includes(s.key),
)
handleApplyMultiple(
seleccionadas,
msg.dbMessageId,
)
}}
>
{isSending ? (
<Loader2
className="mr-2 animate-spin"
size={12}
/>
) : (
<Check className="mr-2" size={12} />
)}
Aplicar seleccionadas (
{
msg.suggestions.filter((s: any) =>
selectedImprovements.includes(s.key),
).length
}
)
</Button>
)}
</div>
)}
</div> </div>
</div> </div>
) )}
})}
{(isSending || isSyncing) && ( {isSending && (
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4"> <div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300">
<Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
<AvatarFallback>
<Sparkles size={16} className="animate-pulse" />
</AvatarFallback>
</Avatar>
<div className="flex flex-col items-start gap-2">
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm"> <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"> <div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span> <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-slate-400 [animation-delay:-0.15s]"></span> <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-slate-400"></span> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" />
</div> </div>
</div> <span className="text-[10px] font-medium tracking-tight text-slate-400 uppercase">
<span className="text-[10px] font-medium text-slate-400 italic"> Esperando respuesta...
La IA está analizando tu solicitud...
</span> </span>
</div> </div>
</div> </div>
</div>
)} )}
</> </>
)} )}
@@ -1117,9 +849,8 @@ function RouteComponent() {
</div> </div>
</div> </div>
</div> </div>
{/* PANEL LATERAL */}
{/* --- PANEL LATERAL: ACCIONES RÁPIDAS (Escritorio) --- */} <div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
<div className="hidden flex-[1] flex-col gap-4 overflow-y-auto pr-2 md:flex">
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800"> <h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas <Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
</h4> </h4>
@@ -1140,65 +871,6 @@ function RouteComponent() {
))} ))}
</div> </div>
</div> </div>
{/* --- DRAWER: HISTORIAL (Móvil) --- */}
<Drawer open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
<DrawerContent className="h-[80vh] p-4">
<Button
onClick={() => {
createNewChat()
setIsHistoryOpen(false)
}}
className="mb-4 w-full bg-teal-600 text-white"
>
<MessageSquarePlus size={18} className="mr-2" /> Nuevo Chat
</Button>
<ScrollArea className="flex-1">
{/* Reutiliza aquí el mapeo de chats que tienes en el panel izquierdo */}
<p className="mb-4 text-xs font-bold text-slate-400 uppercase">
Historial Reciente
</p>
{activeChats.map((chat) => (
<div
key={chat.id}
onClick={() => {
setActiveChatId(chat.id)
setIsHistoryOpen(false)
}}
className="border-b p-3 text-sm"
>
{chat.nombre || 'Chat sin nombre'}
</div>
))}
</ScrollArea>
</DrawerContent>
</Drawer>
{/* --- DRAWER: ACCIONES RÁPIDAS (Móvil) --- */}
<Drawer open={isActionsOpen} onOpenChange={setIsActionsOpen}>
<DrawerContent className="h-[60vh] p-4">
<h4 className="mb-4 flex items-center gap-2 font-bold">
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
</h4>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => {
handleSend(preset.prompt)
setIsActionsOpen(false)
}}
className="flex items-center gap-3 rounded-xl border p-4 text-left text-sm"
>
<preset.icon size={16} />
<span>{preset.label}</span>
</button>
))}
</div>
</DrawerContent>
</Drawer>
{/* Tu Drawer de Referencias IA se queda igual */}
<Drawer open={openIA} onOpenChange={setOpenIA}> <Drawer open={openIA} onOpenChange={setOpenIA}>
<DrawerContent className="fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl"> <DrawerContent className="fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl">
{/* Cabecera más compacta */} {/* Cabecera más compacta */}
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,6 @@
import { createFileRoute, Outlet } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { BibliographyItem } from '@/components/asignaturas/detalle/BibliographyItem'
export const Route = createFileRoute( export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/bibliografia', '/planes/$planId/asignaturas/$asignaturaId/bibliografia',
@@ -7,5 +9,5 @@ export const Route = createFileRoute(
}) })
function RouteComponent() { function RouteComponent() {
return <Outlet /> return <BibliographyItem />
} }
@@ -1,13 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { BibliographyItem } from '@/components/asignaturas/detalle/BibliographyItem'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/',
)({
component: RouteComponent,
})
function RouteComponent() {
return <BibliographyItem />
}
@@ -1,19 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { NuevaBibliografiaModalContainer } from '@/features/bibliografia/nueva/NuevaBibliografiaModalContainer'
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva',
)({
component: NuevaBibliografiaModal,
})
function NuevaBibliografiaModal() {
const { planId, asignaturaId } = Route.useParams()
return (
<NuevaBibliografiaModalContainer
planId={planId}
asignaturaId={asignaturaId}
/>
)
}
@@ -1,9 +1,8 @@
import { createFileRoute, useParams } from '@tanstack/react-router' import { createFileRoute, useParams } from '@tanstack/react-router'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab' import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
import { useSubject } from '@/data' import { fetchPlanPdf } from '@/data/api/document.api'
import { fetchAsignaturaPdf } from '@/data/api/document.api'
export const Route = createFileRoute( export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId/documento', '/planes/$planId/asignaturas/$asignaturaId/documento',
@@ -12,79 +11,52 @@ export const Route = createFileRoute(
}) })
function RouteComponent() { function RouteComponent() {
const { asignaturaId } = useParams({ const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId/documento', from: '/planes/$planId/asignaturas/$asignaturaId/documento',
}) })
const { data: asignatura } = useSubject(asignaturaId)
const asignaturaFileBaseName = sanitizeFileBaseName(
asignatura?.nombre ?? 'documento_sep',
)
const [pdfUrl, setPdfUrl] = useState<string | null>(null) const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const isMountedRef = useRef<boolean>(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isRegenerating, setIsRegenerating] = useState(false) const [isRegenerating, setIsRegenerating] = useState(false)
const loadPdfPreview = useCallback(async () => { const loadPdfPreview = useCallback(async () => {
try { try {
if (isMountedRef.current) setIsLoading(true) setIsLoading(true)
const pdfBlob = await fetchAsignaturaPdf({ const pdfBlob = await fetchPlanPdf({
asignatura_id: asignaturaId, plan_estudio_id: planId,
convertTo: 'pdf',
}) })
if (!isMountedRef.current) return
const url = window.URL.createObjectURL(pdfBlob) const url = window.URL.createObjectURL(pdfBlob)
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current) setPdfUrl((prev) => {
pdfUrlRef.current = url if (prev) window.URL.revokeObjectURL(prev)
setPdfUrl(url) return url
})
} catch (error) { } catch (error) {
console.error('Error cargando PDF:', error) console.error('Error cargando PDF:', error)
} finally { } finally {
if (isMountedRef.current) setIsLoading(false) setIsLoading(false)
} }
}, [asignaturaId]) }, [planId])
useEffect(() => { useEffect(() => {
isMountedRef.current = true
loadPdfPreview() loadPdfPreview()
return () => { return () => {
isMountedRef.current = false if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
} }
}, [loadPdfPreview]) }, [loadPdfPreview])
const handleDownloadPdf = async () => { const handleDownload = async () => {
const pdfBlob = await fetchAsignaturaPdf({ const pdfBlob = await fetchPlanPdf({
asignatura_id: asignaturaId, plan_estudio_id: planId,
convertTo: 'pdf',
}) })
const url = window.URL.createObjectURL(pdfBlob) const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.download = `${asignaturaFileBaseName}.pdf` link.download = 'documento_sep.pdf'
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
}
const handleDownloadWord = async () => {
const docBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
})
const url = window.URL.createObjectURL(docBlob)
const link = document.createElement('a')
link.href = url
link.download = `${asignaturaFileBaseName}.docx`
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
link.remove() link.remove()
@@ -105,28 +77,9 @@ function RouteComponent() {
<DocumentoSEPTab <DocumentoSEPTab
pdfUrl={pdfUrl} pdfUrl={pdfUrl}
isLoading={isLoading} isLoading={isLoading}
onDownloadPdf={handleDownloadPdf} onDownload={handleDownload}
onDownloadWord={handleDownloadWord}
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
isRegenerating={isRegenerating} isRegenerating={isRegenerating}
/> />
) )
} }
function sanitizeFileBaseName(input: string): string {
const text = String(input)
const withoutControlChars = Array.from(text)
.filter((ch) => {
const code = ch.charCodeAt(0)
return code >= 32 && code !== 127
})
.join('')
const cleaned = withoutControlChars
.replace(/[<>:"/\\|?*]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/g, '')
return (cleaned || 'documento').slice(0, 150)
}
@@ -166,20 +166,30 @@ function AsignaturaLayout() {
onSave={(val) => handleUpdateHeader('nombre', val)} onSave={(val) => handleUpdateHeader('nombre', val)}
/> />
</h1> </h1>
{
// console.log(headerData),
console.log(asignaturaApi.planes_estudio?.nombre)
}
<div className="flex flex-wrap gap-4 text-sm text-blue-200"> <div className="flex flex-wrap gap-4 text-sm text-blue-200">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<GraduationCap className="h-4 w-4 shrink-0" /> <GraduationCap className="h-4 w-4 shrink-0" />
Pertenece al plan:{' '}
<span className="text-blue-100"> <span className="text-blue-100">
{(asignaturaApi.planes_estudio as DatosPlan).nombre || ''} {(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>
</span> </span>
</div> </div>
<p className="text-sm text-blue-300">
Pertenece al plan:{' '}
<span className="cursor-pointer underline">
{asignaturaApi.planes_estudio?.nombre}
</span>
</p>
</div> </div>
<div className="flex flex-col items-end gap-2 text-right"> <div className="flex flex-col items-end gap-2 text-right">
@@ -215,19 +225,14 @@ function AsignaturaLayout() {
{/* TABS */} {/* TABS */}
<nav className="sticky top-0 z-20 border-b bg-white"> <nav className="border-b bg-white">
<div className="mx-auto max-w-7xl px-6"> <div className="mx-auto max-w-7xl px-6">
{/* CAMBIOS CLAVE: <div className="flex justify-center gap-8">
1. overflow-x-auto: Permite scroll horizontal.
2. scrollbar-hide: (Opcional) para que no se vea la barra fea.
3. justify-start md:justify-center: Alineado a la izquierda en móvil para que el scroll funcione, centrado en desktop.
*/}
<div className="no-scrollbar flex items-center justify-start gap-8 overflow-x-auto whitespace-nowrap md:justify-center">
{[ {[
{ label: 'Datos', to: '' }, { label: 'Datos', to: '' },
{ label: 'Contenido', to: 'contenido' }, { label: 'Contenido', to: 'contenido' },
{ label: 'Bibliografía', to: 'bibliografia' }, { label: 'Bibliografía', to: 'bibliografia' },
{ label: 'IA', to: 'iaasignatura' }, { label: 'IA', to: 'asignaturaIa' },
{ label: 'Documento SEP', to: 'documento' }, { label: 'Documento SEP', to: 'documento' },
{ label: 'Historial', to: 'historial' }, { label: 'Historial', to: 'historial' },
].map((tab) => { ].map((tab) => {
@@ -246,7 +251,7 @@ function AsignaturaLayout() {
} }
from="/planes/$planId/asignaturas/$asignaturaId" from="/planes/$planId/asignaturas/$asignaturaId"
params={{ planId, asignaturaId }} params={{ planId, asignaturaId }}
className={`shrink-0 border-b-2 py-4 text-sm font-medium transition-colors ${ className={`border-b-2 py-3 text-sm font-medium ${
isActive isActive
? 'border-blue-600 text-blue-600' ? 'border-blue-600 text-blue-600'
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700' : 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
+12 -139
View File
@@ -4,145 +4,18 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Light.otf') format('opentype');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-LightItalic.otf')
format('opentype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Regular.otf') format('opentype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-RegularItalic.otf')
format('opentype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Bold.otf') format('opentype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-BoldItalic.otf') format('opentype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-Black.otf') format('opentype');
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Sans';
src: url('/fonts/indivisa/IndivisaTextSans-BlackItalic.otf')
format('opentype');
font-weight: 900;
font-style: italic;
}
/* Serif */
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Light.otf') format('opentype');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-LightItalic.otf')
format('opentype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Regular.otf') format('opentype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-RegularItalic.otf')
format('opentype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Bold.otf') format('opentype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-BoldItalic.otf')
format('opentype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-Black.otf') format('opentype');
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: 'Indivisa Serif';
src: url('/fonts/indivisa/IndivisaTextSerif-BlackItalic.otf')
format('opentype');
font-weight: 900;
font-style: italic;
}
body { body {
@apply m-0; @apply m-0;
font-family: var(--font-sans); font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
code { code {
font-family: var(--font-mono); font-family:
} source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
strong,
b,
.font-bold {
font-family: 'Indivisa Sans', serif;
font-weight: 900;
/* Inter letter space */
letter-spacing: -0.025em;
} }
:root { :root {
@@ -178,9 +51,9 @@ b,
--sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698); --sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698);
--sidebar-border: oklch(0.9401 0 0); --sidebar-border: oklch(0.9401 0 0);
--sidebar-ring: oklch(0 0 0); --sidebar-ring: oklch(0 0 0);
--font-sans: 'Indivisa Sans', sans-serif; --font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: 'Indivisa Serif', serif; --font-serif: Lora, serif;
--font-mono: 'Indivisa Sans', monospace; --font-mono: IBM Plex Mono, monospace;
--radius: 1.4rem; --radius: 1.4rem;
--shadow-x: 0px; --shadow-x: 0px;
--shadow-y: 2px; --shadow-y: 2px;
@@ -228,7 +101,7 @@ b,
--chart-1: oklch(0.6686 0.1794 251.7436); --chart-1: oklch(0.6686 0.1794 251.7436);
--chart-2: oklch(0.6342 0.2516 22.4415); --chart-2: oklch(0.6342 0.2516 22.4415);
--chart-3: oklch(0.8718 0.1716 90.9505); --chart-3: oklch(0.8718 0.1716 90.9505);
--chart-4: oklch(11.492% 0.00001 271.152); --chart-4: oklch(0.4503 0.229 263.0881);
--chart-5: oklch(0.8322 0.146 185.9404); --chart-5: oklch(0.8322 0.146 185.9404);
--sidebar: oklch(0.1564 0.0688 261.2771); --sidebar: oklch(0.1564 0.0688 261.2771);
--sidebar-foreground: oklch(0.9551 0 0); --sidebar-foreground: oklch(0.9551 0 0);
@@ -238,9 +111,9 @@ b,
--sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583); --sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583);
--sidebar-border: oklch(0.3289 0.0092 268.3843); --sidebar-border: oklch(0.3289 0.0092 268.3843);
--sidebar-ring: oklch(0.6048 0.2166 257.2136); --sidebar-ring: oklch(0.6048 0.2166 257.2136);
--font-sans: 'Indivisa Sans', sans-serif; --font-sans: Plus Jakarta Sans, sans-serif;
--font-serif: 'Indivisa Serif', serif; --font-serif: Lora, serif;
--font-mono: 'Indivisa Sans', monospace; --font-mono: IBM Plex Mono, monospace;
--radius: 1.4rem; --radius: 1.4rem;
--shadow-x: 0px; --shadow-x: 0px;
--shadow-y: 2px; --shadow-y: 2px;
-12
View File
@@ -1,12 +0,0 @@
declare module 'citeproc' {
const CSL: {
Engine: new (
sys: any,
style: string,
lang?: string,
forceLang?: boolean,
) => any
}
export default CSL
}
+1 -1
View File
@@ -50,7 +50,7 @@ export interface Asignatura {
orden?: number orden?: number
hd: number // <--- Añadir hd: number // <--- Añadir
hi: number // <--- Añadir hi: number // <--- Añadir
prerrequisito_asignatura_id: string | null prerrequisitos: Array<string>
} }
export interface Plan { export interface Plan {
+22 -190
View File
@@ -81,56 +81,6 @@ export type Database = {
}, },
] ]
} }
asignatura_mensajes_ia: {
Row: {
campos: Array<string>
conversacion_asignatura_id: string
enviado_por: string
estado: Database['public']['Enums']['estado_mensaje_ia']
fecha_actualizacion: string
fecha_creacion: string
id: string
is_refusal: boolean
mensaje: string
propuesta: Json | null
respuesta: string | null
}
Insert: {
campos?: Array<string>
conversacion_asignatura_id: string
enviado_por?: string
estado?: Database['public']['Enums']['estado_mensaje_ia']
fecha_actualizacion?: string
fecha_creacion?: string
id?: string
is_refusal?: boolean
mensaje: string
propuesta?: Json | null
respuesta?: string | null
}
Update: {
campos?: Array<string>
conversacion_asignatura_id?: string
enviado_por?: string
estado?: Database['public']['Enums']['estado_mensaje_ia']
fecha_actualizacion?: string
fecha_creacion?: string
id?: string
is_refusal?: boolean
mensaje?: string
propuesta?: Json | null
respuesta?: string | null
}
Relationships: [
{
foreignKeyName: 'asignatura_mensajes_ia_conversacion_asignatura_id_fkey'
columns: ['conversacion_asignatura_id']
isOneToOne: false
referencedRelation: 'conversaciones_asignatura'
referencedColumns: ['id']
},
]
}
asignaturas: { asignaturas: {
Row: { Row: {
actualizado_en: string actualizado_en: string
@@ -141,7 +91,6 @@ export type Database = {
creado_en: string creado_en: string
creado_por: string | null creado_por: string | null
creditos: number creditos: number
criterios_de_evaluacion: Json
datos: Json datos: Json
estado: Database['public']['Enums']['estado_asignatura'] estado: Database['public']['Enums']['estado_asignatura']
estructura_id: string | null estructura_id: string | null
@@ -154,7 +103,6 @@ export type Database = {
numero_ciclo: number | null numero_ciclo: number | null
orden_celda: number | null orden_celda: number | null
plan_estudio_id: string plan_estudio_id: string
prerrequisito_asignatura_id: string | null
tipo: Database['public']['Enums']['tipo_asignatura'] tipo: Database['public']['Enums']['tipo_asignatura']
tipo_origen: Database['public']['Enums']['tipo_origen'] | null tipo_origen: Database['public']['Enums']['tipo_origen'] | null
} }
@@ -167,7 +115,6 @@ export type Database = {
creado_en?: string creado_en?: string
creado_por?: string | null creado_por?: string | null
creditos: number creditos: number
criterios_de_evaluacion?: Json
datos?: Json datos?: Json
estado?: Database['public']['Enums']['estado_asignatura'] estado?: Database['public']['Enums']['estado_asignatura']
estructura_id?: string | null estructura_id?: string | null
@@ -180,7 +127,6 @@ export type Database = {
numero_ciclo?: number | null numero_ciclo?: number | null
orden_celda?: number | null orden_celda?: number | null
plan_estudio_id: string plan_estudio_id: string
prerrequisito_asignatura_id?: string | null
tipo?: Database['public']['Enums']['tipo_asignatura'] tipo?: Database['public']['Enums']['tipo_asignatura']
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
} }
@@ -193,7 +139,6 @@ export type Database = {
creado_en?: string creado_en?: string
creado_por?: string | null creado_por?: string | null
creditos?: number creditos?: number
criterios_de_evaluacion?: Json
datos?: Json datos?: Json
estado?: Database['public']['Enums']['estado_asignatura'] estado?: Database['public']['Enums']['estado_asignatura']
estructura_id?: string | null estructura_id?: string | null
@@ -206,7 +151,6 @@ export type Database = {
numero_ciclo?: number | null numero_ciclo?: number | null
orden_celda?: number | null orden_celda?: number | null
plan_estudio_id?: string plan_estudio_id?: string
prerrequisito_asignatura_id?: string | null
tipo?: Database['public']['Enums']['tipo_asignatura'] tipo?: Database['public']['Enums']['tipo_asignatura']
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
} }
@@ -232,13 +176,6 @@ export type Database = {
referencedRelation: 'estructuras_asignatura' referencedRelation: 'estructuras_asignatura'
referencedColumns: ['id'] referencedColumns: ['id']
}, },
{
foreignKeyName: 'asignaturas_estructura_id_fkey'
columns: ['estructura_id']
isOneToOne: false
referencedRelation: 'plantilla_asignatura'
referencedColumns: ['estructura_id']
},
{ {
foreignKeyName: 'asignaturas_linea_plan_fk_compuesta' foreignKeyName: 'asignaturas_linea_plan_fk_compuesta'
columns: ['linea_plan_id', 'plan_estudio_id'] columns: ['linea_plan_id', 'plan_estudio_id']
@@ -260,55 +197,41 @@ export type Database = {
referencedRelation: 'plantilla_plan' referencedRelation: 'plantilla_plan'
referencedColumns: ['plan_estudio_id'] referencedColumns: ['plan_estudio_id']
}, },
{
foreignKeyName: 'asignaturas_prerrequisito_asignatura_id_fkey'
columns: ['prerrequisito_asignatura_id']
isOneToOne: false
referencedRelation: 'asignaturas'
referencedColumns: ['id']
},
{
foreignKeyName: 'asignaturas_prerrequisito_asignatura_id_fkey'
columns: ['prerrequisito_asignatura_id']
isOneToOne: false
referencedRelation: 'plantilla_asignatura'
referencedColumns: ['asignatura_id']
},
] ]
} }
bibliografia_asignatura: { bibliografia_asignatura: {
Row: { Row: {
actualizado_en: string actualizado_en: string
asignatura_id: string asignatura_id: string
biblioteca_item_id: string | null
cita: string cita: string
creado_en: string creado_en: string
creado_por: string | null creado_por: string | null
id: string id: string
referencia_biblioteca: string | null
referencia_en_linea: string | null
tipo: Database['public']['Enums']['tipo_bibliografia'] tipo: Database['public']['Enums']['tipo_bibliografia']
tipo_fuente: Database['public']['Enums']['tipo_fuente_bibliografia']
} }
Insert: { Insert: {
actualizado_en?: string actualizado_en?: string
asignatura_id: string asignatura_id: string
biblioteca_item_id?: string | null
cita: string cita: string
creado_en?: string creado_en?: string
creado_por?: string | null creado_por?: string | null
id?: string id?: string
referencia_biblioteca?: string | null
referencia_en_linea?: string | null
tipo: Database['public']['Enums']['tipo_bibliografia'] tipo: Database['public']['Enums']['tipo_bibliografia']
tipo_fuente?: Database['public']['Enums']['tipo_fuente_bibliografia']
} }
Update: { Update: {
actualizado_en?: string actualizado_en?: string
asignatura_id?: string asignatura_id?: string
biblioteca_item_id?: string | null
cita?: string cita?: string
creado_en?: string creado_en?: string
creado_por?: string | null creado_por?: string | null
id?: string id?: string
referencia_biblioteca?: string | null
referencia_en_linea?: string | null
tipo?: Database['public']['Enums']['tipo_bibliografia'] tipo?: Database['public']['Enums']['tipo_bibliografia']
tipo_fuente?: Database['public']['Enums']['tipo_fuente_bibliografia']
} }
Relationships: [ Relationships: [
{ {
@@ -318,13 +241,6 @@ export type Database = {
referencedRelation: 'asignaturas' referencedRelation: 'asignaturas'
referencedColumns: ['id'] referencedColumns: ['id']
}, },
{
foreignKeyName: 'bibliografia_asignatura_asignatura_id_fkey'
columns: ['asignatura_id']
isOneToOne: false
referencedRelation: 'plantilla_asignatura'
referencedColumns: ['asignatura_id']
},
{ {
foreignKeyName: 'bibliografia_asignatura_creado_por_fkey' foreignKeyName: 'bibliografia_asignatura_creado_por_fkey'
columns: ['creado_por'] columns: ['creado_por']
@@ -379,13 +295,6 @@ export type Database = {
referencedRelation: 'asignaturas' referencedRelation: 'asignaturas'
referencedColumns: ['id'] referencedColumns: ['id']
}, },
{
foreignKeyName: 'cambios_asignatura_asignatura_id_fkey'
columns: ['asignatura_id']
isOneToOne: false
referencedRelation: 'plantilla_asignatura'
referencedColumns: ['asignatura_id']
},
{ {
foreignKeyName: 'cambios_asignatura_cambiado_por_fkey' foreignKeyName: 'cambios_asignatura_cambiado_por_fkey'
columns: ['cambiado_por'] columns: ['cambiado_por']
@@ -491,7 +400,6 @@ export type Database = {
estado: Database['public']['Enums']['estado_conversacion'] estado: Database['public']['Enums']['estado_conversacion']
id: string id: string
intento_archivado: number intento_archivado: number
nombre: string | null
openai_conversation_id: string openai_conversation_id: string
} }
Insert: { Insert: {
@@ -504,7 +412,6 @@ export type Database = {
estado?: Database['public']['Enums']['estado_conversacion'] estado?: Database['public']['Enums']['estado_conversacion']
id?: string id?: string
intento_archivado?: number intento_archivado?: number
nombre?: string | null
openai_conversation_id: string openai_conversation_id: string
} }
Update: { Update: {
@@ -517,7 +424,6 @@ export type Database = {
estado?: Database['public']['Enums']['estado_conversacion'] estado?: Database['public']['Enums']['estado_conversacion']
id?: string id?: string
intento_archivado?: number intento_archivado?: number
nombre?: string | null
openai_conversation_id?: string openai_conversation_id?: string
} }
Relationships: [ Relationships: [
@@ -535,13 +441,6 @@ export type Database = {
referencedRelation: 'asignaturas' referencedRelation: 'asignaturas'
referencedColumns: ['id'] referencedColumns: ['id']
}, },
{
foreignKeyName: 'conversaciones_asignatura_asignatura_id_fkey'
columns: ['asignatura_id']
isOneToOne: false
referencedRelation: 'plantilla_asignatura'
referencedColumns: ['asignatura_id']
},
{ {
foreignKeyName: 'conversaciones_asignatura_creado_por_fkey' foreignKeyName: 'conversaciones_asignatura_creado_por_fkey'
columns: ['creado_por'] columns: ['creado_por']
@@ -653,8 +552,7 @@ export type Database = {
definicion: Json definicion: Json
id: string id: string
nombre: string nombre: string
template_id: string | null version: string | null
tipo: Database['public']['Enums']['tipo_estructura_plan'] | null
} }
Insert: { Insert: {
actualizado_en?: string actualizado_en?: string
@@ -662,8 +560,7 @@ export type Database = {
definicion?: Json definicion?: Json
id?: string id?: string
nombre: string nombre: string
template_id?: string | null version?: string | null
tipo?: Database['public']['Enums']['tipo_estructura_plan'] | null
} }
Update: { Update: {
actualizado_en?: string actualizado_en?: string
@@ -671,8 +568,7 @@ export type Database = {
definicion?: Json definicion?: Json
id?: string id?: string
nombre?: string nombre?: string
template_id?: string | null version?: string | null
tipo?: Database['public']['Enums']['tipo_estructura_plan'] | null
} }
Relationships: [] Relationships: []
} }
@@ -796,13 +692,6 @@ export type Database = {
referencedRelation: 'asignaturas' referencedRelation: 'asignaturas'
referencedColumns: ['id'] referencedColumns: ['id']
}, },
{
foreignKeyName: 'interacciones_ia_asignatura_id_fkey'
columns: ['asignatura_id']
isOneToOne: false
referencedRelation: 'plantilla_asignatura'
referencedColumns: ['asignatura_id']
},
{ {
foreignKeyName: 'interacciones_ia_plan_estudio_id_fkey' foreignKeyName: 'interacciones_ia_plan_estudio_id_fkey'
columns: ['plan_estudio_id'] columns: ['plan_estudio_id']
@@ -909,56 +798,6 @@ export type Database = {
}, },
] ]
} }
plan_mensajes_ia: {
Row: {
campos: Array<string>
conversacion_plan_id: string
enviado_por: string
estado: Database['public']['Enums']['estado_mensaje_ia']
fecha_actualizacion: string
fecha_creacion: string
id: string
is_refusal: boolean
mensaje: string
propuesta: Json | null
respuesta: string | null
}
Insert: {
campos?: Array<string>
conversacion_plan_id: string
enviado_por?: string
estado?: Database['public']['Enums']['estado_mensaje_ia']
fecha_actualizacion?: string
fecha_creacion?: string
id?: string
is_refusal?: boolean
mensaje: string
propuesta?: Json | null
respuesta?: string | null
}
Update: {
campos?: Array<string>
conversacion_plan_id?: string
enviado_por?: string
estado?: Database['public']['Enums']['estado_mensaje_ia']
fecha_actualizacion?: string
fecha_creacion?: string
id?: string
is_refusal?: boolean
mensaje?: string
propuesta?: Json | null
respuesta?: string | null
}
Relationships: [
{
foreignKeyName: 'plan_mensajes_ia_conversacion_plan_id_fkey'
columns: ['conversacion_plan_id']
isOneToOne: false
referencedRelation: 'conversaciones_plan'
referencedColumns: ['id']
},
]
}
planes_estudio: { planes_estudio: {
Row: { Row: {
activo: boolean activo: boolean
@@ -1095,13 +934,6 @@ export type Database = {
referencedRelation: 'asignaturas' referencedRelation: 'asignaturas'
referencedColumns: ['id'] referencedColumns: ['id']
}, },
{
foreignKeyName: 'responsables_asignatura_asignatura_id_fkey'
columns: ['asignatura_id']
isOneToOne: false
referencedRelation: 'plantilla_asignatura'
referencedColumns: ['asignatura_id']
},
{ {
foreignKeyName: 'responsables_asignatura_usuario_id_fkey' foreignKeyName: 'responsables_asignatura_usuario_id_fkey'
columns: ['usuario_id'] columns: ['usuario_id']
@@ -1367,14 +1199,6 @@ export type Database = {
} }
} }
Views: { Views: {
plantilla_asignatura: {
Row: {
asignatura_id: string | null
estructura_id: string | null
template_id: string | null
}
Relationships: []
}
plantilla_plan: { plantilla_plan: {
Row: { Row: {
estructura_id: string | null estructura_id: string | null
@@ -1393,14 +1217,17 @@ export type Database = {
Args: { p_append: Json; p_id: string } Args: { p_append: Json; p_id: string }
Returns: undefined Returns: undefined
} }
suma_porcentajes: { Args: { '': Json }; Returns: number }
unaccent: { Args: { '': string }; Returns: string } unaccent: { Args: { '': string }; Returns: string }
unaccent_immutable: { Args: { '': string }; Returns: string } unaccent_immutable: { Args: { '': string }; Returns: string }
} }
Enums: { Enums: {
estado_asignatura: 'borrador' | 'revisada' | 'aprobada' | 'generando' estado_asignatura:
| 'borrador'
| 'revisada'
| 'aprobada'
| 'generando'
| 'fallida'
estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR' estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR'
estado_mensaje_ia: 'PROCESANDO' | 'COMPLETADO' | 'ERROR'
estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA' estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA'
fuente_cambio: 'HUMANO' | 'IA' fuente_cambio: 'HUMANO' | 'IA'
nivel_plan_estudio: nivel_plan_estudio:
@@ -1573,9 +1400,14 @@ export const Constants = {
}, },
public: { public: {
Enums: { Enums: {
estado_asignatura: ['borrador', 'revisada', 'aprobada', 'generando'], estado_asignatura: [
'borrador',
'revisada',
'aprobada',
'generando',
'fallida',
],
estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'], estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'],
estado_mensaje_ia: ['PROCESANDO', 'COMPLETADO', 'ERROR'],
estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'], estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'],
fuente_cambio: ['HUMANO', 'IA'], fuente_cambio: ['HUMANO', 'IA'],
nivel_plan_estudio: [ nivel_plan_estudio: [
-14
View File
@@ -1,14 +0,0 @@
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": [
"/assets/*",
"/*.css",
"/*.js",
"/*.ico",
"/*.png",
"/*.jpg",
"/*.svg"
]
}
}
-1
View File
@@ -2,7 +2,6 @@
"include": [ "include": [
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
"**/*.d.ts",
"eslint.config.js", "eslint.config.js",
"prettier.config.js", "prettier.config.js",
"vite.config.ts" "vite.config.ts"