Compare commits
1 Commits
main
...
issue/147-
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e1045358d |
@@ -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"
|
|
||||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -1 +1 @@
|
|||||||
Ignora los problemas de imports de eslint
|
Al funcionar como agente, ignora los problemas de eslint del orden de imports
|
||||||
|
|||||||
32
bun.lock
32
bun.lock
@@ -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=="],
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 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 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. C.</term>
|
|
||||||
<term name="bc">a. 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. v.</single>
|
|
||||||
<multiple>s. 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
@@ -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>
|
|
||||||
@@ -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.
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.
@@ -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,12 +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 { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
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
|
||||||
@@ -43,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'))
|
||||||
}
|
}
|
||||||
|
|
||||||
type CriterioEvaluacionRowDraft = {
|
return blocks.join('\n\n').trimEnd()
|
||||||
id: string
|
}
|
||||||
criterio: string
|
|
||||||
porcentaje: string // allow empty while editing
|
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
|
||||||
|
contenido_tematico: parseContenidoTematicoToPlainText,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
@@ -99,19 +132,11 @@ function DatosGenerales({
|
|||||||
}: {
|
}: {
|
||||||
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
|
||||||
@@ -129,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 (
|
||||||
@@ -234,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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -288,11 +244,12 @@ function DatosGenerales({
|
|||||||
<InfoCard
|
<InfoCard
|
||||||
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>
|
||||||
@@ -308,19 +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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoCard({
|
function InfoCard({
|
||||||
@@ -330,22 +279,14 @@ function InfoCard({
|
|||||||
initialContent,
|
initialContent,
|
||||||
placeholder,
|
placeholder,
|
||||||
description,
|
description,
|
||||||
|
xColumn,
|
||||||
required,
|
required,
|
||||||
type = 'text',
|
type = 'text',
|
||||||
onPersist,
|
onPersist,
|
||||||
onClickEditButton,
|
|
||||||
containerRef,
|
|
||||||
forceEditToken,
|
|
||||||
highlightToken,
|
|
||||||
}: 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',
|
||||||
@@ -354,85 +295,16 @@ function InfoCard({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setData(initialContent)
|
setData(initialContent)
|
||||||
setTempText(initialContent)
|
setTempText(initialContent)
|
||||||
|
}, [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 ?? ''))
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ?? ''))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,27 +325,8 @@ function InfoCard({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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">
|
||||||
@@ -522,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" />
|
||||||
@@ -546,177 +404,17 @@ function InfoCard({
|
|||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{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"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
inputMode="numeric"
|
|
||||||
onChange={(e) => {
|
|
||||||
const raw = e.target.value
|
|
||||||
// Solo permitir '' o dígitos
|
|
||||||
if (raw !== '' && !/^\d+$/.test(raw)) return
|
|
||||||
|
|
||||||
if (raw === '') {
|
|
||||||
setEvalRows((prev) =>
|
|
||||||
prev.map((r) =>
|
|
||||||
r.id === row.id
|
|
||||||
? {
|
|
||||||
id: r.id,
|
|
||||||
criterio: r.criterio,
|
|
||||||
porcentaje: '',
|
|
||||||
}
|
|
||||||
: r,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const n = Number(raw)
|
|
||||||
if (!Number.isFinite(n)) return
|
|
||||||
const porcentaje = Math.trunc(n)
|
|
||||||
if (porcentaje < 1 || porcentaje > 100) return
|
|
||||||
|
|
||||||
// No permitir suma > 100
|
|
||||||
setEvalRows((prev) => {
|
|
||||||
const next = prev.map((r) =>
|
|
||||||
r.id === row.id
|
|
||||||
? {
|
|
||||||
id: r.id,
|
|
||||||
criterio: r.criterio,
|
|
||||||
porcentaje: raw,
|
|
||||||
}
|
|
||||||
: r,
|
|
||||||
)
|
|
||||||
|
|
||||||
const total = next.reduce((acc, r) => {
|
|
||||||
const v = String(r.porcentaje).trim()
|
|
||||||
if (!v) return acc
|
|
||||||
const nn = Number(v)
|
|
||||||
if (!Number.isFinite(nn)) return acc
|
|
||||||
const vv = Math.trunc(nn)
|
|
||||||
if (vv < 1 || vv > 100) return acc
|
|
||||||
return acc + vv
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
return total > 100 ? prev : next
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex w-[1ch] items-center justify-center text-sm text-slate-600"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
%
|
|
||||||
</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),
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
aria-label="Quitar renglón"
|
|
||||||
title="Quitar"
|
|
||||||
>
|
|
||||||
<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={() => {
|
|
||||||
// Agregar una fila vacía (siempre permitido)
|
|
||||||
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"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<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)
|
|
||||||
if (type === 'evaluation') {
|
|
||||||
const raw = Array.isArray(data) ? data : []
|
|
||||||
setEvalRows(
|
|
||||||
raw.map((r: CriterioEvaluacionRow) => ({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
criterio:
|
|
||||||
typeof r.criterio === 'string' ? r.criterio : '',
|
|
||||||
porcentaje:
|
|
||||||
typeof r.porcentaje === 'number'
|
|
||||||
? String(Math.trunc(r.porcentaje))
|
|
||||||
: typeof r.porcentaje === 'string'
|
|
||||||
? String(Math.trunc(Number(r.porcentaje)))
|
|
||||||
: '',
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
@@ -724,7 +422,6 @@ 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>
|
||||||
@@ -739,14 +436,11 @@ function InfoCard({
|
|||||||
<p className="text-slate-400 italic">Sin información.</p>
|
<p className="text-slate-400 italic">Sin información.</p>
|
||||||
))}
|
))}
|
||||||
{type === 'requirements' && <RequirementsView items={data} />}
|
{type === 'requirements' && <RequirementsView items={data} />}
|
||||||
{type === 'evaluation' && (
|
{type === 'evaluation' && <EvaluationView items={data} />}
|
||||||
<EvaluationView items={data as Array<CriterioEvaluacionRow>} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,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) => (
|
||||||
@@ -784,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',
|
||||||
@@ -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,208 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,44 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
// import { supabase } from '@/lib/supabase'
|
|
||||||
import { LoginInput } from '../ui/LoginInput'
|
import { LoginInput } from '../ui/LoginInput'
|
||||||
import { SubmitButton } from '../ui/SubmitButton'
|
import { SubmitButton } from '../ui/SubmitButton'
|
||||||
|
|
||||||
|
import { throwIfError } from '@/data/api/_helpers'
|
||||||
|
import { qk } from '@/data/query/keys'
|
||||||
|
import { supabaseBrowser } from '@/data/supabase/client'
|
||||||
|
|
||||||
export function ExternalLoginForm() {
|
export function ExternalLoginForm() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const navigate = useNavigate({ from: '/login' })
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
/* await supabase.auth.signInWithPassword({
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
})*/
|
})
|
||||||
|
throwIfError(error)
|
||||||
|
|
||||||
|
qc.invalidateQueries({ queryKey: qk.session() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.auth })
|
||||||
|
await navigate({ to: '/dashboard', replace: true })
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const anyErr = e as any
|
||||||
|
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,7 +60,11 @@ export function ExternalLoginForm() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={setPassword}
|
||||||
/>
|
/>
|
||||||
<SubmitButton />
|
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||||
|
<SubmitButton
|
||||||
|
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,45 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
// import { supabase } from '@/lib/supabase'
|
|
||||||
import { LoginInput } from '../ui/LoginInput'
|
import { LoginInput } from '../ui/LoginInput'
|
||||||
import { SubmitButton } from '../ui/SubmitButton'
|
import { SubmitButton } from '../ui/SubmitButton'
|
||||||
|
|
||||||
|
import { throwIfError } from '@/data/api/_helpers'
|
||||||
|
import { qk } from '@/data/query/keys'
|
||||||
|
import { supabaseBrowser } from '@/data/supabase/client'
|
||||||
|
|
||||||
export function InternalLoginForm() {
|
export function InternalLoginForm() {
|
||||||
const [clave, setClave] = useState('')
|
const [clave, setClave] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const navigate = useNavigate({ from: '/login' })
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
/* await supabase.auth.signInWithPassword({
|
setIsLoading(true)
|
||||||
email: `${clave}@ulsa.mx`,
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const email = clave.includes('@') ? clave : `${clave}@ulsa.mx`
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
password,
|
password,
|
||||||
})*/
|
})
|
||||||
|
throwIfError(error)
|
||||||
|
|
||||||
|
qc.invalidateQueries({ queryKey: qk.session() })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.auth })
|
||||||
|
await navigate({ to: '/dashboard', replace: true })
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const anyErr = e as any
|
||||||
|
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +57,11 @@ export function InternalLoginForm() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={setPassword}
|
||||||
/>
|
/>
|
||||||
<SubmitButton />
|
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||||
|
<SubmitButton
|
||||||
|
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
text?: string
|
text?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubmitButton({ text = 'Iniciar sesión' }: Props) {
|
export function SubmitButton({ text = 'Iniciar sesión', disabled }: Props) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-[#7b0f1d] text-white py-2 rounded-lg
|
disabled={disabled}
|
||||||
font-semibold hover:opacity-90 transition"
|
className="w-full rounded-lg bg-[#7b0f1d] py-2 font-semibold text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,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 }
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
return msg
|
||||||
|
})
|
||||||
|
|
||||||
// --- FUNCIONES DE ASIGNATURA ---
|
// 3. Actualizar la base de datos con el nuevo JSON
|
||||||
|
const { data, error: updateError } = await supabase
|
||||||
export async function create_subject_conversation(subjectId: string) {
|
.from('conversaciones_plan')
|
||||||
const supabase = supabaseBrowser()
|
.update({ conversacion_json: nuevoJson })
|
||||||
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,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Update
|
|
||||||
const { error: updateError } = await supabase
|
|
||||||
.from('asignatura_mensajes_ia')
|
|
||||||
.update({ propuesta: nuevaPropuesta })
|
|
||||||
.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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Error al generar el PDF')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAsignaturaPdf({
|
// n8n devuelve el archivo → lo tratamos como blob
|
||||||
asignatura_id,
|
return await response.blob()
|
||||||
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>(
|
|
||||||
EDGE.carbone_io_wrapper,
|
|
||||||
{
|
|
||||||
action: 'downloadReport',
|
|
||||||
asignatura_id,
|
|
||||||
body: {
|
|
||||||
...body,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
responseType: 'blob',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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,
|
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(
|
||||||
|
|||||||
@@ -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'] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,21 +22,13 @@ 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,
|
||||||
} from '@/data'
|
} from '@/data'
|
||||||
@@ -106,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>>(
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -129,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)
|
||||||
@@ -161,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,
|
||||||
)
|
)
|
||||||
@@ -199,27 +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])
|
||||||
const scrollToBottom = (behavior = 'smooth') => {
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
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, // 'instant' para carga inicial, 'smooth' para mensajes nuevos
|
behavior: 'smooth',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { activeChats, archivedChats } = useMemo(() => {
|
const { activeChats, archivedChats } = useMemo(() => {
|
||||||
const allChats = lastConversation || []
|
const allChats = lastConversation || []
|
||||||
return {
|
return {
|
||||||
@@ -231,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
|
||||||
@@ -294,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
|
||||||
@@ -306,7 +290,7 @@ function RouteComponent() {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
setInput('')
|
setInput('')
|
||||||
// setSelectedFields([])
|
setSelectedFields([])
|
||||||
}
|
}
|
||||||
|
|
||||||
const archiveChat = (e: React.MouseEvent, id: string) => {
|
const archiveChat = (e: React.MouseEvent, id: string) => {
|
||||||
@@ -368,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) => {
|
||||||
@@ -407,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 (
|
||||||
@@ -510,47 +474,27 @@ function RouteComponent() {
|
|||||||
</div>
|
</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) => {
|
||||||
@@ -560,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>
|
||||||
@@ -625,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>
|
||||||
@@ -700,56 +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 &&
|
||||||
|
msg.suggestions.length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<ImprovementCard
|
<ImprovementCard
|
||||||
suggestions={msg.suggestions} // Usamos el nombre normalizado en el flatMap
|
suggestions={msg.suggestions}
|
||||||
dbMessageId={msg.dbMessageId}
|
|
||||||
planId={planId}
|
planId={planId}
|
||||||
currentDatos={data?.datos}
|
currentDatos={data?.datos}
|
||||||
activeChatId={activeChatId}
|
activeChatId={activeChatId}
|
||||||
@@ -761,29 +677,31 @@ function RouteComponent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
|
|
||||||
{(isSending || isSyncing) && (
|
{optimisticMessage && (
|
||||||
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
|
<div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end">
|
||||||
<Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
|
<div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm">
|
||||||
<AvatarFallback>
|
{optimisticMessage}
|
||||||
<Sparkles size={16} className="animate-pulse" />
|
</div>
|
||||||
</AvatarFallback>
|
</div>
|
||||||
</Avatar>
|
)}
|
||||||
<div className="flex flex-col items-start gap-2">
|
|
||||||
|
{isSending && (
|
||||||
|
<div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300">
|
||||||
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
|
<div className="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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
GripVertical,
|
||||||
Trash2,
|
Trash2,
|
||||||
Pencil,
|
Pencil,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -45,33 +46,16 @@ import {
|
|||||||
useUpdateAsignatura,
|
useUpdateAsignatura,
|
||||||
useUpdateLinea,
|
useUpdateLinea,
|
||||||
} from '@/data'
|
} from '@/data'
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip'
|
|
||||||
|
|
||||||
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
|
// --- Mapeadores (Fuera del componente para mayor limpieza) ---
|
||||||
const palette = [
|
|
||||||
'#4F46E5', // índigo
|
|
||||||
'#7C3AED', // violeta
|
|
||||||
'#EA580C', // naranja
|
|
||||||
'#059669', // esmeralda
|
|
||||||
'#DC2626', // rojo
|
|
||||||
'#0891B2', // cyan
|
|
||||||
'#CA8A04', // ámbar
|
|
||||||
'#C026D3', // fucsia
|
|
||||||
]
|
|
||||||
|
|
||||||
const mapLineasToLineaCurricular = (
|
const mapLineasToLineaCurricular = (
|
||||||
lineasApi: Array<any> = [],
|
lineasApi: Array<any> = [],
|
||||||
): Array<LineaCurricular> => {
|
): Array<LineaCurricular> => {
|
||||||
return lineasApi.map((linea, index) => ({
|
return lineasApi.map((linea) => ({
|
||||||
id: linea.id,
|
id: linea.id,
|
||||||
nombre: linea.nombre,
|
nombre: linea.nombre,
|
||||||
orden: linea.orden ?? 0,
|
orden: linea.orden ?? 0,
|
||||||
color: palette[index % palette.length],
|
color: '#1976d2',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +76,7 @@ const mapAsignaturasToAsignaturas = (
|
|||||||
// Mapeo directo de los nuevos campos de la API
|
// Mapeo directo de los nuevos campos de la API
|
||||||
hd: asig.horas_academicas ?? 0,
|
hd: asig.horas_academicas ?? 0,
|
||||||
hi: asig.horas_independientes ?? 0,
|
hi: asig.horas_independientes ?? 0,
|
||||||
prerrequisito_asignatura_id: asig.prerrequisito_asignatura_id ?? null,
|
prerrequisitos: [],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -137,216 +121,52 @@ function StatItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
import * as Icons from 'lucide-react'
|
|
||||||
|
|
||||||
const estadoConfig: Record<
|
|
||||||
Asignatura['estado'],
|
|
||||||
{
|
|
||||||
label: string
|
|
||||||
dot: string
|
|
||||||
soft: string
|
|
||||||
icon: React.ComponentType<{ className?: string }>
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
borrador: {
|
|
||||||
label: 'Borrador',
|
|
||||||
dot: 'bg-slate-500',
|
|
||||||
soft: 'bg-slate-100 text-slate-700',
|
|
||||||
icon: Icons.FileText,
|
|
||||||
},
|
|
||||||
revisada: {
|
|
||||||
label: 'Revisada',
|
|
||||||
dot: 'bg-amber-500',
|
|
||||||
soft: 'bg-amber-100 text-amber-700',
|
|
||||||
icon: Icons.ScanSearch,
|
|
||||||
},
|
|
||||||
aprobada: {
|
|
||||||
label: 'Aprobada',
|
|
||||||
dot: 'bg-emerald-500',
|
|
||||||
soft: 'bg-emerald-100 text-emerald-700',
|
|
||||||
icon: Icons.BadgeCheck,
|
|
||||||
},
|
|
||||||
generando: {
|
|
||||||
label: 'Generando',
|
|
||||||
dot: 'bg-sky-500',
|
|
||||||
soft: 'bg-sky-100 text-sky-700',
|
|
||||||
icon: Icons.LoaderCircle,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function hexToRgba(hex: string, alpha: number) {
|
|
||||||
const clean = hex.replace('#', '')
|
|
||||||
const bigint = parseInt(clean, 16)
|
|
||||||
const r = (bigint >> 16) & 255
|
|
||||||
const g = (bigint >> 8) & 255
|
|
||||||
const b = bigint & 255
|
|
||||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
||||||
}
|
|
||||||
|
|
||||||
function AsignaturaCardItem({
|
function AsignaturaCardItem({
|
||||||
asignatura,
|
asignatura,
|
||||||
lineaColor,
|
|
||||||
lineaNombre,
|
|
||||||
onDragStart,
|
onDragStart,
|
||||||
isDragging,
|
isDragging,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
asignatura: Asignatura
|
asignatura: Asignatura
|
||||||
lineaColor: string
|
|
||||||
lineaNombre?: string
|
|
||||||
onDragStart: (e: React.DragEvent, id: string) => void
|
onDragStart: (e: React.DragEvent, id: string) => void
|
||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}) {
|
}) {
|
||||||
const estado = estadoConfig[asignatura.estado] ?? estadoConfig.borrador
|
|
||||||
const EstadoIcon = estado.icon
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={150}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
<button
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => onDragStart(e, asignatura.id)}
|
onDragStart={(e) => onDragStart(e, asignatura.id)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={[
|
className={`group cursor-grab rounded-lg border bg-white p-3 shadow-sm transition-all active:cursor-grabbing ${
|
||||||
'group relative h-[200px] w-[272px] shrink-0 overflow-hidden rounded-[22px] border text-left',
|
|
||||||
'transition-all duration-300 ease-out',
|
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
|
|
||||||
'active:cursor-grabbing cursor-grab',
|
|
||||||
isDragging
|
isDragging
|
||||||
? 'scale-[0.985] opacity-45 shadow-none'
|
? 'scale-95 opacity-40'
|
||||||
: 'hover:-translate-y-1 hover:shadow-lg',
|
: 'hover:border-teal-400 hover:shadow-md'
|
||||||
].join(' ')}
|
}`}
|
||||||
style={{
|
|
||||||
borderColor: hexToRgba(lineaColor, 0.18),
|
|
||||||
background: `
|
|
||||||
radial-gradient(circle at top right, ${hexToRgba(lineaColor, 0.22)} 0%, transparent 34%),
|
|
||||||
linear-gradient(180deg, ${hexToRgba(lineaColor, 0.12)} 0%, ${hexToRgba(lineaColor, 0.04)} 42%, var(--card) 100%)
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
title={asignatura.nombre}
|
|
||||||
>
|
>
|
||||||
{/* franja */}
|
<div className="mb-1 flex items-start justify-between">
|
||||||
<div
|
<span className="font-mono text-[10px] font-bold text-slate-400">
|
||||||
className="absolute inset-x-0 top-0 h-2"
|
{asignatura.clave}
|
||||||
style={{ backgroundColor: lineaColor }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* glow decorativo */}
|
|
||||||
<div
|
|
||||||
className="absolute -top-10 -right-10 h-28 w-28 rounded-full blur-2xl transition-transform duration-500 group-hover:scale-110"
|
|
||||||
style={{ backgroundColor: hexToRgba(lineaColor, 0.22) }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative flex h-full flex-col p-4">
|
|
||||||
{/* top */}
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div
|
|
||||||
className="inline-flex h-8 max-w-[200px] items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-semibold"
|
|
||||||
style={{
|
|
||||||
borderColor: hexToRgba(lineaColor, 0.2),
|
|
||||||
backgroundColor: hexToRgba(lineaColor, 0.1),
|
|
||||||
color: lineaColor,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icons.KeyRound className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{asignatura.clave || 'Sin clave'}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative flex h-8 items-center overflow-hidden rounded-full bg-background/70 px-2 backdrop-blur-sm">
|
|
||||||
<div className="flex gap-4 items-center gap-1.5 transition-transform duration-300 group-hover:-translate-x-[72px]">
|
|
||||||
<span className={`h-2.5 w-2.5 rounded-full ${estado.dot}`} />
|
|
||||||
<EstadoIcon
|
|
||||||
className={[
|
|
||||||
'h-3.5 w-3.5 text-foreground/65',
|
|
||||||
asignatura.estado === 'generando' ? 'animate-spin' : '',
|
|
||||||
].join(' ')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'absolute right-2 flex translate-x-6 items-center gap-1.5 opacity-0 transition-all duration-300',
|
|
||||||
'group-hover:translate-x-0 group-hover:opacity-100'
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<span className="text-[11px] font-semibold whitespace-nowrap">
|
|
||||||
{estado.label}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<Badge
|
||||||
|
variant="outline"
|
||||||
</div>
|
className={`px-1 py-0 text-[9px] uppercase ${statusBadge[asignatura.estado] || ''}`}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* titulo */}
|
|
||||||
<div className="mt-4 min-h-[72px]">
|
|
||||||
<h3
|
|
||||||
className="overflow-hidden text-[18px] leading-[1.08] font-bold text-foreground"
|
|
||||||
style={{
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 3,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{asignatura.estado}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mb-1 text-xs leading-tight font-bold text-slate-700">
|
||||||
{asignatura.nombre}
|
{asignatura.nombre}
|
||||||
</h3>
|
</p>
|
||||||
</div>
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-slate-500">
|
||||||
{/* bottom */}
|
{asignatura.creditos} CR • HD:{asignatura.hd} • HI:{asignatura.hi}
|
||||||
<div className="mt-auto grid grid-cols-3 gap-2">
|
|
||||||
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
|
|
||||||
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Icons.Award className="h-3.5 w-3.5" />
|
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wide">
|
|
||||||
CR
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<GripVertical
|
||||||
<div className="text-sm font-bold text-foreground">
|
size={12}
|
||||||
{asignatura.creditos}
|
className="text-slate-300 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
|
|
||||||
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Icons.Clock3 className="h-3.5 w-3.5" />
|
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wide">
|
|
||||||
HD
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-bold text-foreground">
|
|
||||||
{asignatura.hd}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/40 bg-white/55 px-2.5 py-2 backdrop-blur-sm dark:border-white/10 dark:bg-white/5">
|
|
||||||
<div className="mb-1 flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Icons.BookOpenText className="h-3.5 w-3.5" />
|
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wide">
|
|
||||||
HI
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-bold text-foreground">
|
|
||||||
{asignatura.hi}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* drag affordance */}
|
|
||||||
<div className="pointer-events-none absolute right-3 bottom-3 rounded-full bg-background/70 p-1.5 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:opacity-100">
|
|
||||||
<Icons.GripVertical className="h-4 w-4 text-muted-foreground/55" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
|
||||||
|
|
||||||
<TooltipContent side="bottom">
|
|
||||||
<div className="text-xs">
|
|
||||||
{lineaNombre ? `${lineaNombre} · ` : ''}
|
|
||||||
{asignatura.nombre}
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,7 +336,6 @@ function MapaCurricularPage() {
|
|||||||
horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes']
|
horas_independientes?: TablesUpdate<'asignaturas'>['horas_independientes']
|
||||||
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo']
|
numero_ciclo?: TablesUpdate<'asignaturas'>['numero_ciclo']
|
||||||
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id']
|
linea_plan_id?: TablesUpdate<'asignaturas'>['linea_plan_id']
|
||||||
prerrequisito_asignatura_id?: string | null
|
|
||||||
}
|
}
|
||||||
const patch: Partial<AsignaturaPatch> = {
|
const patch: Partial<AsignaturaPatch> = {
|
||||||
nombre: editingData.nombre,
|
nombre: editingData.nombre,
|
||||||
@@ -526,7 +345,6 @@ function MapaCurricularPage() {
|
|||||||
horas_independientes: editingData.hi,
|
horas_independientes: editingData.hi,
|
||||||
numero_ciclo: editingData.ciclo,
|
numero_ciclo: editingData.ciclo,
|
||||||
linea_plan_id: editingData.lineaCurricularId,
|
linea_plan_id: editingData.lineaCurricularId,
|
||||||
prerrequisito_asignatura_id: editingData.prerrequisito_asignatura_id,
|
|
||||||
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
|
tipo: editingData.tipo.toUpperCase() as TipoAsignatura, // Asegurar que coincida con el ENUM (OBLIGATORIA/OPTATIVA)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,7 +490,7 @@ function MapaCurricularPage() {
|
|||||||
e: React.FocusEvent<HTMLSpanElement>,
|
e: React.FocusEvent<HTMLSpanElement>,
|
||||||
id: string,
|
id: string,
|
||||||
) => {
|
) => {
|
||||||
const nuevoNombre = e.currentTarget.textContent.trim() || ''
|
const nuevoNombre = e.currentTarget.textContent?.trim() || ''
|
||||||
|
|
||||||
// Buscamos la línea original para comparar
|
// Buscamos la línea original para comparar
|
||||||
const lineaOriginal = lineas.find((l) => l.id === id)
|
const lineaOriginal = lineas.find((l) => l.id === id)
|
||||||
@@ -798,7 +616,8 @@ function MapaCurricularPage() {
|
|||||||
return (
|
return (
|
||||||
<Fragment key={linea.id}>
|
<Fragment key={linea.id}>
|
||||||
<div
|
<div
|
||||||
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${lineColors[idx % lineColors.length]
|
className={`group relative flex items-center justify-between rounded-xl border-l-4 p-4 transition-all ${
|
||||||
|
lineColors[idx % lineColors.length]
|
||||||
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
|
} ${editingLineaId === linea.id ? 'bg-white ring-2 ring-teal-500/20' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
@@ -814,7 +633,8 @@ function MapaCurricularPage() {
|
|||||||
setTempNombreLinea(linea.nombre)
|
setTempNombreLinea(linea.nombre)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full text-xs font-bold break-words outline-none ${editingLineaId === linea.id
|
className={`block w-full text-xs font-bold break-words outline-none ${
|
||||||
|
editingLineaId === linea.id
|
||||||
? 'cursor-text border-b border-teal-500/50 pb-1'
|
? 'cursor-text border-b border-teal-500/50 pb-1'
|
||||||
: 'cursor-pointer'
|
: 'cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
@@ -855,8 +675,6 @@ function MapaCurricularPage() {
|
|||||||
<AsignaturaCardItem
|
<AsignaturaCardItem
|
||||||
key={m.id}
|
key={m.id}
|
||||||
asignatura={m}
|
asignatura={m}
|
||||||
lineaColor={linea.color || '#1976d2'}
|
|
||||||
lineaNombre={linea.nombre}
|
|
||||||
isDragging={draggedAsignatura === m.id}
|
isDragging={draggedAsignatura === m.id}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -907,81 +725,45 @@ function MapaCurricularPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Asignaturas Sin Asignar */}
|
{/* Asignaturas Sin Asignar */}
|
||||||
<div className="mt-12 rounded-[28px] border border-border bg-card/80 p-5 shadow-sm backdrop-blur-sm">
|
<div className="mt-10 rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
||||||
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div className="min-w-0">
|
<div className="flex items-center gap-2 text-slate-600">
|
||||||
<div className="flex items-center gap-2">
|
<h3 className="text-sm font-bold tracking-wider uppercase">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
Bandeja de Entrada / Asignaturas sin asignar
|
||||||
<Icons.Inbox className="h-4.5 w-4.5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="text-sm font-bold tracking-wide text-foreground uppercase">
|
|
||||||
Bandeja de entrada
|
|
||||||
</h3>
|
</h3>
|
||||||
|
<Badge variant="secondary">{unassignedAsignaturas.length}</Badge>
|
||||||
<div className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-muted px-2 text-[11px] font-semibold text-muted-foreground">
|
|
||||||
{unassignedAsignaturas.length}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className="text-xs text-slate-400">
|
||||||
|
Arrastra una asignatura aquí para quitarla del mapa
|
||||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
|
||||||
Asignaturas sin ciclo o línea curricular
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 rounded-full border border-dashed border-border bg-background/80 px-3 py-1.5 text-xs text-muted-foreground">
|
|
||||||
<Icons.MoveDown className="h-3.5 w-3.5" />
|
|
||||||
<span>Arrastra aquí para desasignar</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onDragOver={handleDragOver}
|
className={`flex min-h-[120px] flex-wrap gap-4 rounded-xl border-2 border-dashed p-4 transition-colors ${
|
||||||
onDrop={(e) => handleDrop(e, null, null)}
|
|
||||||
className={[
|
|
||||||
'rounded-[24px] border-2 border-dashed p-4 transition-all duration-300',
|
|
||||||
'min-h-[220px]',
|
|
||||||
draggedAsignatura
|
draggedAsignatura
|
||||||
? 'border-primary/35 bg-primary/6 shadow-inner'
|
? 'border-teal-300 bg-teal-50/50'
|
||||||
: 'border-border bg-muted/20',
|
: 'border-slate-200 bg-white/50'
|
||||||
].join(' ')}
|
}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={(e) => handleDrop(e, null, null)} // Limpia ciclo y línea
|
||||||
>
|
>
|
||||||
{unassignedAsignaturas.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
{unassignedAsignaturas.map((m) => (
|
{unassignedAsignaturas.map((m) => (
|
||||||
<div key={m.id} className="w-[272px] shrink-0">
|
<div key={m.id} className="w-[200px]">
|
||||||
<AsignaturaCardItem
|
<AsignaturaCardItem
|
||||||
asignatura={m}
|
asignatura={m}
|
||||||
lineaColor="#94A3B8"
|
|
||||||
lineaNombre="Sin asignar"
|
|
||||||
isDragging={draggedAsignatura === m.id}
|
isDragging={draggedAsignatura === m.id}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingData(m)
|
setEditingData(m) // Cargamos los datos en el estado de edición
|
||||||
setIsEditModalOpen(true)
|
setIsEditModalOpen(true)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
{unassignedAsignaturas.length === 0 && (
|
||||||
) : (
|
<div className="flex w-full items-center justify-center text-sm text-slate-400">
|
||||||
<div className="flex min-h-[188px] flex-col items-center justify-center rounded-[20px] border border-border/70 bg-background/70 px-6 text-center">
|
No hay asignaturas pendientes. Arrastra una asignatura aquí para
|
||||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
desasignarla.
|
||||||
<Icons.CheckCheck className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm font-semibold text-foreground">
|
|
||||||
No hay asignaturas pendientes
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
|
||||||
Todo está colocado en el mapa. Arrastra una asignatura aquí para quitarle
|
|
||||||
ciclo y línea curricular.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1153,55 +935,65 @@ function MapaCurricularPage() {
|
|||||||
{/* Fila 4: Seriación (Prerrequisitos) */}
|
{/* Fila 4: Seriación (Prerrequisitos) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
<label className="text-xs font-bold text-slate-500 uppercase">
|
||||||
Seriación (Prerrequisito)
|
Seriación (Prerrequisitos)
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select
|
||||||
// Cambiamos a manejo de valor único basado en el ID de la columna
|
value={seriacionValue}
|
||||||
value={editingData.prerrequisito_asignatura_id || undefined}
|
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
console.log(editingData)
|
if (val === 'none') {
|
||||||
|
setSeriacionValue('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!editingData.prerrequisitos.includes(val)) {
|
||||||
setEditingData({
|
setEditingData({
|
||||||
...editingData,
|
...editingData,
|
||||||
prerrequisito_asignatura_id: val === 'none' ? null : val,
|
prerrequisitos: [...editingData.prerrequisitos, val],
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
setSeriacionValue('')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full bg-white">
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Seleccionar asignatura..." />
|
<SelectValue placeholder="Seleccionar asignatura..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">-- Sin Seriación --</SelectItem>
|
<SelectItem value="none">-- Sin Seriación --</SelectItem>
|
||||||
|
|
||||||
{asignaturas
|
{asignaturas
|
||||||
.filter((asig) => {
|
.filter((m) => m.id !== editingData.id)
|
||||||
// 1. No es la misma materia
|
.map((m) => (
|
||||||
const noEsMisma = asig.id !== editingData.id
|
<SelectItem key={m.id} value={m.id}>
|
||||||
// 2. El ciclo debe ser estrictamente MENOR
|
{m.nombre} ({m.clave})
|
||||||
const esCicloMenor =
|
|
||||||
asig.ciclo !== null &&
|
|
||||||
editingData.ciclo !== null &&
|
|
||||||
asig.ciclo < editingData.ciclo
|
|
||||||
|
|
||||||
return noEsMisma && esCicloMenor
|
|
||||||
})
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
(a.ciclo || 0) - (b.ciclo || 0) ||
|
|
||||||
a.nombre.localeCompare(b.nombre),
|
|
||||||
)
|
|
||||||
.map((asig) => (
|
|
||||||
<SelectItem key={asig.id} value={asig.id}>
|
|
||||||
<span className="font-bold text-teal-600">
|
|
||||||
[C{asig.ciclo}]
|
|
||||||
</span>{' '}
|
|
||||||
{asig.nombre}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* Visualización del Prerrequisito con el Nombre */}
|
{/* Visualización de los prerrequisitos seleccionados */}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{editingData.prerrequisitos.map((pre) => (
|
||||||
|
<Badge
|
||||||
|
key={pre}
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-slate-100 text-slate-600"
|
||||||
|
>
|
||||||
|
{pre}
|
||||||
|
<button
|
||||||
|
className="ml-1 hover:text-red-500"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingData({
|
||||||
|
...editingData,
|
||||||
|
prerrequisitos: editingData.prerrequisitos.filter(
|
||||||
|
(p) => p !== pre,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fila 5: Tipo */}
|
{/* Fila 5: Tipo */}
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -222,7 +232,7 @@ function AsignaturaLayout() {
|
|||||||
{ 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) => {
|
||||||
|
|||||||
151
src/styles.css
151
src/styles.css
@@ -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
src/types/citeproc.d.ts
vendored
12
src/types/citeproc.d.ts
vendored
@@ -1,12 +0,0 @@
|
|||||||
declare module 'citeproc' {
|
|
||||||
const CSL: {
|
|
||||||
Engine: new (
|
|
||||||
sys: any,
|
|
||||||
style: string,
|
|
||||||
lang?: string,
|
|
||||||
forceLang?: boolean,
|
|
||||||
) => any
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CSL
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"navigationFallback": {
|
|
||||||
"rewrite": "/index.html",
|
|
||||||
"exclude": [
|
|
||||||
"/assets/*",
|
|
||||||
"/*.css",
|
|
||||||
"/*.js",
|
|
||||||
"/*.ico",
|
|
||||||
"/*.png",
|
|
||||||
"/*.jpg",
|
|
||||||
"/*.svg"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user