Compare commits
111 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3e9d63833 | |||
| 7a2f16b160 | |||
| 883e6e7fa4 | |||
| dd11c2707e | |||
| 60c2e8402d | |||
| 350d7c0f69 | |||
| f2900f3d22 | |||
| 1f27ea6252 | |||
| 0e710f9637 | |||
| 3bb3a11779 | |||
| 75784658bf | |||
| d2f12d96de | |||
| 7f9c3162a0 | |||
| 03ef51d8a6 | |||
| f81b404223 | |||
| 614150f8d1 | |||
| 54a3b74836 | |||
| 0820bf19a5 | |||
| 7ddc5c7f1f | |||
| ad7a741d1e | |||
| 60bfa31186 | |||
| 1bd37c7e27 | |||
| 7eb9ca0bec | |||
| 10dc299311 | |||
| b3954ab16c | |||
| 88a2a28a8d | |||
| 36a11e3793 | |||
| 33efaed03f | |||
| d481e9706c | |||
| ee3b7a56ec | |||
| 2359e38f85 | |||
| ed318fa67b | |||
| c262cd16be | |||
| a08b2abf87 | |||
| a07213d959 | |||
| a2234e5022 | |||
| 658c392f96 | |||
| 4c730fa0ab | |||
| 2abe296b9e | |||
| 1bce226d15 | |||
| b986ec343e | |||
| 9fd816bfa1 | |||
| 379e2d3826 | |||
| cb5422f57c | |||
| 67724181fd | |||
| d9a5cec3c5 | |||
| 96848e1793 | |||
| cbaf96c6b5 | |||
| 0fb831fb58 | |||
| 0d1aa61022 | |||
| 84281a88f2 | |||
| d91018c612 | |||
| 658b2e245c | |||
| 30562fead0 | |||
| 2b91004129 | |||
| 96a045dc67 | |||
| a8229f12d5 | |||
| dd4ac5374a | |||
| 670e0b1d14 | |||
| 93fe247a19 | |||
| 32ebfde9ed | |||
| 32f0c4c4d4 | |||
| 6a520ef6b1 | |||
| 25d451839e | |||
| fe8f1d4753 | |||
| 518b1124d8 | |||
| 8bdaf935ca | |||
| 0d636cbf3b | |||
| 82d047e1c2 | |||
| 674c8a6bee | |||
| 3acea813b6 | |||
| e68954e03c | |||
| 296fbfee79 | |||
| a55910c226 | |||
| 88c6dc6b4d | |||
| 03caa791ad | |||
| 577daaff03 | |||
| f75680e8dd | |||
| 0b7f45c150 | |||
| 56ac8c0155 | |||
| 8ecb0f205a | |||
| ea842ee46c | |||
| 11369ce792 | |||
| 78471c19d9 | |||
| 3e8b8cd011 | |||
| 9eb7aae7d0 | |||
| e5afaa0c7c | |||
| 06bae3ba3e | |||
| 614ef3ffaf | |||
| 2c0c9e0ba4 | |||
| a07304c555 | |||
| ab2510ba1c | |||
| 4624c9add1 | |||
| 1b178dd2a8 | |||
| 203e8608a2 | |||
| a9f38e6d72 | |||
| 2c594fb9f7 | |||
| 98be1a0405 | |||
| 2165d4a976 | |||
| 772f3b6750 | |||
| e84e0abe8d | |||
| 37fab3ead6 | |||
| fa200acbfd | |||
| 020caf4e68 | |||
| 896c694a85 | |||
| 990daf5786 | |||
| c1197413db | |||
| bf2b8a9b6e | |||
| d6ecee7549 | |||
| 66bbf8ae17 | |||
| 6012d0ced8 |
@@ -0,0 +1,37 @@
|
||||
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"
|
||||
@@ -0,0 +1 @@
|
||||
Ignora los problemas de imports de eslint
|
||||
@@ -4,6 +4,7 @@
|
||||
"": {
|
||||
"name": "acad-ia-2",
|
||||
"dependencies": {
|
||||
"@dnd-kit/react": "^0.3.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -30,6 +31,7 @@
|
||||
"@tanstack/router-plugin": "^1.132.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"citeproc": "^2.4.63",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -39,7 +41,7 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"use-debounce": "^10.1.0",
|
||||
@@ -137,6 +139,18 @@
|
||||
|
||||
"@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/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
@@ -249,6 +263,8 @@
|
||||
|
||||
"@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/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
@@ -735,6 +751,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
@@ -1309,7 +1327,7 @@
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
|
||||
|
||||
+3
-1
@@ -17,6 +17,7 @@
|
||||
"ci:verify": "prettier --check . && eslint . && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/react": "^0.3.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -43,6 +44,7 @@
|
||||
"@tanstack/router-plugin": "^1.132.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"citeproc": "^2.4.63",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -52,7 +54,7 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"use-debounce": "^10.1.0",
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
<?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
@@ -0,0 +1,519 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,520 @@
|
||||
<?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,11 +1,19 @@
|
||||
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
||||
import { Pencil, Sparkles } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Minus, Pencil, Plus, Sparkles } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import type { AsignaturaDetail } from '@/data'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -13,7 +21,9 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { usePlanAsignaturas } from '@/data'
|
||||
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
|
||||
import { columnParsers } from '@/lib/asignaturaColumnParsers'
|
||||
|
||||
export interface BibliografiaEntry {
|
||||
id: string
|
||||
@@ -41,50 +51,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function parseContenidoTematicoToPlainText(value: unknown): string {
|
||||
if (!Array.isArray(value)) return ''
|
||||
|
||||
const blocks: Array<string> = []
|
||||
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) continue
|
||||
|
||||
const unidad =
|
||||
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
|
||||
? item.unidad
|
||||
: undefined
|
||||
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
|
||||
|
||||
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
|
||||
if (!header) continue
|
||||
|
||||
const lines: Array<string> = [header]
|
||||
|
||||
const temas = Array.isArray(item.temas) ? item.temas : []
|
||||
temas.forEach((tema, idx) => {
|
||||
const temaNombre =
|
||||
typeof tema === 'string'
|
||||
? tema
|
||||
: isRecord(tema) && typeof tema.nombre === 'string'
|
||||
? tema.nombre
|
||||
: ''
|
||||
if (!temaNombre) return
|
||||
|
||||
if (unidad != null) {
|
||||
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
|
||||
} else {
|
||||
lines.push(`${idx + 1}. ${temaNombre}`)
|
||||
}
|
||||
})
|
||||
|
||||
blocks.push(lines.join('\n'))
|
||||
}
|
||||
|
||||
return blocks.join('\n\n').trimEnd()
|
||||
type CriterioEvaluacionRow = {
|
||||
criterio: string
|
||||
porcentaje: number
|
||||
}
|
||||
|
||||
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
|
||||
contenido_tematico: parseContenidoTematicoToPlainText,
|
||||
type CriterioEvaluacionRowDraft = {
|
||||
id: string
|
||||
criterio: string
|
||||
porcentaje: string // allow empty while editing
|
||||
}
|
||||
|
||||
export const Route = createFileRoute(
|
||||
@@ -97,8 +72,12 @@ export default function AsignaturaDetailPage() {
|
||||
const { asignaturaId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
const { planId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
const { data: asignaturaApi } = useSubject(asignaturaId)
|
||||
|
||||
const { data: asignaturasApi, isLoading: loadingAsig } =
|
||||
usePlanAsignaturas(planId)
|
||||
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
|
||||
const updateAsignatura = useUpdateAsignatura()
|
||||
|
||||
@@ -119,24 +98,70 @@ export default function AsignaturaDetailPage() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const asignaturaSeriada = useMemo(() => {
|
||||
if (!asignaturaApi?.prerrequisito_asignatura_id || !asignaturasApi)
|
||||
return null
|
||||
return asignaturasApi.find(
|
||||
(asig) => asig.id === asignaturaApi.prerrequisito_asignatura_id,
|
||||
)
|
||||
}, [asignaturaApi, asignaturasApi])
|
||||
const requisitosFormateados = useMemo(() => {
|
||||
if (!asignaturaSeriada) return []
|
||||
return [
|
||||
{
|
||||
type: 'Pre-requisito',
|
||||
code: asignaturaSeriada.codigo,
|
||||
name: asignaturaSeriada.nombre,
|
||||
id: asignaturaSeriada.id, // Guardamos el ID para el select
|
||||
},
|
||||
]
|
||||
}, [asignaturaSeriada])
|
||||
|
||||
const handleUpdatePrerrequisito = (newId: string | null) => {
|
||||
updateAsignatura.mutate({
|
||||
asignaturaId,
|
||||
patch: {
|
||||
prerrequisito_asignatura_id: newId,
|
||||
},
|
||||
})
|
||||
}
|
||||
/* ---------- sincronizar API ---------- */
|
||||
useEffect(() => {
|
||||
if (asignaturaApi) setAsignatura(asignaturaApi)
|
||||
}, [asignaturaApi])
|
||||
console.log(requisitosFormateados)
|
||||
|
||||
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
|
||||
if (asignaturaApi) setAsignatura(asignaturaApi)
|
||||
}, [asignaturaApi, requisitosFormateados])
|
||||
|
||||
return (
|
||||
<DatosGenerales
|
||||
pre={requisitosFormateados}
|
||||
availableSubjects={asignaturasApi}
|
||||
onPersistDato={handlePersistDatoGeneral}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DatosGenerales({
|
||||
onPersistDato,
|
||||
pre,
|
||||
availableSubjects,
|
||||
}: {
|
||||
onPersistDato: (clave: string, value: string) => void
|
||||
}) {
|
||||
const { asignaturaId } = useParams({
|
||||
const { asignaturaId, planId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
const navigate = useNavigate()
|
||||
|
||||
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)
|
||||
const definicionRaw = data?.estructuras_asignatura?.definicion
|
||||
@@ -154,6 +179,56 @@ function DatosGenerales({
|
||||
const valoresActuales = isRecord(datosRaw)
|
||||
? (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>
|
||||
|
||||
return (
|
||||
@@ -209,10 +284,29 @@ function DatosGenerales({
|
||||
clave={key}
|
||||
title={cardTitle}
|
||||
initialContent={currentContent}
|
||||
xColumn={xColumn}
|
||||
placeholder={placeholder}
|
||||
description={description}
|
||||
onPersist={(clave, value) => onPersistDato(clave, value)}
|
||||
onPersist={({ 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()
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -224,32 +318,39 @@ function DatosGenerales({
|
||||
<div className="space-y-6">
|
||||
{/* Tarjeta de Requisitos */}
|
||||
<InfoCard
|
||||
asignaturaId={asignaturaId}
|
||||
title="Requisitos y Seriación"
|
||||
type="requirements"
|
||||
initialContent={[
|
||||
{
|
||||
type: 'Pre-requisito',
|
||||
code: 'PA-301',
|
||||
name: 'Programación Avanzada',
|
||||
},
|
||||
{
|
||||
type: 'Co-requisito',
|
||||
code: 'MAT-201',
|
||||
name: 'Matemáticas Discretas',
|
||||
},
|
||||
]}
|
||||
initialContent={pre}
|
||||
// Pasamos las materias del plan para el Select (excluyendo la actual)
|
||||
availableSubjects={
|
||||
availableSubjects?.filter(
|
||||
(a) =>
|
||||
a.id !== asignaturaId &&
|
||||
a.numero_ciclo < data?.numero_ciclo &&
|
||||
a.numero_ciclo,
|
||||
) || []
|
||||
}
|
||||
onPersist={({ value }) => {
|
||||
updateAsignatura.mutate({
|
||||
asignaturaId,
|
||||
patch: {
|
||||
prerrequisito_asignatura_id: value, // value ya viene como ID o null desde handleSave
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Tarjeta de Evaluación */}
|
||||
<InfoCard
|
||||
asignaturaId={asignaturaId}
|
||||
title="Sistema de Evaluación"
|
||||
type="evaluation"
|
||||
initialContent={[
|
||||
{ label: 'Exámenes parciales', value: '30%' },
|
||||
{ label: 'Proyecto integrador', value: '35%' },
|
||||
{ label: 'Prácticas de laboratorio', value: '20%' },
|
||||
{ label: 'Participación', value: '15%' },
|
||||
]}
|
||||
initialContent={criteriosEvaluacion}
|
||||
containerRef={evaluationCardRef}
|
||||
forceEditToken={evaluationForceEditToken}
|
||||
highlightToken={evaluationHighlightToken}
|
||||
onPersist={({ value }) => persistCriteriosEvaluacion(value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,11 +366,20 @@ interface InfoCardProps {
|
||||
initialContent: any
|
||||
placeholder?: string
|
||||
description?: string
|
||||
xColumn?: string
|
||||
required?: boolean // Nueva prop para el asterisco
|
||||
type?: 'text' | 'requirements' | 'evaluation'
|
||||
onEnhanceAI?: (content: any) => void
|
||||
onPersist?: (clave: string, value: string) => void
|
||||
onPersist?: (payload: {
|
||||
type: NonNullable<InfoCardProps['type']>
|
||||
clave?: string
|
||||
value: any
|
||||
}) => void | Promise<void>
|
||||
onClickEditButton?: (helpers: { startEditing: () => void }) => void
|
||||
|
||||
containerRef?: React.RefObject<HTMLDivElement | null>
|
||||
forceEditToken?: number
|
||||
highlightToken?: number
|
||||
availableSubjects?: any
|
||||
}
|
||||
|
||||
function InfoCard({
|
||||
@@ -279,14 +389,23 @@ function InfoCard({
|
||||
initialContent,
|
||||
placeholder,
|
||||
description,
|
||||
xColumn,
|
||||
required,
|
||||
type = 'text',
|
||||
onPersist,
|
||||
onClickEditButton,
|
||||
containerRef,
|
||||
forceEditToken,
|
||||
highlightToken,
|
||||
availableSubjects,
|
||||
}: InfoCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [isHighlighted, setIsHighlighted] = useState(false)
|
||||
const [data, setData] = useState(initialContent)
|
||||
const [tempText, setTempText] = useState(initialContent)
|
||||
|
||||
const [evalRows, setEvalRows] = useState<Array<CriterioEvaluacionRowDraft>>(
|
||||
[],
|
||||
)
|
||||
const navigate = useNavigate()
|
||||
const { planId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
@@ -295,152 +414,421 @@ function InfoCard({
|
||||
useEffect(() => {
|
||||
setData(initialContent)
|
||||
setTempText(initialContent)
|
||||
}, [initialContent])
|
||||
console.log(data)
|
||||
console.log(initialContent)
|
||||
if (type === 'evaluation') {
|
||||
const raw = Array.isArray(initialContent) ? initialContent : []
|
||||
const rows: Array<CriterioEvaluacionRowDraft> = raw
|
||||
.map((r: any): CriterioEvaluacionRowDraft | null => {
|
||||
const criterio = typeof r?.criterio === 'string' ? r.criterio : ''
|
||||
const porcentajeNum =
|
||||
typeof r?.porcentaje === 'number'
|
||||
? r.porcentaje
|
||||
: typeof r?.porcentaje === 'string'
|
||||
? Number(r.porcentaje)
|
||||
: NaN
|
||||
|
||||
const porcentaje = Number.isFinite(porcentajeNum)
|
||||
? String(Math.trunc(porcentajeNum))
|
||||
: ''
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
criterio,
|
||||
porcentaje,
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Array<CriterioEvaluacionRowDraft>
|
||||
|
||||
setEvalRows(rows)
|
||||
}
|
||||
}, [initialContent, type])
|
||||
|
||||
useEffect(() => {
|
||||
if (!forceEditToken) return
|
||||
setIsEditing(true)
|
||||
}, [forceEditToken])
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightToken) return
|
||||
setIsHighlighted(true)
|
||||
const t = window.setTimeout(() => setIsHighlighted(false), 900)
|
||||
return () => window.clearTimeout(t)
|
||||
}, [highlightToken])
|
||||
|
||||
const handleSave = () => {
|
||||
console.log('clave, valor:', clave, String(tempText ?? ''))
|
||||
console.log(clave)
|
||||
console.log(tempText)
|
||||
|
||||
if (type === 'evaluation') {
|
||||
const cleaned: Array<CriterioEvaluacionRow> = []
|
||||
for (const r of evalRows) {
|
||||
const criterio = String(r.criterio).trim()
|
||||
const porcentajeStr = String(r.porcentaje).trim()
|
||||
if (!criterio) continue
|
||||
if (!porcentajeStr) continue
|
||||
|
||||
const n = Number(porcentajeStr)
|
||||
if (!Number.isFinite(n)) continue
|
||||
const porcentaje = Math.trunc(n)
|
||||
if (porcentaje < 1 || porcentaje > 100) continue
|
||||
|
||||
cleaned.push({ criterio, porcentaje })
|
||||
}
|
||||
|
||||
setData(cleaned)
|
||||
setEvalRows(
|
||||
cleaned.map((x) => ({
|
||||
id: crypto.randomUUID(),
|
||||
criterio: x.criterio,
|
||||
porcentaje: String(x.porcentaje),
|
||||
})),
|
||||
)
|
||||
setIsEditing(false)
|
||||
|
||||
void onPersist?.({ type, clave, value: cleaned })
|
||||
return
|
||||
}
|
||||
if (type === 'requirements') {
|
||||
console.log('entre aqui ')
|
||||
|
||||
// Si tempText es un array y tiene elementos, tomamos el ID del primero
|
||||
// Si es "none" o está vacío, mandamos null (para limpiar la seriación)
|
||||
const prerequisiteId =
|
||||
Array.isArray(tempText) && tempText.length > 0 ? tempText[0].id : null
|
||||
|
||||
setData(tempText) // Actualiza la vista local
|
||||
setIsEditing(false)
|
||||
|
||||
// Mandamos el ID específico a la base de datos
|
||||
void onPersist?.({
|
||||
type,
|
||||
clave: 'prerrequisito_asignatura_id', // Forzamos la columna correcta
|
||||
value: prerequisiteId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setData(tempText)
|
||||
setIsEditing(false)
|
||||
|
||||
if (type === 'text' && clave && onPersist) {
|
||||
onPersist(clave, String(tempText ?? ''))
|
||||
if (type === 'text') {
|
||||
void onPersist?.({ type, clave, value: String(tempText ?? '') })
|
||||
}
|
||||
}
|
||||
|
||||
const handleIARequest = (campoClave: string) => {
|
||||
console.log(placeholder)
|
||||
console.log(campoClave)
|
||||
|
||||
let targetClave = campoClave
|
||||
if (type === 'evaluation' && !targetClave) {
|
||||
targetClave = 'criterios_de_evaluacion'
|
||||
}
|
||||
|
||||
if (targetClave === 'contenido') {
|
||||
targetClave = 'contenido_tematico'
|
||||
}
|
||||
console.log(targetClave)
|
||||
console.log(asignaturaId)
|
||||
|
||||
// Añadimos un timestamp a la state para forzar que la navegación
|
||||
// genere una nueva ubicación incluso si la ruta y los params son iguales.
|
||||
navigate({
|
||||
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
|
||||
params: { planId, asignaturaId: asignaturaId! },
|
||||
state: {
|
||||
activeTab: 'ia',
|
||||
prefillCampo: campoClave,
|
||||
prefillCampo: targetClave,
|
||||
prefillContenido: data,
|
||||
_ts: Date.now(),
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden transition-all hover:border-slate-300">
|
||||
<TooltipProvider>
|
||||
<CardHeader className="border-b bg-slate-50/50 px-5 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CardTitle className="cursor-help text-sm font-bold text-slate-700">
|
||||
{title}
|
||||
</CardTitle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs text-xs">
|
||||
{description || 'Información del campo'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
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])
|
||||
|
||||
{required && (
|
||||
<span
|
||||
className="text-sm font-bold text-red-500"
|
||||
title="Requerido"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
return (
|
||||
<div ref={containerRef as any}>
|
||||
<Card
|
||||
className={
|
||||
'overflow-hidden transition-all hover:border-slate-300 ' +
|
||||
(isHighlighted ? 'ring-primary/40 ring-2' : '')
|
||||
}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<CardHeader className="border-b bg-slate-50/50 px-5 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CardTitle className="cursor-help text-sm font-bold text-slate-700">
|
||||
{title}
|
||||
</CardTitle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs text-xs">
|
||||
{description || 'Información del campo'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{required && (
|
||||
<span
|
||||
className="text-sm font-bold text-red-500"
|
||||
title="Requerido"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="flex gap-1">
|
||||
{type !== 'requirements' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
|
||||
onClick={() => handleIARequest(clave)}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Mejorar con IA</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400"
|
||||
onClick={() => {
|
||||
const startEditing = () => setIsEditing(true)
|
||||
|
||||
if (onClickEditButton) {
|
||||
onClickEditButton({ startEditing })
|
||||
return
|
||||
}
|
||||
|
||||
startEditing()
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Editar campo</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</TooltipProvider>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="flex gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CardContent className="pt-4">
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
{/* Condicionales de edición según el tipo */}
|
||||
{type === 'requirements' ? (
|
||||
<div className="space-y-3">
|
||||
<label className="text-xs font-medium text-slate-500">
|
||||
Materia de Seriación
|
||||
</label>
|
||||
<Select
|
||||
value={
|
||||
Array.isArray(tempText) && tempText.length > 0
|
||||
? tempText[0].id
|
||||
: 'none'
|
||||
}
|
||||
onValueChange={(val) => {
|
||||
console.log(availableSubjects)
|
||||
|
||||
const selected = availableSubjects?.find(
|
||||
(s) => s.id === val,
|
||||
)
|
||||
if (val === 'none' || !selected) {
|
||||
console.log('guardando')
|
||||
|
||||
setTempText([])
|
||||
} else {
|
||||
console.log('hola')
|
||||
|
||||
setTempText([
|
||||
{
|
||||
id: selected.id,
|
||||
type: 'Pre-requisito',
|
||||
code: selected.codigo,
|
||||
name: selected.nombre,
|
||||
},
|
||||
])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<div className="flex-1 truncate text-left">
|
||||
<SelectValue placeholder="Selecciona una materia" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
Ninguna (Sin seriación)
|
||||
</SelectItem>
|
||||
{availableSubjects?.map((asig) => (
|
||||
<SelectItem
|
||||
key={asig.id}
|
||||
value={asig.id}
|
||||
className="max-w-[300px] sm:max-w-[500px]"
|
||||
>
|
||||
<span className="block truncate">
|
||||
{asig.codigo} - {asig.nombre}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : type === 'evaluation' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{evalRows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="grid grid-cols-[2fr_1fr_1ch_32px] items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
value={row.criterio}
|
||||
placeholder="Criterio"
|
||||
onChange={(e) => {
|
||||
const nextCriterio = e.target.value
|
||||
setEvalRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === row.id
|
||||
? { ...r, criterio: nextCriterio }
|
||||
: r,
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={row.porcentaje}
|
||||
placeholder="%"
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
if (raw !== '' && !/^\d+$/.test(raw)) return
|
||||
|
||||
setEvalRows((prev) => {
|
||||
const next = prev.map((r) =>
|
||||
r.id === row.id ? { ...r, porcentaje: raw } : r,
|
||||
)
|
||||
const total = next.reduce(
|
||||
(acc, r) => acc + (Number(r.porcentaje) || 0),
|
||||
0,
|
||||
)
|
||||
return total > 100 ? prev : next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-slate-600">%</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-600 hover:bg-red-50"
|
||||
onClick={() =>
|
||||
setEvalRows((prev) =>
|
||||
prev.filter((r) => r.id !== row.id),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`text-sm ${evaluationTotal === 100 ? 'text-muted-foreground' : 'text-destructive font-semibold'}`}
|
||||
>
|
||||
Total: {evaluationTotal}/100
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
|
||||
onClick={() => clave && handleIARequest(clave)}
|
||||
size="sm"
|
||||
className="text-emerald-700 hover:bg-emerald-50"
|
||||
onClick={() =>
|
||||
setEvalRows((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
criterio: '',
|
||||
porcentaje: '',
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Mejorar con IA</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400"
|
||||
onClick={() => {
|
||||
// Si esta InfoCard proviene de una columna externa (ej: contenido_tematico),
|
||||
// redirigimos a la pestaña de Contenido en vez de editar inline.
|
||||
if (xColumn === 'contenido_tematico') {
|
||||
// Agregamos un timestamp para forzar la actualización
|
||||
// de la location.state aunque la ruta sea la misma.
|
||||
navigate({
|
||||
to: '/planes/$planId/asignaturas/$asignaturaId/contenido',
|
||||
params: { planId, asignaturaId: asignaturaId! },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsEditing(true)
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Editar campo</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</TooltipProvider>
|
||||
|
||||
<CardContent className="pt-4">
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={tempText}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setTempText(e.target.value)}
|
||||
className="min-h-30 text-sm leading-relaxed"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#00a878] hover:bg-[#008f66]"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm leading-relaxed text-slate-600">
|
||||
{type === 'text' &&
|
||||
(data ? (
|
||||
<p className="whitespace-pre-wrap">{data}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-400 italic">Sin información.</p>
|
||||
))}
|
||||
{type === 'requirements' && <RequirementsView items={data} />}
|
||||
{type === 'evaluation' && <EvaluationView items={data} />}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Textarea
|
||||
value={tempText}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setTempText(e.target.value)}
|
||||
className="min-h-30 text-sm leading-relaxed"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Botones de acción comunes */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsEditing(false)
|
||||
// Lógica de reset si es necesario...
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#00a878] hover:bg-[#008f66]"
|
||||
onClick={handleSave}
|
||||
disabled={type === 'evaluation' && evaluationTotal > 100}
|
||||
>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Modo Visualización */
|
||||
<div className="text-sm leading-relaxed text-slate-600">
|
||||
{type === 'text' &&
|
||||
(data ? (
|
||||
<p className="whitespace-pre-wrap">{data}</p>
|
||||
) : (
|
||||
<p className="text-slate-400 italic">Sin información.</p>
|
||||
))}
|
||||
{type === 'requirements' && <RequirementsView items={data} />}
|
||||
{type === 'evaluation' && <EvaluationView items={data} />}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -466,7 +854,11 @@ function RequirementsView({ items }: { items: Array<any> }) {
|
||||
}
|
||||
|
||||
// Vista de Evaluación
|
||||
function EvaluationView({ items }: { items: Array<any> }) {
|
||||
function EvaluationView({ items }: { items: Array<CriterioEvaluacionRow> }) {
|
||||
const porcentajeTotal = items.reduce(
|
||||
(total, item) => total + Number(item.porcentaje),
|
||||
0,
|
||||
)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, i) => (
|
||||
@@ -474,10 +866,15 @@ function EvaluationView({ items }: { items: Array<any> }) {
|
||||
key={i}
|
||||
className="flex justify-between border-b border-slate-50 pb-1.5 text-sm italic"
|
||||
>
|
||||
<span className="text-slate-500">{item.label}</span>
|
||||
<span className="font-bold text-blue-600">{item.value}</span>
|
||||
<span className="text-slate-500">{item.criterio}</span>
|
||||
<span className="font-bold text-blue-600">{item.porcentaje}%</span>
|
||||
</div>
|
||||
))}
|
||||
{porcentajeTotal < 100 && (
|
||||
<p className="text-destructive text-sm font-medium">
|
||||
El porcentaje total es menor a 100%.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { useNavigate, useParams } from '@tanstack/react-router'
|
||||
import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
@@ -54,7 +54,8 @@ export interface BibliografiaEntry {
|
||||
}
|
||||
|
||||
export function BibliographyItem() {
|
||||
const { asignaturaId } = useParams({
|
||||
const navigate = useNavigate()
|
||||
const { planId, asignaturaId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
|
||||
@@ -68,13 +69,9 @@ export function BibliographyItem() {
|
||||
const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId)
|
||||
|
||||
// --- 3. Estados de UI (Solo para diálogos y edición) ---
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [newEntryType, setNewEntryType] = useState<'BASICA' | 'COMPLEMENTARIA'>(
|
||||
'BASICA',
|
||||
)
|
||||
|
||||
console.log('Datos actuales en el front:', bibliografia)
|
||||
// --- 4. Derivación de datos (Se calculan en cada render) ---
|
||||
@@ -85,20 +82,6 @@ export function BibliographyItem() {
|
||||
|
||||
// --- 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 = (
|
||||
resource: any,
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA',
|
||||
@@ -143,7 +126,7 @@ export function BibliographyItem() {
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500">
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<div className="flex flex-col gap-4 border-b pb-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||
Bibliografía
|
||||
@@ -179,20 +162,17 @@ export function BibliographyItem() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Plus className="mr-2 h-4 w-4" /> Añadir manual
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<AddManualDialog
|
||||
tipo={newEntryType}
|
||||
onTypeChange={setNewEntryType}
|
||||
onAdd={handleAddManual}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: `/planes/${planId}/asignaturas/${asignaturaId}/bibliografia/nueva`,
|
||||
resetScroll: false,
|
||||
})
|
||||
}
|
||||
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" /> Agregar Bibliografía
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -364,49 +344,6 @@ 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) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DragDropProvider } from '@dnd-kit/react'
|
||||
import { isSortable, useSortable } from '@dnd-kit/react/sortable'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import {
|
||||
Plus,
|
||||
@@ -11,7 +13,7 @@ import {
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
|
||||
import type { FocusEvent, KeyboardEvent } from 'react'
|
||||
import type { FocusEvent, KeyboardEvent, ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -50,6 +52,95 @@ export interface UnidadTematica {
|
||||
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> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -100,20 +191,18 @@ function mapContenidoItem(value: unknown, index: number): ContenidoApi | null {
|
||||
if (Array.isArray(value.temas)) {
|
||||
temas = value.temas
|
||||
.map(mapTemaValue)
|
||||
.filter((t): t is ContenidoTemaApi => t !== null)
|
||||
} else if (typeof value.temas === 'string' && value.temas.trim()) {
|
||||
temas = value.temas
|
||||
.split(/\r?\n|,/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.filter((x): x is ContenidoTemaApi => x !== null)
|
||||
}
|
||||
|
||||
return { unidad, titulo, temas }
|
||||
return {
|
||||
...value,
|
||||
unidad,
|
||||
titulo,
|
||||
temas,
|
||||
}
|
||||
}
|
||||
|
||||
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
|
||||
if (value == null) return []
|
||||
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return mapContenidoTematicoFromDb(JSON.parse(value))
|
||||
@@ -192,7 +281,16 @@ export function ContenidoTematico() {
|
||||
const [temaDraftHoras, setTemaDraftHoras] = useState('')
|
||||
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>) => {
|
||||
// A partir del primer guardado, ya respetamos lo que el usuario deje expandido.
|
||||
didInitExpandedUnitsRef.current = true
|
||||
const payload = serializeUnidadesToApi(nextUnidades)
|
||||
await updateContenido.mutateAsync({
|
||||
subjectId: asignaturaId,
|
||||
@@ -246,10 +344,17 @@ 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 = () => {
|
||||
if (!editingTema) return
|
||||
const parsedHoras = Number.parseInt(temaDraftHoras, 10)
|
||||
const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
|
||||
const horasEstimadas = parseHorasEstimadas(temaDraftHoras)
|
||||
|
||||
const next = unidades.map((u) => {
|
||||
if (u.id !== editingTema.unitId) return u
|
||||
@@ -303,28 +408,110 @@ export function ContenidoTematico() {
|
||||
data ? data.contenido_tematico : undefined,
|
||||
)
|
||||
|
||||
const transformed = contenido.map((u, idx) => ({
|
||||
id: `u-${u.unidad || idx + 1}`,
|
||||
numero: u.unidad || idx + 1,
|
||||
nombre: u.titulo || 'Sin título',
|
||||
temas: Array.isArray(u.temas)
|
||||
? u.temas.map((t: any, tidx: number) => ({
|
||||
id: `t-${u.unidad || idx + 1}-${tidx + 1}`,
|
||||
nombre: typeof t === 'string' ? t : t?.nombre || 'Tema',
|
||||
horasEstimadas: t?.horasEstimadas || 0,
|
||||
}))
|
||||
: [],
|
||||
}))
|
||||
// 1. EL ESCUDO: Comparamos si nuestro estado local ya tiene esta info exacta
|
||||
// (Esto ocurre justo después de arrastrar, ya que actualizamos la UI antes que la BD)
|
||||
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,
|
||||
nombre: dbTitulo,
|
||||
temas: Array.isArray(u.temas)
|
||||
? u.temas.map((t: any, tidx: number) => {
|
||||
const dbTemaNombre =
|
||||
typeof t === 'string' ? t : t?.nombre || 'Tema'
|
||||
|
||||
// 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)
|
||||
// Mantener las unidades ya expandidas si existen; si no, expandir la primera.
|
||||
|
||||
setExpandedUnits((prev) => {
|
||||
const validIds = new Set(transformed.map((u) => u.id))
|
||||
const filtered = new Set(
|
||||
Array.from(prev).filter((id) => validIds.has(id)),
|
||||
)
|
||||
if (filtered.size > 0) return filtered
|
||||
return transformed.length > 0 ? new Set([transformed[0].id]) : new Set()
|
||||
|
||||
// Expandir la primera unidad solo una vez al llegar a la ruta.
|
||||
// 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])
|
||||
|
||||
@@ -353,7 +540,7 @@ export function ContenidoTematico() {
|
||||
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos)
|
||||
const totalHoras = unidades.reduce(
|
||||
(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,
|
||||
)
|
||||
|
||||
@@ -364,16 +551,22 @@ export function ContenidoTematico() {
|
||||
setExpandedUnits(newExpanded)
|
||||
}
|
||||
|
||||
const addUnidad = () => {
|
||||
const newNumero = unidades.length + 1
|
||||
const newId = `u-${newNumero}`
|
||||
const insertUnidadAt = (insertIndex: number) => {
|
||||
const newId = createClientId('u')
|
||||
const newUnidad: UnidadTematica = {
|
||||
id: newId,
|
||||
nombre: 'Nueva Unidad',
|
||||
numero: newNumero,
|
||||
numero: 0,
|
||||
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)
|
||||
setExpandedUnits((prev) => {
|
||||
const n = new Set(prev)
|
||||
@@ -382,10 +575,40 @@ export function ContenidoTematico() {
|
||||
})
|
||||
setPendingScrollUnitId(newId)
|
||||
|
||||
// Abrir edición del título inmediatamente
|
||||
setEditingUnit(newId)
|
||||
setUnitDraftNombre(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 ---
|
||||
@@ -451,158 +674,182 @@ export function ContenidoTematico() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{unidades.map((unidad) => (
|
||||
<div
|
||||
key={unidad.id}
|
||||
ref={(el) => {
|
||||
if (el) unitContainerRefs.current.set(unidad.id, el)
|
||||
else unitContainerRefs.current.delete(unidad.id)
|
||||
}}
|
||||
>
|
||||
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
||||
<Collapsible
|
||||
open={expandedUnits.has(unidad.id)}
|
||||
onOpenChange={() => toggleUnit(unidad.id)}
|
||||
>
|
||||
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="h-4 w-4 cursor-grab text-slate-300" />
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-auto p-0">
|
||||
{expandedUnits.has(unidad.id) ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<Badge className="bg-blue-600 font-mono">
|
||||
Unidad {unidad.numero}
|
||||
</Badge>
|
||||
<DragDropProvider onDragEnd={handleReorderEnd}>
|
||||
<div className="space-y-4">
|
||||
{unidades.map((unidad, index) => (
|
||||
<SortableUnidad
|
||||
key={unidad.id}
|
||||
id={unidad.id}
|
||||
index={index}
|
||||
registerContainer={(el) => {
|
||||
if (el) unitContainerRefs.current.set(unidad.id, el)
|
||||
else unitContainerRefs.current.delete(unidad.id)
|
||||
}}
|
||||
>
|
||||
{({ handleRef }) => (
|
||||
<>
|
||||
{index === 0 && (
|
||||
<InsertUnidadOverlay
|
||||
position="top"
|
||||
onInsert={() => insertUnidadAt(index)}
|
||||
/>
|
||||
)}
|
||||
<InsertUnidadOverlay
|
||||
position="bottom"
|
||||
onInsert={() => insertUnidadAt(index + 1)}
|
||||
/>
|
||||
|
||||
{editingUnit === unidad.id ? (
|
||||
<Input
|
||||
ref={unitTitleInputRef}
|
||||
value={unitDraftNombre}
|
||||
onChange={(e) => setUnitDraftNombre(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (cancelNextBlurRef.current) {
|
||||
cancelNextBlurRef.current = false
|
||||
return
|
||||
}
|
||||
commitEditUnit()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelNextBlurRef.current = true
|
||||
cancelEditUnit()
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
className="h-8 max-w-md bg-white"
|
||||
/>
|
||||
) : (
|
||||
<CardTitle
|
||||
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
|
||||
onClick={() => beginEditUnit(unidad.id)}
|
||||
>
|
||||
{unidad.nombre}
|
||||
</CardTitle>
|
||||
)}
|
||||
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
||||
<Collapsible
|
||||
open={expandedUnits.has(unidad.id)}
|
||||
onOpenChange={() => toggleUnit(unidad.id)}
|
||||
>
|
||||
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto cursor-pointer p-0"
|
||||
>
|
||||
{expandedUnits.has(unidad.id) ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<Badge className="bg-blue-600 font-mono">
|
||||
Unidad {unidad.numero}
|
||||
</Badge>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-slate-400">
|
||||
<Clock className="h-3 w-3" />{' '}
|
||||
{unidad.temas.reduce(
|
||||
(sum, t) => sum + (t.horasEstimadas || 0),
|
||||
0,
|
||||
)}
|
||||
h
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-500"
|
||||
onClick={() =>
|
||||
setDeleteDialog({ type: 'unidad', id: unidad.id })
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="bg-white pt-4">
|
||||
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
|
||||
{unidad.temas.map((tema, idx) => (
|
||||
<TemaRow
|
||||
key={tema.id}
|
||||
tema={tema}
|
||||
index={idx + 1}
|
||||
isEditing={
|
||||
!!editingTema &&
|
||||
editingTema.unitId === unidad.id &&
|
||||
editingTema.temaId === tema.id
|
||||
}
|
||||
draftNombre={temaDraftNombre}
|
||||
draftHoras={temaDraftHoras}
|
||||
onBeginEdit={() => beginEditTema(unidad.id, tema.id)}
|
||||
onDraftNombreChange={setTemaDraftNombre}
|
||||
onDraftHorasChange={setTemaDraftHoras}
|
||||
onEditorBlurCapture={handleTemaEditorBlurCapture}
|
||||
onEditorKeyDownCapture={
|
||||
handleTemaEditorKeyDownCapture
|
||||
}
|
||||
onNombreInputRef={(el) => {
|
||||
temaNombreInputElRef.current = el
|
||||
}}
|
||||
onDelete={() =>
|
||||
setDeleteDialog({
|
||||
type: 'tema',
|
||||
id: tema.id,
|
||||
parentId: unidad.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 w-full justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
||||
onClick={() => addTema(unidad.id)}
|
||||
>
|
||||
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{editingUnit === unidad.id ? (
|
||||
<Input
|
||||
ref={unitTitleInputRef}
|
||||
value={unitDraftNombre}
|
||||
onChange={(e) =>
|
||||
setUnitDraftNombre(e.target.value)
|
||||
}
|
||||
onBlur={() => {
|
||||
if (cancelNextBlurRef.current) {
|
||||
cancelNextBlurRef.current = false
|
||||
return
|
||||
}
|
||||
commitEditUnit()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelNextBlurRef.current = true
|
||||
cancelEditUnit()
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
className="h-8 max-w-md bg-white"
|
||||
/>
|
||||
) : (
|
||||
<CardTitle
|
||||
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
|
||||
onClick={() => beginEditUnit(unidad.id)}
|
||||
>
|
||||
{unidad.nombre}
|
||||
</CardTitle>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<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">
|
||||
<Clock className="h-3 w-3" />{' '}
|
||||
{unidad.temas.reduce(
|
||||
(sum, t) => sum + (t.horasEstimadas || 0),
|
||||
0,
|
||||
)}
|
||||
h
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 cursor-pointer text-slate-400 hover:text-red-500"
|
||||
onClick={() =>
|
||||
setDeleteDialog({
|
||||
type: 'unidad',
|
||||
id: unidad.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="bg-white pt-4">
|
||||
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
|
||||
{unidad.temas.map((tema, idx) => (
|
||||
<TemaRow
|
||||
key={tema.id}
|
||||
tema={tema}
|
||||
index={idx + 1}
|
||||
isEditing={
|
||||
!!editingTema &&
|
||||
editingTema.unitId === unidad.id &&
|
||||
editingTema.temaId === tema.id
|
||||
}
|
||||
draftNombre={temaDraftNombre}
|
||||
draftHoras={temaDraftHoras}
|
||||
onBeginEdit={() =>
|
||||
beginEditTema(unidad.id, tema.id)
|
||||
}
|
||||
onDraftNombreChange={setTemaDraftNombre}
|
||||
onDraftHorasChange={setTemaDraftHoras}
|
||||
onEditorBlurCapture={
|
||||
handleTemaEditorBlurCapture
|
||||
}
|
||||
onEditorKeyDownCapture={
|
||||
handleTemaEditorKeyDownCapture
|
||||
}
|
||||
onNombreInputRef={(el) => {
|
||||
temaNombreInputElRef.current = el
|
||||
}}
|
||||
onDelete={() =>
|
||||
setDeleteDialog({
|
||||
type: 'tema',
|
||||
id: tema.id,
|
||||
parentId: unidad.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 w-full cursor-pointer justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
||||
onClick={() => addTema(unidad.id)}
|
||||
>
|
||||
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</SortableUnidad>
|
||||
))}
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
dialog={deleteDialog}
|
||||
@@ -667,6 +914,9 @@ function TemaRow({
|
||||
<Input
|
||||
type="number"
|
||||
value={draftHoras}
|
||||
min={0}
|
||||
max={200}
|
||||
step={0.5}
|
||||
onChange={(e) => onDraftHorasChange(e.target.value)}
|
||||
className="h-8 w-16 bg-white"
|
||||
/>
|
||||
@@ -675,7 +925,7 @@ function TemaRow({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-3 text-left"
|
||||
className="flex flex-1 cursor-pointer items-center gap-3 text-left"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onBeginEdit()
|
||||
@@ -690,7 +940,7 @@ function TemaRow({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-blue-600"
|
||||
className="h-7 w-7 cursor-pointer text-slate-400 hover:text-blue-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onBeginEdit()
|
||||
@@ -701,7 +951,7 @@ function TemaRow({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-red-500"
|
||||
className="h-7 w-7 cursor-pointer text-slate-400 hover:text-red-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
|
||||
@@ -18,7 +18,8 @@ import { Card } from '@/components/ui/card'
|
||||
interface DocumentoSEPTabProps {
|
||||
pdfUrl: string | null
|
||||
isLoading: boolean
|
||||
onDownload: () => void
|
||||
onDownloadPdf: () => void
|
||||
onDownloadWord: () => void
|
||||
onRegenerate: () => void
|
||||
isRegenerating: boolean
|
||||
}
|
||||
@@ -26,7 +27,8 @@ interface DocumentoSEPTabProps {
|
||||
export function DocumentoSEPTab({
|
||||
pdfUrl,
|
||||
isLoading,
|
||||
onDownload,
|
||||
onDownloadPdf,
|
||||
onDownloadWord,
|
||||
onRegenerate,
|
||||
isRegenerating,
|
||||
}: DocumentoSEPTabProps) {
|
||||
@@ -52,25 +54,23 @@ export function DocumentoSEPTab({
|
||||
</div>
|
||||
|
||||
<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
|
||||
open={showConfirmDialog}
|
||||
onOpenChange={setShowConfirmDialog}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button disabled={isRegenerating}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={isRegenerating}
|
||||
>
|
||||
{isRegenerating ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
|
||||
{isRegenerating ? 'Generando...' : 'Regenerar'}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
@@ -91,11 +91,31 @@ export function DocumentoSEPTab({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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>
|
||||
|
||||
{/* PDF Preview */}
|
||||
<Card className="h-[800px] overflow-hidden">
|
||||
<Card className="h-200 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
||||
import { Check, Loader2, BookOpen, Clock, ListChecks } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { IASugerencia } from '@/types/asignatura'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
useUpdateAsignatura,
|
||||
useSubject,
|
||||
useUpdateSubjectRecommendation,
|
||||
} from '@/data'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ImprovementCardProps {
|
||||
sug: IASugerencia
|
||||
asignaturaId: string
|
||||
onApplied: (campoKey: string) => void
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
export function ImprovementCard({
|
||||
sug,
|
||||
asignaturaId,
|
||||
onApplied,
|
||||
}: ImprovementCardProps) {
|
||||
const { data: asignatura } = useSubject(asignaturaId)
|
||||
const updateAsignatura = useUpdateAsignatura()
|
||||
const updateRecommendation = useUpdateSubjectRecommendation()
|
||||
|
||||
const [isApplying, setIsApplying] = useState(false)
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!asignatura) return
|
||||
|
||||
setIsApplying(true)
|
||||
try {
|
||||
// 1. Identificar a qué columna debe ir el guardado
|
||||
let patchData = {}
|
||||
|
||||
if (sug.campoKey === 'contenido_tematico') {
|
||||
// Se guarda directamente en la columna contenido_tematico
|
||||
patchData = { contenido_tematico: sug.valorSugerido }
|
||||
} else if (sug.campoKey === 'criterios_de_evaluacion') {
|
||||
// Se guarda directamente en la columna criterios_de_evaluacion
|
||||
patchData = { criterios_de_evaluacion: sug.valorSugerido }
|
||||
} else {
|
||||
// Otros campos (ciclo, fines, etc.) se siguen guardando en el JSON de la columna 'datos'
|
||||
patchData = {
|
||||
datos: {
|
||||
...asignatura.datos,
|
||||
[sug.campoKey]: sug.valorSugerido,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Ejecutar la actualización con la estructura correcta
|
||||
await updateAsignatura.mutateAsync({
|
||||
asignaturaId: asignaturaId as any,
|
||||
patch: patchData as any,
|
||||
})
|
||||
|
||||
// 3. Marcar la recomendación como aplicada
|
||||
await updateRecommendation.mutateAsync({
|
||||
mensajeId: sug.messageId,
|
||||
campoAfectado: sug.campoKey,
|
||||
})
|
||||
console.log(sug.campoKey)
|
||||
|
||||
onApplied(sug.campoKey)
|
||||
} catch (error) {
|
||||
console.error('Error al aplicar mejora:', error)
|
||||
} finally {
|
||||
setIsApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- FUNCIÓN PARA RENDERIZAR EL CONTENIDO DE FORMA SEGURA ---
|
||||
const renderContenido = (valor: any) => {
|
||||
// Si no es un array, es texto simple
|
||||
if (!Array.isArray(valor)) {
|
||||
return <p className="italic">"{String(valor)}"</p>
|
||||
}
|
||||
|
||||
// --- CASO 1: CONTENIDO TEMÁTICO (Detectamos si el primer objeto tiene 'unidad') ---
|
||||
if (valor[0]?.hasOwnProperty('unidad')) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{valor.map((u: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="rounded-md border border-teal-100 bg-white p-2 shadow-sm"
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 border-b border-slate-50 pb-1 text-[11px] font-bold text-teal-800">
|
||||
<BookOpen size={12} /> Unidad {u.unidad}: {u.titulo}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{u.temas?.map((t: any, tidx: number) => (
|
||||
<li
|
||||
key={tidx}
|
||||
className="flex items-start justify-between gap-2 text-[10px] text-slate-600"
|
||||
>
|
||||
<span className="leading-tight">• {t.nombre}</span>
|
||||
<span className="flex shrink-0 items-center gap-0.5 font-mono text-slate-400">
|
||||
<Clock size={10} /> {t.horasEstimadas}h
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- CASO 2: CRITERIOS DE EVALUACIÓN (Detectamos si tiene 'criterio') ---
|
||||
if (valor[0]?.hasOwnProperty('criterio')) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase">
|
||||
<ListChecks size={12} /> Desglose de evaluación
|
||||
</div>
|
||||
{valor.map((c: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-slate-100 bg-white p-2 shadow-sm"
|
||||
>
|
||||
<span className="text-[11px] leading-tight text-slate-700">
|
||||
{c.criterio}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-full border border-orange-100 bg-orange-50 px-2 py-0.5 text-[10px] font-bold text-orange-600">
|
||||
{c.porcentaje}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Opcional: Suma total para verificar que de 100% */}
|
||||
<div className="pt-1 text-right text-[9px] font-medium text-slate-400">
|
||||
Total:{' '}
|
||||
{valor.reduce(
|
||||
(acc: number, curr: any) => acc + (curr.porcentaje || 0),
|
||||
0,
|
||||
)}
|
||||
%
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Caso por defecto (Array genérico)
|
||||
return (
|
||||
<pre className="text-[10px]">
|
||||
{/* JSON.stringify(valor, null, 2)*/ 'hola'}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
// --- ESTADO APLICADO ---
|
||||
if (sug.aceptada) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-slate-100 bg-white p-3 opacity-80 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-bold text-slate-800">
|
||||
{sug.campoNombre}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-400">
|
||||
<Check size={14} />
|
||||
Aplicado
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-teal-100 bg-teal-50/30 p-3 text-xs leading-relaxed text-slate-500">
|
||||
{renderContenido(sug.valorSugerido)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- ESTADO PENDIENTE ---
|
||||
return (
|
||||
<div className="group flex flex-col rounded-xl border border-teal-100 bg-white p-3 shadow-sm transition-all hover:border-teal-200">
|
||||
<div className="mb-3 flex items-center justify-between gap-4">
|
||||
<span className="max-w-[150px] truncate rounded-lg border border-teal-100 bg-teal-50/50 px-2.5 py-1 text-[10px] font-bold tracking-wider text-teal-700 uppercase">
|
||||
{sug.campoNombre}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isApplying || !asignatura}
|
||||
className="h-8 w-auto bg-teal-600 px-4 text-xs font-semibold shadow-sm hover:bg-teal-700"
|
||||
onClick={handleApply}
|
||||
>
|
||||
{isApplying ? (
|
||||
<Loader2 size={14} className="mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Check size={14} className="mr-1.5" />
|
||||
)}
|
||||
{isApplying ? 'Aplicando...' : 'Aplicar mejora'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-dashed border-slate-200 bg-slate-50/50 p-3 text-xs leading-relaxed text-slate-600',
|
||||
!Array.isArray(sug.valorSugerido) && 'line-clamp-4 italic',
|
||||
)}
|
||||
>
|
||||
{renderContenido(sug.valorSugerido)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
titulo?: string
|
||||
descripcion?: string
|
||||
}
|
||||
export const AlertaConflicto = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
titulo,
|
||||
descripcion,
|
||||
}: Props) => {
|
||||
// Intentamos parsear el mensaje si viene como JSON para la lista de materias
|
||||
let contenido
|
||||
try {
|
||||
const data = JSON.parse(descripcion as any)
|
||||
contenido = (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-600">{data.main}</p>
|
||||
<div className="flex flex-wrap gap-2 py-2">
|
||||
{data.materias.map((m: string, i: number) => (
|
||||
<span
|
||||
key={i}
|
||||
className="animate-in fade-in zoom-in-95 inline-flex items-center rounded-md border border-red-100 bg-red-50 px-2.5 py-1 text-xs font-medium text-red-700 duration-300"
|
||||
>
|
||||
<AlertTriangle className="mr-1.5 h-3 w-3 shrink-0" />
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-semibold text-slate-500">
|
||||
¿Deseas ignorar la regla y moverla de todos modos?
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
} catch {
|
||||
contenido = <p className="text-sm text-slate-600">{descripcion}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog.Root open={isOpen} onOpenChange={onOpenChange}>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay className="fixed inset-0 z-[200] bg-slate-950/40 backdrop-blur-[2px]" />
|
||||
<AlertDialog.Content className="fixed top-1/2 left-1/2 z-[201] w-[95vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-slate-100 bg-white p-6 shadow-2xl">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 text-red-600">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
</div>
|
||||
<AlertDialog.Title className="text-xl font-bold tracking-tight text-slate-900">
|
||||
{titulo}
|
||||
</AlertDialog.Title>
|
||||
</div>
|
||||
|
||||
<AlertDialog.Description asChild>{contenido}</AlertDialog.Description>
|
||||
|
||||
<div className="mt-8 flex flex-col-reverse justify-end gap-3 sm:flex-row">
|
||||
<AlertDialog.Cancel asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onConfirm()} // Si tu componente espera resolve
|
||||
className="font-semibold text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
</AlertDialog.Cancel>
|
||||
|
||||
<AlertDialog.Action asChild>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className="bg-red-600 font-bold text-white shadow-lg shadow-red-200 hover:bg-red-700"
|
||||
>
|
||||
Mover de todos modos
|
||||
</Button>
|
||||
</AlertDialog.Action>
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import * as Icons from 'lucide-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useSubject } from '@/data'
|
||||
|
||||
export function PasoBasicosClonadoInterno({
|
||||
wizard,
|
||||
onChange,
|
||||
}: {
|
||||
wizard: NewSubjectWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||
}) {
|
||||
const sourceId = wizard.clonInterno?.asignaturaOrigenId ?? null
|
||||
const { data: source, isLoading, isError } = useSubject(sourceId)
|
||||
|
||||
const lastAppliedRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) return
|
||||
if (lastAppliedRef.current === source.id) return
|
||||
|
||||
lastAppliedRef.current = source.id
|
||||
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
nombre: source.nombre,
|
||||
codigo: source.codigo ?? '',
|
||||
tipo: (source.tipo as any) ?? null,
|
||||
creditos: source.creditos,
|
||||
horasAcademicas: (source as any).horas_academicas ?? null,
|
||||
horasIndependientes: (source as any).horas_independientes ?? null,
|
||||
estructuraId: (source.estructura_id ??
|
||||
w.datosBasicos.estructuraId) as any,
|
||||
},
|
||||
}))
|
||||
}, [onChange, source])
|
||||
|
||||
if (!sourceId) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Datos básicos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-muted-foreground text-sm">
|
||||
Selecciona una asignatura fuente para continuar.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Datos básicos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-muted-foreground text-sm">
|
||||
Cargando información de la asignatura fuente…
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !source) {
|
||||
return (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive flex items-center gap-2 text-base">
|
||||
<Icons.AlertTriangle className="h-5 w-5" />
|
||||
No se pudo cargar la fuente
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-muted-foreground text-sm">
|
||||
Intenta seleccionar otra asignatura.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PasoBasicosForm
|
||||
wizard={wizard}
|
||||
onChange={onChange}
|
||||
estructuraFuenteId={source.estructura_id ?? null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Icons from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import PasoSugerenciasForm from './PasoSugerenciasForm'
|
||||
@@ -21,9 +22,11 @@ import { cn } from '@/lib/utils'
|
||||
export function PasoBasicosForm({
|
||||
wizard,
|
||||
onChange,
|
||||
estructuraFuenteId,
|
||||
}: {
|
||||
wizard: NewSubjectWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||
estructuraFuenteId?: string | null
|
||||
}) {
|
||||
const { data: estructuras } = useSubjectEstructuras()
|
||||
|
||||
@@ -258,6 +261,17 @@ export function PasoBasicosForm({
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{estructuraFuenteId &&
|
||||
wizard.datosBasicos.estructuraId &&
|
||||
wizard.datosBasicos.estructuraId !== estructuraFuenteId ? (
|
||||
<div className="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-2 rounded-md border p-2 text-xs">
|
||||
<Icons.AlertTriangle className="mt-0.5 h-4 w-4 flex-none" />
|
||||
<span>
|
||||
Es posible que se pierdan datos generales al seleccionar otra
|
||||
estructura.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
import type { Database } from '@/types/supabase'
|
||||
|
||||
import Pagination03 from '@/components/shadcn-studio/pagination/pagination-03'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { supabaseBrowser, useCatalogosPlanes, usePlanes } from '@/data'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type SourceSubjectRow = {
|
||||
id: string
|
||||
nombre: string
|
||||
codigo: string | null
|
||||
creditos: number
|
||||
tipo: any
|
||||
plan_estudio_id: string
|
||||
estructura_id: string | null
|
||||
rank?: number
|
||||
}
|
||||
|
||||
const ALL = '__all__'
|
||||
|
||||
type SearchAsignaturasRow =
|
||||
Database['public']['Functions']['search_asignaturas']['Returns'][number]
|
||||
|
||||
export function PasoFuenteClonadoInterno({
|
||||
wizard,
|
||||
onChange,
|
||||
}: {
|
||||
wizard: NewSubjectWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||
}) {
|
||||
const pageSize = 20
|
||||
|
||||
const facultadId = wizard.clonInterno?.facultadId ?? null
|
||||
const carreraId = wizard.clonInterno?.carreraId ?? null
|
||||
const planOrigenId = wizard.clonInterno?.planOrigenId ?? null
|
||||
const search = wizard.clonInterno?.search ?? ''
|
||||
const page = Math.max(1, wizard.clonInterno?.page ?? 1)
|
||||
|
||||
const [debouncedSearch] = useDebounce(search, 350)
|
||||
|
||||
const { data: catalogos } = useCatalogosPlanes()
|
||||
|
||||
const carrerasOptions = useMemo(() => {
|
||||
const raw = catalogos?.carreras ?? []
|
||||
return facultadId ? raw.filter((c) => c.facultad_id === facultadId) : raw
|
||||
}, [catalogos?.carreras, facultadId])
|
||||
|
||||
const planesQuery = usePlanes({
|
||||
search: '',
|
||||
facultadId: facultadId ?? 'todas',
|
||||
carreraId: carreraId ?? 'todas',
|
||||
estadoId: 'todos',
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
} as any)
|
||||
|
||||
const needPlansForFilter = Boolean((facultadId || carreraId) && !planOrigenId)
|
||||
const plansForFilter = planesQuery.data?.data ?? []
|
||||
|
||||
const { data: subjectsPaged, isLoading: subjectsLoading } = useQuery({
|
||||
queryKey: [
|
||||
'asignaturas',
|
||||
'clone-source',
|
||||
{
|
||||
facultadId,
|
||||
carreraId,
|
||||
planOrigenId,
|
||||
search: debouncedSearch,
|
||||
page,
|
||||
pageSize,
|
||||
planIdsKey: needPlansForFilter
|
||||
? plansForFilter.map((p) => p.id).join(',')
|
||||
: null,
|
||||
},
|
||||
],
|
||||
enabled: !needPlansForFilter || !planesQuery.isLoading,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () => {
|
||||
const supabase = supabaseBrowser()
|
||||
const from = (page - 1) * pageSize
|
||||
const term = debouncedSearch.trim()
|
||||
|
||||
// Una sola llamada limpia. RPC maneja tanto búsqueda vacía como llena.
|
||||
const { data, error } = await supabase.rpc('search_asignaturas', {
|
||||
p_search: term,
|
||||
p_facultad_id: facultadId ?? undefined, // Corrección TS 2322
|
||||
p_carrera_id: carreraId ?? undefined, // Corrección TS 2322
|
||||
p_plan_estudio_id: planOrigenId ?? undefined, // Corrección TS 2322
|
||||
p_limit: pageSize,
|
||||
p_offset: from,
|
||||
})
|
||||
|
||||
if (error) throw new Error(error.message)
|
||||
|
||||
// Extraemos el conteo total de la primera fila.
|
||||
// ESLint feliz: "data" ya no puede ser null a este punto.
|
||||
const count = data.length > 0 ? Number(data[0].total_count) : 0
|
||||
|
||||
return {
|
||||
// ESLint feliz: map directo sobre data
|
||||
data: data.map((r) => ({
|
||||
id: r.id,
|
||||
nombre: r.nombre,
|
||||
codigo: r.codigo,
|
||||
creditos: Number(r.creditos),
|
||||
tipo: r.tipo,
|
||||
plan_estudio_id: r.plan_estudio_id,
|
||||
estructura_id: null,
|
||||
rank: r.rank,
|
||||
})),
|
||||
count,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const subjects = subjectsPaged?.data ?? []
|
||||
const total = subjectsPaged?.count ?? 0
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
useEffect(() => {
|
||||
// clamp page if results shrink
|
||||
if (page > pageCount) {
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonInterno: { ...(w.clonInterno ?? {}), page: pageCount },
|
||||
}))
|
||||
}
|
||||
}, [onChange, page, pageCount])
|
||||
|
||||
const patchClonInterno = (
|
||||
patch: Partial<NonNullable<NewSubjectWizardState['clonInterno']>>,
|
||||
) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonInterno: { ...(w.clonInterno ?? {}), ...patch },
|
||||
}))
|
||||
|
||||
const hasAnyFilter = Boolean(
|
||||
facultadId || carreraId || planOrigenId || search.trim().length,
|
||||
)
|
||||
|
||||
const clearDisabled = !hasAnyFilter
|
||||
|
||||
const selectedId = wizard.clonInterno?.asignaturaOrigenId ?? null
|
||||
|
||||
const resetSelection = () => patchClonInterno({ asignaturaOrigenId: null })
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Fuente</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="grid gap-1">
|
||||
<Label>Facultad</Label>
|
||||
<Select
|
||||
value={facultadId ?? ALL}
|
||||
onValueChange={(val) => {
|
||||
const next = val === ALL ? null : val
|
||||
patchClonInterno({
|
||||
facultadId: next,
|
||||
carreraId: null,
|
||||
planOrigenId: null,
|
||||
page: 1,
|
||||
})
|
||||
resetSelection()
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0 [&>span]:block! [&>span]:truncate!">
|
||||
<SelectValue placeholder="Todas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>Todas</SelectItem>
|
||||
{(catalogos?.facultades ?? []).map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label>Carrera</Label>
|
||||
<Select
|
||||
value={carreraId ?? ALL}
|
||||
onValueChange={(val) => {
|
||||
const next = val === ALL ? null : val
|
||||
patchClonInterno({
|
||||
carreraId: next,
|
||||
planOrigenId: null,
|
||||
page: 1,
|
||||
})
|
||||
resetSelection()
|
||||
}}
|
||||
disabled={!facultadId}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0 [&>span]:block! [&>span]:truncate!">
|
||||
<SelectValue
|
||||
placeholder={facultadId ? 'Todas' : 'Selecciona facultad'}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>Todas</SelectItem>
|
||||
{carrerasOptions.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label>Plan</Label>
|
||||
<Select
|
||||
value={planOrigenId ?? ALL}
|
||||
onValueChange={(val) => {
|
||||
const next = val === ALL ? null : val
|
||||
patchClonInterno({ planOrigenId: next, page: 1 })
|
||||
resetSelection()
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
disabled={!carreraId && !facultadId}
|
||||
>
|
||||
<SelectValue placeholder="Todos" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>Todos</SelectItem>
|
||||
{(planesQuery.data?.data ?? []).map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
|
||||
<div className="grid gap-1">
|
||||
<Label>Buscar</Label>
|
||||
<Input
|
||||
placeholder="Nombre o código..."
|
||||
value={search}
|
||||
onChange={(e) =>
|
||||
patchClonInterno({ search: e.target.value, page: 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
patchClonInterno({
|
||||
facultadId: null,
|
||||
carreraId: null,
|
||||
planOrigenId: null,
|
||||
search: '',
|
||||
page: 1,
|
||||
asignaturaOrigenId: null,
|
||||
})
|
||||
}}
|
||||
disabled={clearDisabled}
|
||||
>
|
||||
<Icons.X className="mr-2 h-4 w-4" />
|
||||
Limpiar filtros
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Selecciona una asignatura fuente (solo una).
|
||||
</div>
|
||||
|
||||
<div className="grid max-h-80 gap-2 overflow-y-auto">
|
||||
{subjectsLoading ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Cargando asignaturas…
|
||||
</div>
|
||||
) : subjects.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No hay asignaturas con esos filtros.
|
||||
</div>
|
||||
) : (
|
||||
subjects.map((m) => {
|
||||
const active = String(selectedId) === String(m.id)
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
className={cn(
|
||||
'hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 text-left',
|
||||
active && 'border-primary bg-primary/5 ring-primary ring-1',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className="sr-only"
|
||||
type="radio"
|
||||
name="asignaturaFuente"
|
||||
checked={active}
|
||||
onChange={() =>
|
||||
patchClonInterno({ asignaturaOrigenId: m.id, page })
|
||||
}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{m.nombre}</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
{(m.codigo ? m.codigo : '—') +
|
||||
' • ' +
|
||||
String(m.creditos) +
|
||||
' créditos'}
|
||||
</div>
|
||||
</div>
|
||||
{active ? (
|
||||
<Icons.CheckCircle2 className="text-primary h-5 w-5 flex-none" />
|
||||
) : (
|
||||
<span className="h-5 w-5 flex-none" aria-hidden />
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pageCount > 1 ? (
|
||||
<Pagination03
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPageChange={(nextPage) => patchClonInterno({ page: nextPage })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
qk,
|
||||
useCreateSubjectManual,
|
||||
subjects_get_maybe,
|
||||
subjects_get,
|
||||
} from '@/data'
|
||||
|
||||
export function WizardControls({
|
||||
@@ -201,6 +202,87 @@ export function WizardControls({
|
||||
let startedWaiting = false
|
||||
|
||||
try {
|
||||
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
||||
if (!wizard.plan_estudio_id) {
|
||||
throw new Error('Plan de estudio inválido.')
|
||||
}
|
||||
|
||||
const asignaturaOrigenId = wizard.clonInterno?.asignaturaOrigenId
|
||||
if (!asignaturaOrigenId) {
|
||||
throw new Error('Selecciona una asignatura fuente.')
|
||||
}
|
||||
|
||||
if (!wizard.datosBasicos.estructuraId) {
|
||||
throw new Error('Estructura inválida.')
|
||||
}
|
||||
if (!wizard.datosBasicos.nombre.trim()) {
|
||||
throw new Error('Nombre inválido.')
|
||||
}
|
||||
if (wizard.datosBasicos.tipo == null) {
|
||||
throw new Error('Tipo inválido.')
|
||||
}
|
||||
if (wizard.datosBasicos.creditos == null) {
|
||||
throw new Error('Créditos inválidos.')
|
||||
}
|
||||
|
||||
const fuente = await subjects_get(asignaturaOrigenId as any)
|
||||
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const codigo = (wizard.datosBasicos.codigo ?? '').trim()
|
||||
|
||||
const payload: TablesInsert<'asignaturas'> = {
|
||||
plan_estudio_id: wizard.plan_estudio_id,
|
||||
estructura_id: wizard.datosBasicos.estructuraId,
|
||||
codigo: codigo ? codigo : null,
|
||||
nombre: wizard.datosBasicos.nombre,
|
||||
tipo: wizard.datosBasicos.tipo,
|
||||
creditos: wizard.datosBasicos.creditos,
|
||||
datos: (fuente as any).datos,
|
||||
contenido_tematico: (fuente as any).contenido_tematico,
|
||||
criterios_de_evaluacion: (fuente as any).criterios_de_evaluacion,
|
||||
tipo_origen: 'CLONADO_INTERNO',
|
||||
meta_origen: {
|
||||
...(fuente as any).meta_origen,
|
||||
asignatura_origen_id: fuente.id,
|
||||
plan_origen_id: (fuente as any).plan_estudio_id,
|
||||
},
|
||||
horas_academicas:
|
||||
wizard.datosBasicos.horasAcademicas ??
|
||||
(fuente as any).horas_academicas ??
|
||||
null,
|
||||
horas_independientes:
|
||||
wizard.datosBasicos.horasIndependientes ??
|
||||
(fuente as any).horas_independientes ??
|
||||
null,
|
||||
}
|
||||
|
||||
console.log('payload:', payload)
|
||||
|
||||
const { data: inserted, error: insertError } = await supabase
|
||||
.from('asignaturas')
|
||||
.insert(payload)
|
||||
.select('id,plan_estudio_id')
|
||||
.single()
|
||||
|
||||
if (insertError) throw new Error(insertError.message)
|
||||
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planAsignaturas(wizard.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planHistorial(wizard.plan_estudio_id),
|
||||
})
|
||||
|
||||
navigate({
|
||||
to: `/planes/${inserted.plan_estudio_id}/asignaturas/${inserted.id}`,
|
||||
state: { showConfetti: true },
|
||||
resetScroll: false,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
||||
if (!wizard.plan_estudio_id) {
|
||||
throw new Error('Plan de estudio inválido.')
|
||||
|
||||
@@ -8,6 +8,7 @@ export const ImprovementCard = ({
|
||||
suggestions,
|
||||
onApply,
|
||||
planId,
|
||||
dbMessageId,
|
||||
currentDatos,
|
||||
activeChatId,
|
||||
onApplySuccess,
|
||||
@@ -16,6 +17,7 @@ export const ImprovementCard = ({
|
||||
onApply?: (key: string, value: string) => void
|
||||
planId: string
|
||||
currentDatos: any
|
||||
dbMessageId: string
|
||||
activeChatId: any
|
||||
onApplySuccess?: (key: string) => void
|
||||
}) => {
|
||||
@@ -53,9 +55,11 @@ export const ImprovementCard = ({
|
||||
setLocalApplied((prev) => [...prev, key])
|
||||
|
||||
if (onApplySuccess) onApplySuccess(key)
|
||||
if (activeChatId) {
|
||||
|
||||
// --- CAMBIO AQUÍ: Ahora enviamos el ID del mensaje ---
|
||||
if (dbMessageId) {
|
||||
updateAppliedStatus.mutate({
|
||||
conversacionId: activeChatId,
|
||||
conversacionId: dbMessageId, // Cambiamos el nombre de la propiedad si es necesario
|
||||
campoAfectado: key,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type Pagination03Props = {
|
||||
page: number
|
||||
pageCount: number
|
||||
onPageChange: (page: number) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const toInt = (n: unknown, fallback: number) => {
|
||||
const x = typeof n === 'number' ? n : Number(n)
|
||||
return Number.isFinite(x) ? Math.floor(x) : fallback
|
||||
}
|
||||
|
||||
function getPageItems(page: number, pageCount: number): Array<number | '...'> {
|
||||
if (pageCount <= 7) {
|
||||
return Array.from({ length: pageCount }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
const items: Array<number | '...'> = []
|
||||
const safePage = Math.min(Math.max(page, 1), pageCount)
|
||||
|
||||
items.push(1)
|
||||
|
||||
const start = Math.max(2, safePage - 1)
|
||||
const end = Math.min(pageCount - 1, safePage + 1)
|
||||
|
||||
if (start > 2) items.push('...')
|
||||
for (let p = start; p <= end; p++) items.push(p)
|
||||
if (end < pageCount - 1) items.push('...')
|
||||
|
||||
items.push(pageCount)
|
||||
return items
|
||||
}
|
||||
|
||||
export function Pagination03({
|
||||
page,
|
||||
pageCount,
|
||||
onPageChange,
|
||||
className,
|
||||
}: Pagination03Props) {
|
||||
const safePageCount = Math.max(1, toInt(pageCount, 1))
|
||||
const safePage = Math.min(Math.max(toInt(page, 1), 1), safePageCount)
|
||||
|
||||
const items = getPageItems(safePage, safePageCount)
|
||||
const canPrev = safePage > 1
|
||||
const canNext = safePage < safePageCount
|
||||
|
||||
const go = (p: number) => {
|
||||
const next = Math.min(Math.max(p, 1), safePageCount)
|
||||
if (next === safePage) return
|
||||
onPageChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<Pagination className={className}>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
className={cn(!canPrev && 'pointer-events-none opacity-50')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (!canPrev) return
|
||||
go(safePage - 1)
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{items.map((it, idx) =>
|
||||
it === '...' ? (
|
||||
<PaginationItem key={`ellipsis-${idx}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={it}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
isActive={it === safePage}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
go(it)
|
||||
}}
|
||||
className={
|
||||
it === safePage
|
||||
? cn(
|
||||
buttonVariants({
|
||||
variant: 'default',
|
||||
size: 'icon',
|
||||
}),
|
||||
'hover:text-primary-foreground! dark:bg-primary dark:text-primary-foreground dark:hover:text-primary-foreground dark:hover:bg-primary/90 shadow-none! dark:border-transparent',
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{it}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
),
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
className={cn(!canNext && 'pointer-events-none opacity-50')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (!canNext) return
|
||||
go(safePage + 1)
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)
|
||||
}
|
||||
|
||||
export default Pagination03
|
||||
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants, type Button } from "@/components/ui/button"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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,18 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<'textarea'>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
data-slot="textarea"
|
||||
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",
|
||||
className
|
||||
'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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
|
||||
@@ -5,16 +5,24 @@ export function WizardResponsiveHeader({
|
||||
wizard,
|
||||
methods,
|
||||
titleOverrides,
|
||||
hiddenStepIds,
|
||||
}: {
|
||||
wizard: any
|
||||
methods: any
|
||||
titleOverrides?: Record<string, string>
|
||||
hiddenStepIds?: Array<string>
|
||||
}) {
|
||||
const idx = wizard.utils.getIndex(methods.current.id)
|
||||
const totalSteps = wizard.steps.length
|
||||
const currentIndex = idx + 1
|
||||
const hasNextStep = idx < totalSteps - 1
|
||||
const nextStep = wizard.steps[currentIndex]
|
||||
const hidden = new Set(hiddenStepIds ?? [])
|
||||
const visibleSteps = (wizard.steps as Array<any>).filter(
|
||||
(s) => s && !hidden.has(s.id),
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -45,10 +53,11 @@ export function WizardResponsiveHeader({
|
||||
|
||||
<div className="hidden sm:block">
|
||||
<wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
|
||||
{wizard.steps.map((step: any) => (
|
||||
{visibleSteps.map((step: any, visibleIdx: number) => (
|
||||
<wizard.Stepper.Step
|
||||
key={step.id}
|
||||
of={step.id}
|
||||
icon={visibleIdx + 1}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
<wizard.Stepper.Title>
|
||||
|
||||
+168
-29
@@ -100,7 +100,7 @@ export async function library_search(payload: {
|
||||
export async function create_conversation(planId: string) {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
'create-chat-conversation/conversations',
|
||||
'create-chat-conversation/plan/conversations',
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
@@ -149,7 +149,7 @@ export async function ai_plan_chat_v2(payload: {
|
||||
}): Promise<{ reply: string; meta?: any }> {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
`create-chat-conversation/conversations/${payload.conversacionId}/messages`,
|
||||
`create-chat-conversation/conversations/plan/${payload.conversacionId}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
@@ -175,6 +175,22 @@ export async function getConversationByPlan(planId: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
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(
|
||||
conversacionId: string,
|
||||
@@ -194,45 +210,168 @@ export async function update_conversation_title(
|
||||
}
|
||||
|
||||
export async function update_recommendation_applied_status(
|
||||
conversacionId: string,
|
||||
mensajeId: string, // Ahora es más eficiente usar el ID del mensaje directamente
|
||||
campoAfectado: string,
|
||||
) {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
// 1. Obtener el estado actual del JSON
|
||||
const { data: conv, error: fetchError } = await supabase
|
||||
.from('conversaciones_plan')
|
||||
.select('conversacion_json')
|
||||
.eq('id', conversacionId)
|
||||
// 1. Obtener la propuesta actual de ese mensaje específico
|
||||
const { data: msgData, error: fetchError } = await supabase
|
||||
.from('plan_mensajes_ia')
|
||||
.select('propuesta')
|
||||
.eq('id', mensajeId)
|
||||
.single()
|
||||
|
||||
if (fetchError) throw fetchError
|
||||
if (!conv.conversacion_json) throw new Error('No se encontró la conversación')
|
||||
if (!msgData?.propuesta)
|
||||
throw new Error('No se encontró la propuesta en el mensaje')
|
||||
|
||||
// 2. Transformar el JSON para marcar como aplicada la recomendación específica
|
||||
// Usamos una transformación inmutable para evitar efectos secundarios
|
||||
const nuevoJson = (conv.conversacion_json as Array<any>).map((msg) => {
|
||||
if (msg.user === 'assistant' && Array.isArray(msg.recommendations)) {
|
||||
return {
|
||||
...msg,
|
||||
recommendations: msg.recommendations.map((rec: any) =>
|
||||
rec.campo_afectado === campoAfectado
|
||||
? { ...rec, aplicada: true }
|
||||
: rec,
|
||||
),
|
||||
}
|
||||
}
|
||||
return msg
|
||||
})
|
||||
const propuestaActual = msgData.propuesta as any
|
||||
|
||||
// 3. Actualizar la base de datos con el nuevo JSON
|
||||
const { data, error: updateError } = await supabase
|
||||
.from('conversaciones_plan')
|
||||
.update({ conversacion_json: nuevoJson })
|
||||
// 2. Modificar el array de recommendations dentro de la propuesta
|
||||
// Mantenemos el resto de la propuesta (prompt, respuesta, etc.) intacto
|
||||
const nuevaPropuesta = {
|
||||
...propuestaActual,
|
||||
recommendations: (propuestaActual.recommendations || []).map((rec: any) =>
|
||||
rec.campo_afectado === campoAfectado ? { ...rec, aplicada: true } : rec,
|
||||
),
|
||||
}
|
||||
|
||||
// 3. Actualizar la base de datos con el nuevo objeto JSON
|
||||
const { error: updateError } = await supabase
|
||||
.from('plan_mensajes_ia')
|
||||
.update({ propuesta: nuevaPropuesta })
|
||||
.eq('id', mensajeId)
|
||||
|
||||
if (updateError) throw updateError
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// --- FUNCIONES DE ASIGNATURA ---
|
||||
|
||||
export async function create_subject_conversation(subjectId: string) {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
'create-chat-conversation/asignatura/conversations', // Ruta corregida
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
asignatura_id: subjectId,
|
||||
instanciador: 'alex',
|
||||
},
|
||||
},
|
||||
)
|
||||
if (error) throw error
|
||||
return data // Retorna { conversation_asignatura: { id, ... } }
|
||||
}
|
||||
|
||||
export async function ai_subject_chat_v2(payload: {
|
||||
conversacionId: string
|
||||
content: string
|
||||
campos?: Array<string>
|
||||
}) {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
`create-chat-conversation/conversations/asignatura/${payload.conversacionId}/messages`, // Ruta corregida
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
content: payload.content,
|
||||
campos: payload.campos || [],
|
||||
},
|
||||
},
|
||||
)
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getConversationBySubject(subjectId: string) {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from('conversaciones_asignatura') // Tabla corregida
|
||||
.select('*')
|
||||
.eq('asignatura_id', subjectId)
|
||||
.order('creado_en', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export async function getMessagesBySubjectConversation(conversationId: string) {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from('asignatura_mensajes_ia' as any)
|
||||
.select('*')
|
||||
.eq('conversacion_asignatura_id', conversationId)
|
||||
.order('fecha_creacion', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export async function update_subject_recommendation_applied(
|
||||
mensajeId: string,
|
||||
campoAfectado: string,
|
||||
) {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
// 1. Obtener propuesta actual
|
||||
const { data: msgData, error: fetchError } = await supabase
|
||||
.from('asignatura_mensajes_ia')
|
||||
.select('propuesta')
|
||||
.eq('id', mensajeId)
|
||||
.single()
|
||||
|
||||
if (fetchError) throw fetchError
|
||||
const propuestaActual = msgData?.propuesta as any
|
||||
|
||||
// 2. Marcar como aplicada
|
||||
const nuevaPropuesta = {
|
||||
...propuestaActual,
|
||||
recommendations: (propuestaActual.recommendations || []).map((rec: any) =>
|
||||
rec.campo_afectado === campoAfectado ? { ...rec, aplicada: true } : rec,
|
||||
),
|
||||
}
|
||||
|
||||
// 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)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError) throw updateError
|
||||
if (error) throw error
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,27 +1,86 @@
|
||||
// document.api.ts
|
||||
|
||||
const DOCUMENT_PDF_URL =
|
||||
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
|
||||
import { supabaseBrowser } from '../supabase/client'
|
||||
import { invokeEdge } from '../supabase/invokeEdge'
|
||||
|
||||
import { requireData, throwIfError } from './_helpers'
|
||||
|
||||
import type { Tables } from '@/types/supabase'
|
||||
|
||||
const EDGE = {
|
||||
carbone_io_wrapper: 'carbone-io-wrapper',
|
||||
} as const
|
||||
|
||||
interface GeneratePdfParams {
|
||||
plan_estudio_id: string
|
||||
convertTo?: 'pdf'
|
||||
}
|
||||
interface GeneratePdfParamsAsignatura {
|
||||
asignatura_id: string
|
||||
convertTo?: 'pdf'
|
||||
}
|
||||
|
||||
export async function fetchPlanPdf({
|
||||
plan_estudio_id,
|
||||
convertTo,
|
||||
}: GeneratePdfParams): Promise<Blob> {
|
||||
const response = await fetch(DOCUMENT_PDF_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
return await invokeEdge<Blob>(
|
||||
EDGE.carbone_io_wrapper,
|
||||
{
|
||||
action: 'downloadReport',
|
||||
plan_estudio_id,
|
||||
body: convertTo ? { convertTo } : {},
|
||||
},
|
||||
body: JSON.stringify({ plan_estudio_id }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Error al generar el PDF')
|
||||
}
|
||||
|
||||
// n8n devuelve el archivo → lo tratamos como blob
|
||||
return await response.blob()
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
responseType: 'blob',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchAsignaturaPdf({
|
||||
asignatura_id,
|
||||
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',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,35 @@ const cleanText = (text: string) => {
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
const recalculoVectoresAsignaturasInFlight = new Set<string>()
|
||||
|
||||
function triggerRecalculoVectoresAsignaturasNonBlocking(
|
||||
supabase: ReturnType<typeof supabaseBrowser>,
|
||||
planId: UUID,
|
||||
) {
|
||||
const key = String(planId)
|
||||
if (recalculoVectoresAsignaturasInFlight.has(key)) return
|
||||
|
||||
recalculoVectoresAsignaturasInFlight.add(key)
|
||||
|
||||
void (async () => {
|
||||
const { error } = await supabase.rpc('recalcular_vectores_asignaturas')
|
||||
if (error) {
|
||||
// No debe bloquear ni romper el flujo principal.
|
||||
console.warn(
|
||||
'[recalcular_vectores_asignaturas] RPC error:',
|
||||
error.message,
|
||||
)
|
||||
}
|
||||
})()
|
||||
.catch((err: unknown) => {
|
||||
console.warn('[recalcular_vectores_asignaturas] RPC failed:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
recalculoVectoresAsignaturasInFlight.delete(key)
|
||||
})
|
||||
}
|
||||
|
||||
export async function plans_list(
|
||||
filters: PlanListFilters = {},
|
||||
): Promise<Paged<PlanEstudio>> {
|
||||
@@ -207,7 +236,7 @@ export async function plan_asignaturas_list(
|
||||
const { data, error } = await supabase
|
||||
.from('asignaturas')
|
||||
.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',
|
||||
'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,criterios_de_evaluacion,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,prerrequisito_asignatura_id,search_vector',
|
||||
)
|
||||
.eq('plan_estudio_id', planId)
|
||||
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
||||
@@ -215,6 +244,13 @@ export async function plan_asignaturas_list(
|
||||
.order('nombre', { ascending: true })
|
||||
|
||||
throwIfError(error)
|
||||
|
||||
// No bloqueante: si el primer registro viene sin vector, dispara el recalculo.
|
||||
const first: any = (data as any)?.[0]
|
||||
if (first && first.search_vector === null) {
|
||||
triggerRecalculoVectoresAsignaturasNonBlocking(supabase, planId)
|
||||
}
|
||||
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
|
||||
+134
-13
@@ -19,7 +19,7 @@ import type {
|
||||
AsignaturaSugerida,
|
||||
DataAsignaturaSugerida,
|
||||
} from '@/features/asignaturas/nueva/types'
|
||||
import type { Database, TablesInsert } from '@/types/supabase'
|
||||
import type { Database, Tables, TablesInsert } from '@/types/supabase'
|
||||
|
||||
const EDGE = {
|
||||
generate_subject_suggestions: 'generate-subject-suggestions',
|
||||
@@ -29,6 +29,9 @@ const EDGE = {
|
||||
subjects_clone_from_existing: 'subjects_clone_from_existing',
|
||||
subjects_import_from_file: 'subjects_import_from_file',
|
||||
|
||||
// Bibliografía
|
||||
buscar_bibliografia: 'buscar-bibliografia',
|
||||
|
||||
subjects_update_fields: 'subjects_update_fields',
|
||||
subjects_update_bibliografia: 'subjects_update_bibliografia',
|
||||
|
||||
@@ -36,6 +39,82 @@ const EDGE = {
|
||||
subjects_get_document: 'subjects_get_document',
|
||||
} 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 =
|
||||
| string
|
||||
| {
|
||||
@@ -92,7 +171,7 @@ export type PlanEstudioInSubject = Pick<
|
||||
|
||||
export type EstructuraAsignaturaInSubject = Pick<
|
||||
EstructuraAsignatura,
|
||||
'id' | 'nombre' | 'version' | 'definicion'
|
||||
'id' | 'nombre' | 'definicion'
|
||||
>
|
||||
|
||||
/**
|
||||
@@ -112,12 +191,12 @@ export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
|
||||
.from('asignaturas')
|
||||
.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,
|
||||
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,criterios_de_evaluacion,prerrequisito_asignatura_id,
|
||||
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,
|
||||
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
|
||||
),
|
||||
estructuras_asignatura(id,nombre,version,definicion)
|
||||
estructuras_asignatura(id,nombre,definicion)
|
||||
`,
|
||||
)
|
||||
.eq('id', subjectId)
|
||||
@@ -153,7 +232,7 @@ export async function subjects_bibliografia_list(
|
||||
const { data, error } = await supabase
|
||||
.from('bibliografia_asignatura')
|
||||
.select(
|
||||
'id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en',
|
||||
'id,asignatura_id,tipo,cita,referencia_biblioteca,referencia_en_linea,creado_por,creado_en,actualizado_en',
|
||||
)
|
||||
.eq('asignatura_id', subjectId)
|
||||
.order('tipo', { ascending: true })
|
||||
@@ -463,13 +542,9 @@ export async function lineas_delete(lineaId: string) {
|
||||
return lineaId
|
||||
}
|
||||
|
||||
export async function bibliografia_insert(entry: {
|
||||
asignatura_id: string
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
||||
cita: string
|
||||
tipo_fuente: 'MANUAL' | 'BIBLIOTECA'
|
||||
biblioteca_item_id?: string | null
|
||||
}) {
|
||||
export async function bibliografia_insert(
|
||||
entry: TablesInsert<'bibliografia_asignatura'>,
|
||||
): Promise<Tables<'bibliografia_asignatura'>> {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from('bibliografia_asignatura')
|
||||
@@ -478,7 +553,7 @@ export async function bibliografia_insert(entry: {
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
return data as Tables<'bibliografia_asignatura'>
|
||||
}
|
||||
|
||||
export async function bibliografia_update(
|
||||
@@ -510,3 +585,49 @@ export async function bibliografia_delete(id: string) {
|
||||
if (error) throw error
|
||||
return id
|
||||
}
|
||||
|
||||
export async function checkPrerrequisitoConflicts(
|
||||
asignaturaId: string,
|
||||
nuevoCiclo: number,
|
||||
): Promise<Array<string>> {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
// CORRECCIÓN 1: Agregamos 'id' al select
|
||||
// CORRECCIÓN 2: Quitamos espacios en el .or()
|
||||
const { data, error } = await supabase
|
||||
.from('asignaturas')
|
||||
.select('id, nombre, numero_ciclo, prerrequisito_asignatura_id')
|
||||
.or(`prerrequisito_asignatura_id.eq.${asignaturaId},id.eq.${asignaturaId}`)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// CORRECCIÓN 3: 'data' ahora está disponible porque lo desestructuramos arriba
|
||||
if (!data) return []
|
||||
|
||||
const conflictos: Array<string> = []
|
||||
|
||||
data.forEach((asig) => {
|
||||
// Caso 1: Materias que tienen a esta como prerrequisito (Hijas)
|
||||
// "Si yo me muevo al ciclo 5, y mi hija está en el 4, se rompe la regla"
|
||||
if (asig.prerrequisito_asignatura_id === asignaturaId) {
|
||||
if (asig.numero_ciclo !== null && asig.numero_ciclo <= nuevoCiclo) {
|
||||
conflictos.push(asig.nombre)
|
||||
}
|
||||
}
|
||||
|
||||
// Caso 2: El prerrequisito de la materia que estoy moviendo (Padre)
|
||||
// "Si yo me muevo al ciclo 2, y mi padre está en el 3, se rompe la regla"
|
||||
if (asig.id === asignaturaId && asig.prerrequisito_asignatura_id) {
|
||||
const padre = data.find((p) => p.id === asig.prerrequisito_asignatura_id)
|
||||
if (
|
||||
padre &&
|
||||
padre.numero_ciclo !== null &&
|
||||
padre.numero_ciclo >= nuevoCiclo
|
||||
) {
|
||||
conflictos.push(padre.nombre)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return conflictos
|
||||
}
|
||||
|
||||
+205
-7
@@ -1,9 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import {
|
||||
ai_plan_chat_v2,
|
||||
ai_plan_improve,
|
||||
ai_subject_chat,
|
||||
ai_subject_improve,
|
||||
create_conversation,
|
||||
get_chat_history,
|
||||
@@ -12,10 +12,18 @@ import {
|
||||
update_conversation_status,
|
||||
update_recommendation_applied_status,
|
||||
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'
|
||||
import { supabaseBrowser } from '../supabase/client'
|
||||
|
||||
// eslint-disable-next-line node/prefer-node-protocol
|
||||
import type { UUID } from 'crypto'
|
||||
import type { UUID } from 'node:crypto'
|
||||
|
||||
export function useAIPlanImprove() {
|
||||
return useMutation({ mutationFn: ai_plan_improve })
|
||||
@@ -88,6 +96,61 @@ 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() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
@@ -117,10 +180,6 @@ export function useAISubjectImprove() {
|
||||
return useMutation({ mutationFn: ai_subject_improve })
|
||||
}
|
||||
|
||||
export function useAISubjectChat() {
|
||||
return useMutation({ mutationFn: ai_subject_chat })
|
||||
}
|
||||
|
||||
export function useLibrarySearch() {
|
||||
return useMutation({ mutationFn: library_search })
|
||||
}
|
||||
@@ -137,3 +196,142 @@ 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,4 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import {
|
||||
ai_generate_subject,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
bibliografia_delete,
|
||||
bibliografia_insert,
|
||||
bibliografia_update,
|
||||
checkPrerrequisitoConflicts,
|
||||
lineas_insert,
|
||||
lineas_update,
|
||||
subjects_bibliografia_list,
|
||||
@@ -317,3 +319,34 @@ export function useDeleteBibliografia(asignaturaId: string) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAsignaturaConflictos() {
|
||||
const [isValidating, setIsValidating] = useState(false)
|
||||
|
||||
const validarCambioCiclo = async (
|
||||
asignaturaId: string,
|
||||
nuevoCiclo: number,
|
||||
) => {
|
||||
setIsValidating(true)
|
||||
try {
|
||||
const nombresConflictivos = await checkPrerrequisitoConflicts(
|
||||
asignaturaId,
|
||||
nuevoCiclo,
|
||||
)
|
||||
|
||||
if (nombresConflictivos.length > 0) {
|
||||
const mensaje = `Si mueves esta materia al ciclo ${nuevoCiclo}, se perderá la seriación con:\n\n• ${nombresConflictivos.join('\n• ')}\n\n¿Deseas continuar?`
|
||||
return confirm(mensaje) // Puedes usar un Modal de Shadcn aquí en lugar de confirm
|
||||
}
|
||||
|
||||
return true // Sin conflictos
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return false
|
||||
} finally {
|
||||
setIsValidating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { validarCambioCiclo, isValidating }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
export type EdgeInvokeOptions = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
headers?: Record<string, string>
|
||||
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer'
|
||||
}
|
||||
|
||||
export class EdgeFunctionError extends Error {
|
||||
@@ -26,6 +27,55 @@ 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>(
|
||||
functionName: string,
|
||||
body?:
|
||||
@@ -42,10 +92,16 @@ export async function invokeEdge<TOut>(
|
||||
): Promise<TOut> {
|
||||
const supabase = client ?? supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase.functions.invoke(functionName, {
|
||||
// Nota: algunas versiones/defs de @supabase/supabase-js no tipan `responseType`
|
||||
// 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,
|
||||
method: opts.method ?? 'POST',
|
||||
headers: opts.headers,
|
||||
responseType: opts.responseType,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
@@ -104,5 +160,20 @@ export async function invokeEdge<TOut>(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import * as Icons from 'lucide-react'
|
||||
|
||||
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
|
||||
|
||||
import { PasoBasicosClonadoInterno } from '@/components/asignaturas/wizard/PasoBasicosClonadoInterno.tsx'
|
||||
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
|
||||
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
|
||||
import { PasoFuenteClonadoInterno } from '@/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx'
|
||||
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
|
||||
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
|
||||
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
|
||||
@@ -63,7 +65,12 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
||||
basicos: 'Sugerencias',
|
||||
detalles: 'Estructura',
|
||||
}
|
||||
: undefined
|
||||
: wizard.tipoOrigen === 'CLONADO_INTERNO'
|
||||
? {
|
||||
basicos: 'Fuente',
|
||||
detalles: 'Datos básicos',
|
||||
}
|
||||
: undefined
|
||||
|
||||
const handleClose = () => {
|
||||
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
|
||||
@@ -99,6 +106,21 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
||||
>
|
||||
{({ methods }) => {
|
||||
const idx = Wizard.utils.getIndex(methods.current.id)
|
||||
const stepId = methods.current.id
|
||||
|
||||
const disableNext =
|
||||
wizard.isLoading ||
|
||||
(stepId === 'metodo'
|
||||
? !canContinueDesdeMetodo
|
||||
: stepId === 'basicos'
|
||||
? wizard.tipoOrigen === 'CLONADO_INTERNO'
|
||||
? !canContinueDesdeDetalles
|
||||
: !canContinueDesdeBasicos
|
||||
: stepId === 'detalles'
|
||||
? wizard.tipoOrigen === 'CLONADO_INTERNO'
|
||||
? !canContinueDesdeBasicos
|
||||
: !canContinueDesdeDetalles
|
||||
: false)
|
||||
|
||||
return (
|
||||
<WizardLayout
|
||||
@@ -118,12 +140,7 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
||||
onPrev={() => methods.prev()}
|
||||
onNext={() => methods.next()}
|
||||
disablePrev={idx === 0 || wizard.isLoading}
|
||||
disableNext={
|
||||
wizard.isLoading ||
|
||||
(idx === 0 && !canContinueDesdeMetodo) ||
|
||||
(idx === 1 && !canContinueDesdeBasicos) ||
|
||||
(idx === 2 && !canContinueDesdeDetalles)
|
||||
}
|
||||
disableNext={disableNext}
|
||||
disableCreate={wizard.isLoading}
|
||||
isLastStep={idx >= Wizard.steps.length - 1}
|
||||
wizard={wizard}
|
||||
@@ -141,13 +158,27 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
||||
|
||||
{idx === 1 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
|
||||
{wizard.tipoOrigen === 'CLONADO_INTERNO' ? (
|
||||
<PasoFuenteClonadoInterno
|
||||
wizard={wizard}
|
||||
onChange={setWizard}
|
||||
/>
|
||||
) : (
|
||||
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
|
||||
)}
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
|
||||
{idx === 2 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoDetallesPanel wizard={wizard} onChange={setWizard} />
|
||||
{wizard.tipoOrigen === 'CLONADO_INTERNO' ? (
|
||||
<PasoBasicosClonadoInterno
|
||||
wizard={wizard}
|
||||
onChange={setWizard}
|
||||
/>
|
||||
) : (
|
||||
<PasoDetallesPanel wizard={wizard} onChange={setWizard} />
|
||||
)}
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
|
||||
|
||||
@@ -50,10 +50,12 @@ export type NewSubjectWizardState = {
|
||||
}
|
||||
sugerencias: Array<AsignaturaSugerida>
|
||||
clonInterno?: {
|
||||
facultadId?: string
|
||||
carreraId?: string
|
||||
planOrigenId?: string
|
||||
facultadId?: string | null
|
||||
carreraId?: string | null
|
||||
planOrigenId?: string | null
|
||||
asignaturaOrigenId?: string | null
|
||||
search?: string
|
||||
page?: number
|
||||
}
|
||||
clonTradicional?: {
|
||||
archivoWordAsignaturaId: string | null
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,78 @@
|
||||
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,
|
||||
}
|
||||
+62
-6
@@ -31,6 +31,8 @@ import { Route as PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport } from
|
||||
import { Route as PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||
import { Route as PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||
import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva'
|
||||
import { Route as 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({
|
||||
id: '/login',
|
||||
@@ -156,6 +158,18 @@ const PlanesPlanIdDetalleAsignaturasNuevaRoute =
|
||||
path: '/nueva',
|
||||
getParentRoute: () => PlanesPlanIdDetalleAsignaturasRoute,
|
||||
} 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 {
|
||||
'/': typeof IndexRoute
|
||||
@@ -174,12 +188,14 @@ export interface FileRoutesByFullPath {
|
||||
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
|
||||
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
|
||||
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren
|
||||
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
@@ -196,12 +212,13 @@ export interface FileRoutesByTo {
|
||||
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
|
||||
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
|
||||
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -221,12 +238,14 @@ export interface FileRoutesById {
|
||||
'/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute
|
||||
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
|
||||
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren
|
||||
'/planes/$planId/asignaturas/$asignaturaId/contenido': typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/documento': typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/historial': typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/': typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/': typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -253,6 +272,8 @@ export interface FileRouteTypes {
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
@@ -269,12 +290,13 @@ export interface FileRouteTypes {
|
||||
| '/planes/$planId/mapa'
|
||||
| '/planes/$planId'
|
||||
| '/planes/$planId/asignaturas/nueva'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
@@ -299,6 +321,8 @@ export interface FileRouteTypes {
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -467,6 +491,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRouteImport
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,8 +559,26 @@ const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = {
|
||||
const PlanesPlanIdDetalleRouteWithChildren =
|
||||
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 {
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren
|
||||
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
|
||||
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
|
||||
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
|
||||
@@ -533,7 +589,7 @@ interface PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren {
|
||||
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren =
|
||||
{
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute:
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute,
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren,
|
||||
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute:
|
||||
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute,
|
||||
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute:
|
||||
|
||||
@@ -73,7 +73,7 @@ const mapAsignaturas = (
|
||||
estado: asig.estado,
|
||||
hd: asig.horas_academicas ?? 0,
|
||||
hi: asig.horas_independientes ?? 0,
|
||||
prerrequisitos: [],
|
||||
prerrequisito_asignatura_id: asig.prerrequisito_asignatura_id ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -218,26 +218,30 @@ function AsignaturasPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50/50">
|
||||
<TableHead className="w-30">Clave</TableHead>
|
||||
<TableHead>Nombre</TableHead>
|
||||
<TableHead className="text-center">Créditos</TableHead>
|
||||
<TableHead className="text-center">Ciclo</TableHead>
|
||||
<TableHead>Línea Curricular</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>Estado</TableHead>
|
||||
<TableHead className="w-12.5"></TableHead>
|
||||
<TableHead className="w-30 px-6 py-4">Clave</TableHead>
|
||||
<TableHead className="px-6 py-4">Nombre</TableHead>
|
||||
<TableHead className="px-6 py-4 text-center">Créditos</TableHead>
|
||||
<TableHead className="px-6 py-4 text-center">Ciclo</TableHead>
|
||||
<TableHead className="px-6 py-4">Línea Curricular</TableHead>
|
||||
<TableHead className="px-6 py-4">Tipo</TableHead>
|
||||
<TableHead className="px-6 py-4">Estado</TableHead>
|
||||
<TableHead className="w-12.5 px-6 py-4"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAsignaturas.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-40 text-center">
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center">
|
||||
<BookOpen className="mb-2 h-10 w-10 opacity-20" />
|
||||
<p className="font-medium">No se encontraron asignaturas</p>
|
||||
<p className="text-xs">
|
||||
Intenta cambiar los filtros de búsqueda
|
||||
</p>
|
||||
<TableCell colSpan={8} className="h-40 px-6 py-8 text-center">
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center gap-3">
|
||||
<BookOpen className="h-10 w-10 opacity-20" />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
No se encontraron asignaturas
|
||||
</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Intenta cambiar los filtros de búsqueda
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -251,25 +255,25 @@ function AsignaturasPage() {
|
||||
to: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
params: {
|
||||
planId,
|
||||
asignaturaId: asignatura.id, // 👈 puede ser índice, consecutivo o slug
|
||||
asignaturaId: asignatura.id,
|
||||
},
|
||||
state: {
|
||||
realId: asignatura.id, // 👈 ID largo oculto
|
||||
realId: asignatura.id,
|
||||
asignaturaId: asignatura.id,
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
>
|
||||
<TableCell className="font-mono text-xs font-bold text-slate-400">
|
||||
<TableCell className="px-6 py-4 font-mono text-xs font-bold text-slate-400">
|
||||
{asignatura.clave}
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold text-slate-700">
|
||||
<TableCell className="px-6 py-4 font-semibold text-slate-700">
|
||||
{asignatura.nombre}
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-medium">
|
||||
<TableCell className="px-6 py-4 text-center font-medium">
|
||||
{asignatura.creditos}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="px-6 py-4 text-center">
|
||||
{asignatura.ciclo ? (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
Ciclo {asignatura.ciclo}
|
||||
@@ -278,10 +282,10 @@ function AsignaturasPage() {
|
||||
<span className="text-slate-300">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-slate-600">
|
||||
<TableCell className="px-6 py-4 text-sm text-slate-600">
|
||||
{getLineaNombre(asignatura.lineaCurricularId)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="px-6 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`capitalize shadow-sm ${tipoConfig[asignatura.tipo].className}`}
|
||||
@@ -289,7 +293,7 @@ function AsignaturasPage() {
|
||||
{tipoConfig[asignatura.tipo].label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="px-6 py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`capitalize shadow-sm ${statusConfig[asignatura.estado].className}`}
|
||||
@@ -297,7 +301,7 @@ function AsignaturasPage() {
|
||||
{statusConfig[asignatura.estado].label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="px-6 py-4">
|
||||
<div className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<ChevronRight className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
Clock,
|
||||
FileJson,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { usePlan } from '@/data'
|
||||
import { fetchPlanPdf } from '@/data/api/document.api'
|
||||
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
|
||||
@@ -20,30 +21,41 @@ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
|
||||
|
||||
function RouteComponent() {
|
||||
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
|
||||
const { data: plan } = usePlan(planId)
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
||||
const pdfUrlRef = useRef<string | null>(null)
|
||||
const isMountedRef = useRef<boolean>(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const planFileBaseName = sanitizeFileBaseName(plan?.nombre ?? 'plan_estudios')
|
||||
|
||||
const loadPdfPreview = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const pdfBlob = await fetchPlanPdf({ plan_estudio_id: planId })
|
||||
if (isMountedRef.current) setIsLoading(true)
|
||||
const pdfBlob = await fetchPlanPdf({
|
||||
plan_estudio_id: planId,
|
||||
convertTo: 'pdf',
|
||||
})
|
||||
|
||||
if (!isMountedRef.current) return
|
||||
const url = window.URL.createObjectURL(pdfBlob)
|
||||
|
||||
// Limpiar URL anterior si existe para evitar fugas de memoria
|
||||
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
||||
|
||||
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
||||
pdfUrlRef.current = url
|
||||
setPdfUrl(url)
|
||||
} catch (error) {
|
||||
console.error('Error cargando preview:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (isMountedRef.current) setIsLoading(false)
|
||||
}
|
||||
}, [planId])
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
loadPdfPreview()
|
||||
return () => {
|
||||
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
||||
isMountedRef.current = false
|
||||
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
||||
}
|
||||
}, [loadPdfPreview])
|
||||
|
||||
@@ -51,12 +63,13 @@ function RouteComponent() {
|
||||
try {
|
||||
const pdfBlob = await fetchPlanPdf({
|
||||
plan_estudio_id: planId,
|
||||
convertTo: 'pdf',
|
||||
})
|
||||
|
||||
const url = window.URL.createObjectURL(pdfBlob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'plan_estudios.pdf'
|
||||
link.download = `${planFileBaseName}.pdf`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
|
||||
@@ -67,6 +80,27 @@ function RouteComponent() {
|
||||
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 (
|
||||
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
|
||||
{/* HEADER DE ACCIONES */}
|
||||
@@ -88,12 +122,17 @@ function RouteComponent() {
|
||||
>
|
||||
<RefreshCcw size={16} /> Regenerar
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Download size={16} /> Descargar Word
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-2 bg-teal-700 hover:bg-teal-800"
|
||||
onClick={handleDownloadWord}
|
||||
>
|
||||
<Download size={16} /> Descargar Word
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={handleDownloadPdf}
|
||||
>
|
||||
<Download size={16} /> Descargar PDF
|
||||
@@ -139,7 +178,7 @@ function RouteComponent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardContent className="flex min-h-[800px] justify-center bg-slate-500 p-0">
|
||||
<CardContent className="flex min-h-200 justify-center bg-slate-500 p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-white">
|
||||
<RefreshCcw size={40} className="animate-spin opacity-50" />
|
||||
@@ -149,7 +188,7 @@ function RouteComponent() {
|
||||
/* 3. VISOR DE PDF REAL */
|
||||
<iframe
|
||||
src={`${pdfUrl}#toolbar=0&navpanes=0`}
|
||||
className="h-[1000px] w-full max-w-[1000px] border-none shadow-2xl"
|
||||
className="h-250 w-full max-w-250 border-none shadow-2xl"
|
||||
title="PDF Preview"
|
||||
/>
|
||||
) : (
|
||||
@@ -163,6 +202,24 @@ 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
|
||||
function StatusCard({
|
||||
icon,
|
||||
|
||||
@@ -139,13 +139,16 @@ function RouteComponent() {
|
||||
</div>
|
||||
|
||||
<div className="relative space-y-0">
|
||||
<div className="absolute top-0 bottom-0 left-9 w-px bg-slate-200" />
|
||||
<div className="absolute top-0 bottom-0 left-6 w-px bg-slate-200 md:left-9" />
|
||||
{historyEvents.length === 0 ? (
|
||||
<div className="ml-20 py-10 text-slate-500">No hay registros.</div>
|
||||
) : (
|
||||
historyEvents.map((event) => (
|
||||
<div key={event.id} className="group relative flex gap-6 pb-8">
|
||||
<div className="relative z-10 flex h-18 flex-col items-center">
|
||||
<div
|
||||
key={event.id}
|
||||
className="group relative flex gap-3 pb-8 md:gap-6"
|
||||
>
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600">
|
||||
{event.icon}
|
||||
</div>
|
||||
@@ -153,10 +156,10 @@ function RouteComponent() {
|
||||
|
||||
<Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col justify-between gap-2 md:flex-row md:items-center">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-bold text-slate-800">
|
||||
{event.type}
|
||||
</span>
|
||||
@@ -172,9 +175,9 @@ function RouteComponent() {
|
||||
</div>
|
||||
|
||||
{/* Grupo de elementos alineados a la derecha */}
|
||||
<div className="flex items-center gap-4 text-slate-500">
|
||||
<div className="flex flex-wrap items-center gap-3 text-slate-500 md:gap-4">
|
||||
{/* Usuario e Icono */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
<span className="text-muted-foreground">
|
||||
{event.user}
|
||||
@@ -184,14 +187,14 @@ function RouteComponent() {
|
||||
{/* Botón Ver Cambios */}
|
||||
<button
|
||||
onClick={() => openCompareModal(event)}
|
||||
className="group/btn flex items-center gap-1.5 text-xs transition-colors hover:text-teal-600"
|
||||
className="group/btn flex items-center gap-1.5 text-xs font-medium text-teal-600 md:text-slate-500 md:hover:text-teal-600"
|
||||
>
|
||||
<Eye className="h-4 w-4 text-slate-400 group-hover/btn:text-teal-600" />
|
||||
<span>Ver cambios</span>
|
||||
</button>
|
||||
|
||||
{/* Fecha exacta (Solo visible en desktop para no amontonar) */}
|
||||
<span className="hidden text-[11px] text-slate-400 md:block">
|
||||
<span className="hidden text-[11px] text-slate-400 lg:block">
|
||||
{format(event.date, 'yyyy-MM-dd HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -233,7 +236,7 @@ function RouteComponent() {
|
||||
))
|
||||
)}
|
||||
{historyEvents.length > 0 && (
|
||||
<div className="mt-10 ml-20 flex items-center justify-between border-t pt-4">
|
||||
<div className="mt-10 ml-12 flex flex-col gap-3 border-t pt-4 md:ml-20 md:flex-row md:items-center md:justify-between">
|
||||
<p className="text-xs text-slate-500">
|
||||
Mostrando {rawData.length} de {totalRecords} cambios
|
||||
</p>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { BibliographyItem } from '@/components/asignaturas/detalle/BibliographyItem'
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia',
|
||||
@@ -9,5 +7,5 @@ export const Route = createFileRoute(
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <BibliographyItem />
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { BibliographyItem } from '@/components/asignaturas/detalle/BibliographyItem'
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia/',
|
||||
)({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <BibliographyItem />
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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,8 +1,9 @@
|
||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
|
||||
import { fetchPlanPdf } from '@/data/api/document.api'
|
||||
import { useSubject } from '@/data'
|
||||
import { fetchAsignaturaPdf } from '@/data/api/document.api'
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/planes/$planId/asignaturas/$asignaturaId/documento',
|
||||
@@ -11,52 +12,79 @@ export const Route = createFileRoute(
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const { planId } = useParams({
|
||||
const { asignaturaId } = useParams({
|
||||
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 pdfUrlRef = useRef<string | null>(null)
|
||||
const isMountedRef = useRef<boolean>(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isRegenerating, setIsRegenerating] = useState(false)
|
||||
|
||||
const loadPdfPreview = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
if (isMountedRef.current) setIsLoading(true)
|
||||
|
||||
const pdfBlob = await fetchPlanPdf({
|
||||
plan_estudio_id: planId,
|
||||
const pdfBlob = await fetchAsignaturaPdf({
|
||||
asignatura_id: asignaturaId,
|
||||
convertTo: 'pdf',
|
||||
})
|
||||
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
const url = window.URL.createObjectURL(pdfBlob)
|
||||
|
||||
setPdfUrl((prev) => {
|
||||
if (prev) window.URL.revokeObjectURL(prev)
|
||||
return url
|
||||
})
|
||||
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
||||
pdfUrlRef.current = url
|
||||
setPdfUrl(url)
|
||||
} catch (error) {
|
||||
console.error('Error cargando PDF:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (isMountedRef.current) setIsLoading(false)
|
||||
}
|
||||
}, [planId])
|
||||
}, [asignaturaId])
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
loadPdfPreview()
|
||||
|
||||
return () => {
|
||||
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
|
||||
isMountedRef.current = false
|
||||
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
|
||||
}
|
||||
}, [loadPdfPreview])
|
||||
|
||||
const handleDownload = async () => {
|
||||
const pdfBlob = await fetchPlanPdf({
|
||||
plan_estudio_id: planId,
|
||||
const handleDownloadPdf = async () => {
|
||||
const pdfBlob = await fetchAsignaturaPdf({
|
||||
asignatura_id: asignaturaId,
|
||||
convertTo: 'pdf',
|
||||
})
|
||||
|
||||
const url = window.URL.createObjectURL(pdfBlob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'documento_sep.pdf'
|
||||
link.download = `${asignaturaFileBaseName}.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)
|
||||
link.click()
|
||||
link.remove()
|
||||
@@ -77,9 +105,28 @@ function RouteComponent() {
|
||||
<DocumentoSEPTab
|
||||
pdfUrl={pdfUrl}
|
||||
isLoading={isLoading}
|
||||
onDownload={handleDownload}
|
||||
onDownloadPdf={handleDownloadPdf}
|
||||
onDownloadWord={handleDownloadWord}
|
||||
onRegenerate={handleRegenerate}
|
||||
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,30 +166,20 @@ function AsignaturaLayout() {
|
||||
onSave={(val) => handleUpdateHeader('nombre', val)}
|
||||
/>
|
||||
</h1>
|
||||
{
|
||||
// console.log(headerData),
|
||||
|
||||
console.log(asignaturaApi.planes_estudio?.nombre)
|
||||
}
|
||||
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
|
||||
<span className="flex items-center gap-1">
|
||||
<GraduationCap className="h-4 w-4 shrink-0" />
|
||||
Pertenece al plan:{' '}
|
||||
<span className="text-blue-100">
|
||||
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
|
||||
.nombre || ''}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-blue-100">
|
||||
{(asignaturaApi.planes_estudio?.datos as DatosPlan)
|
||||
.nombre ?? ''}
|
||||
{(asignaturaApi.planes_estudio as DatosPlan).nombre || ''}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-blue-300">
|
||||
Pertenece al plan:{' '}
|
||||
<span className="cursor-pointer underline">
|
||||
{asignaturaApi.planes_estudio?.nombre}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2 text-right">
|
||||
@@ -225,14 +215,19 @@ function AsignaturaLayout() {
|
||||
|
||||
{/* TABS */}
|
||||
|
||||
<nav className="border-b bg-white">
|
||||
<nav className="sticky top-0 z-20 border-b bg-white">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<div className="flex justify-center gap-8">
|
||||
{/* CAMBIOS CLAVE:
|
||||
1. overflow-x-auto: Permite scroll horizontal.
|
||||
2. scrollbar-hide: (Opcional) para que no se vea la barra fea.
|
||||
3. justify-start md:justify-center: Alineado a la izquierda en móvil para que el scroll funcione, centrado en desktop.
|
||||
*/}
|
||||
<div className="no-scrollbar flex items-center justify-start gap-8 overflow-x-auto whitespace-nowrap md:justify-center">
|
||||
{[
|
||||
{ label: 'Datos', to: '' },
|
||||
{ label: 'Contenido', to: 'contenido' },
|
||||
{ label: 'Bibliografía', to: 'bibliografia' },
|
||||
{ label: 'IA', to: 'asignaturaIa' },
|
||||
{ label: 'IA', to: 'iaasignatura' },
|
||||
{ label: 'Documento SEP', to: 'documento' },
|
||||
{ label: 'Historial', to: 'historial' },
|
||||
].map((tab) => {
|
||||
@@ -251,7 +246,7 @@ function AsignaturaLayout() {
|
||||
}
|
||||
from="/planes/$planId/asignaturas/$asignaturaId"
|
||||
params={{ planId, asignaturaId }}
|
||||
className={`border-b-2 py-3 text-sm font-medium ${
|
||||
className={`shrink-0 border-b-2 py-4 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-slate-500 hover:border-slate-300 hover:text-slate-700'
|
||||
|
||||
+139
-12
@@ -4,18 +4,145 @@
|
||||
|
||||
@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 {
|
||||
@apply m-0;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
|
||||
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:
|
||||
source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
strong,
|
||||
b,
|
||||
.font-bold {
|
||||
font-family: 'Indivisa Sans', serif;
|
||||
font-weight: 900;
|
||||
/* Inter letter space */
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -51,9 +178,9 @@ code {
|
||||
--sidebar-accent-foreground: oklch(0.6304 0.2472 28.2698);
|
||||
--sidebar-border: oklch(0.9401 0 0);
|
||||
--sidebar-ring: oklch(0 0 0);
|
||||
--font-sans: Plus Jakarta Sans, sans-serif;
|
||||
--font-serif: Lora, serif;
|
||||
--font-mono: IBM Plex Mono, monospace;
|
||||
--font-sans: 'Indivisa Sans', sans-serif;
|
||||
--font-serif: 'Indivisa Serif', serif;
|
||||
--font-mono: 'Indivisa Sans', monospace;
|
||||
--radius: 1.4rem;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 2px;
|
||||
@@ -101,7 +228,7 @@ code {
|
||||
--chart-1: oklch(0.6686 0.1794 251.7436);
|
||||
--chart-2: oklch(0.6342 0.2516 22.4415);
|
||||
--chart-3: oklch(0.8718 0.1716 90.9505);
|
||||
--chart-4: oklch(0.4503 0.229 263.0881);
|
||||
--chart-4: oklch(11.492% 0.00001 271.152);
|
||||
--chart-5: oklch(0.8322 0.146 185.9404);
|
||||
--sidebar: oklch(0.1564 0.0688 261.2771);
|
||||
--sidebar-foreground: oklch(0.9551 0 0);
|
||||
@@ -111,9 +238,9 @@ code {
|
||||
--sidebar-accent-foreground: oklch(0.6786 0.2095 24.6583);
|
||||
--sidebar-border: oklch(0.3289 0.0092 268.3843);
|
||||
--sidebar-ring: oklch(0.6048 0.2166 257.2136);
|
||||
--font-sans: Plus Jakarta Sans, sans-serif;
|
||||
--font-serif: Lora, serif;
|
||||
--font-mono: IBM Plex Mono, monospace;
|
||||
--font-sans: 'Indivisa Sans', sans-serif;
|
||||
--font-serif: 'Indivisa Serif', serif;
|
||||
--font-mono: 'Indivisa Sans', monospace;
|
||||
--radius: 1.4rem;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 2px;
|
||||
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
declare module 'citeproc' {
|
||||
const CSL: {
|
||||
Engine: new (
|
||||
sys: any,
|
||||
style: string,
|
||||
lang?: string,
|
||||
forceLang?: boolean,
|
||||
) => any
|
||||
}
|
||||
|
||||
export default CSL
|
||||
}
|
||||
+1
-1
@@ -50,7 +50,7 @@ export interface Asignatura {
|
||||
orden?: number
|
||||
hd: number // <--- Añadir
|
||||
hi: number // <--- Añadir
|
||||
prerrequisitos: Array<string>
|
||||
prerrequisito_asignatura_id: string | null
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
|
||||
+222
-22
@@ -81,6 +81,56 @@ 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: {
|
||||
Row: {
|
||||
actualizado_en: string
|
||||
@@ -91,6 +141,7 @@ export type Database = {
|
||||
creado_en: string
|
||||
creado_por: string | null
|
||||
creditos: number
|
||||
criterios_de_evaluacion: Json
|
||||
datos: Json
|
||||
estado: Database['public']['Enums']['estado_asignatura']
|
||||
estructura_id: string | null
|
||||
@@ -103,6 +154,8 @@ export type Database = {
|
||||
numero_ciclo: number | null
|
||||
orden_celda: number | null
|
||||
plan_estudio_id: string
|
||||
prerrequisito_asignatura_id: string | null
|
||||
search_vector: unknown
|
||||
tipo: Database['public']['Enums']['tipo_asignatura']
|
||||
tipo_origen: Database['public']['Enums']['tipo_origen'] | null
|
||||
}
|
||||
@@ -115,6 +168,7 @@ export type Database = {
|
||||
creado_en?: string
|
||||
creado_por?: string | null
|
||||
creditos: number
|
||||
criterios_de_evaluacion?: Json
|
||||
datos?: Json
|
||||
estado?: Database['public']['Enums']['estado_asignatura']
|
||||
estructura_id?: string | null
|
||||
@@ -127,6 +181,8 @@ export type Database = {
|
||||
numero_ciclo?: number | null
|
||||
orden_celda?: number | null
|
||||
plan_estudio_id: string
|
||||
prerrequisito_asignatura_id?: string | null
|
||||
search_vector?: unknown
|
||||
tipo?: Database['public']['Enums']['tipo_asignatura']
|
||||
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
||||
}
|
||||
@@ -139,6 +195,7 @@ export type Database = {
|
||||
creado_en?: string
|
||||
creado_por?: string | null
|
||||
creditos?: number
|
||||
criterios_de_evaluacion?: Json
|
||||
datos?: Json
|
||||
estado?: Database['public']['Enums']['estado_asignatura']
|
||||
estructura_id?: string | null
|
||||
@@ -151,6 +208,8 @@ export type Database = {
|
||||
numero_ciclo?: number | null
|
||||
orden_celda?: number | null
|
||||
plan_estudio_id?: string
|
||||
prerrequisito_asignatura_id?: string | null
|
||||
search_vector?: unknown
|
||||
tipo?: Database['public']['Enums']['tipo_asignatura']
|
||||
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
|
||||
}
|
||||
@@ -176,6 +235,13 @@ export type Database = {
|
||||
referencedRelation: 'estructuras_asignatura'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'asignaturas_estructura_id_fkey'
|
||||
columns: ['estructura_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'plantilla_asignatura'
|
||||
referencedColumns: ['estructura_id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'asignaturas_linea_plan_fk_compuesta'
|
||||
columns: ['linea_plan_id', 'plan_estudio_id']
|
||||
@@ -197,41 +263,55 @@ export type Database = {
|
||||
referencedRelation: 'plantilla_plan'
|
||||
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: {
|
||||
Row: {
|
||||
actualizado_en: string
|
||||
asignatura_id: string
|
||||
biblioteca_item_id: string | null
|
||||
cita: string
|
||||
creado_en: string
|
||||
creado_por: string | null
|
||||
id: string
|
||||
referencia_biblioteca: string | null
|
||||
referencia_en_linea: string | null
|
||||
tipo: Database['public']['Enums']['tipo_bibliografia']
|
||||
tipo_fuente: Database['public']['Enums']['tipo_fuente_bibliografia']
|
||||
}
|
||||
Insert: {
|
||||
actualizado_en?: string
|
||||
asignatura_id: string
|
||||
biblioteca_item_id?: string | null
|
||||
cita: string
|
||||
creado_en?: string
|
||||
creado_por?: string | null
|
||||
id?: string
|
||||
referencia_biblioteca?: string | null
|
||||
referencia_en_linea?: string | null
|
||||
tipo: Database['public']['Enums']['tipo_bibliografia']
|
||||
tipo_fuente?: Database['public']['Enums']['tipo_fuente_bibliografia']
|
||||
}
|
||||
Update: {
|
||||
actualizado_en?: string
|
||||
asignatura_id?: string
|
||||
biblioteca_item_id?: string | null
|
||||
cita?: string
|
||||
creado_en?: string
|
||||
creado_por?: string | null
|
||||
id?: string
|
||||
referencia_biblioteca?: string | null
|
||||
referencia_en_linea?: string | null
|
||||
tipo?: Database['public']['Enums']['tipo_bibliografia']
|
||||
tipo_fuente?: Database['public']['Enums']['tipo_fuente_bibliografia']
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
@@ -241,6 +321,13 @@ export type Database = {
|
||||
referencedRelation: 'asignaturas'
|
||||
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'
|
||||
columns: ['creado_por']
|
||||
@@ -295,6 +382,13 @@ export type Database = {
|
||||
referencedRelation: 'asignaturas'
|
||||
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'
|
||||
columns: ['cambiado_por']
|
||||
@@ -400,6 +494,7 @@ export type Database = {
|
||||
estado: Database['public']['Enums']['estado_conversacion']
|
||||
id: string
|
||||
intento_archivado: number
|
||||
nombre: string | null
|
||||
openai_conversation_id: string
|
||||
}
|
||||
Insert: {
|
||||
@@ -412,6 +507,7 @@ export type Database = {
|
||||
estado?: Database['public']['Enums']['estado_conversacion']
|
||||
id?: string
|
||||
intento_archivado?: number
|
||||
nombre?: string | null
|
||||
openai_conversation_id: string
|
||||
}
|
||||
Update: {
|
||||
@@ -424,6 +520,7 @@ export type Database = {
|
||||
estado?: Database['public']['Enums']['estado_conversacion']
|
||||
id?: string
|
||||
intento_archivado?: number
|
||||
nombre?: string | null
|
||||
openai_conversation_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
@@ -441,6 +538,13 @@ export type Database = {
|
||||
referencedRelation: 'asignaturas'
|
||||
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'
|
||||
columns: ['creado_por']
|
||||
@@ -552,7 +656,8 @@ export type Database = {
|
||||
definicion: Json
|
||||
id: string
|
||||
nombre: string
|
||||
version: string | null
|
||||
template_id: string | null
|
||||
tipo: Database['public']['Enums']['tipo_estructura_plan'] | null
|
||||
}
|
||||
Insert: {
|
||||
actualizado_en?: string
|
||||
@@ -560,7 +665,8 @@ export type Database = {
|
||||
definicion?: Json
|
||||
id?: string
|
||||
nombre: string
|
||||
version?: string | null
|
||||
template_id?: string | null
|
||||
tipo?: Database['public']['Enums']['tipo_estructura_plan'] | null
|
||||
}
|
||||
Update: {
|
||||
actualizado_en?: string
|
||||
@@ -568,7 +674,8 @@ export type Database = {
|
||||
definicion?: Json
|
||||
id?: string
|
||||
nombre?: string
|
||||
version?: string | null
|
||||
template_id?: string | null
|
||||
tipo?: Database['public']['Enums']['tipo_estructura_plan'] | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
@@ -692,6 +799,13 @@ export type Database = {
|
||||
referencedRelation: 'asignaturas'
|
||||
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'
|
||||
columns: ['plan_estudio_id']
|
||||
@@ -798,6 +912,56 @@ 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: {
|
||||
Row: {
|
||||
activo: boolean
|
||||
@@ -934,6 +1098,13 @@ export type Database = {
|
||||
referencedRelation: 'asignaturas'
|
||||
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'
|
||||
columns: ['usuario_id']
|
||||
@@ -1199,6 +1370,14 @@ export type Database = {
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
plantilla_asignatura: {
|
||||
Row: {
|
||||
asignatura_id: string | null
|
||||
estructura_id: string | null
|
||||
template_id: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
plantilla_plan: {
|
||||
Row: {
|
||||
estructura_id: string | null
|
||||
@@ -1217,17 +1396,43 @@ export type Database = {
|
||||
Args: { p_append: Json; p_id: string }
|
||||
Returns: undefined
|
||||
}
|
||||
build_asignaturas_prefix_tsquery: {
|
||||
Args: { p_search: string }
|
||||
Returns: unknown
|
||||
}
|
||||
recalcular_vectores_asignaturas: { Args: never; Returns: undefined }
|
||||
search_asignaturas: {
|
||||
Args: {
|
||||
p_carrera_id?: string
|
||||
p_facultad_id?: string
|
||||
p_limit?: number
|
||||
p_offset?: number
|
||||
p_plan_estudio_id?: string
|
||||
p_search?: string
|
||||
}
|
||||
Returns: Array<{
|
||||
codigo: string
|
||||
contenido_tematico: Json
|
||||
creditos: number
|
||||
datos: Json
|
||||
estado: Database['public']['Enums']['estado_asignatura']
|
||||
id: string
|
||||
nombre: string
|
||||
numero_ciclo: number
|
||||
plan_estudio_id: string
|
||||
rank: number
|
||||
tipo: Database['public']['Enums']['tipo_asignatura']
|
||||
total_count: number
|
||||
}>
|
||||
}
|
||||
suma_porcentajes: { Args: { '': Json }; Returns: number }
|
||||
unaccent: { Args: { '': string }; Returns: string }
|
||||
unaccent_immutable: { Args: { '': string }; Returns: string }
|
||||
}
|
||||
Enums: {
|
||||
estado_asignatura:
|
||||
| 'borrador'
|
||||
| 'revisada'
|
||||
| 'aprobada'
|
||||
| 'generando'
|
||||
| 'fallida'
|
||||
estado_asignatura: 'borrador' | 'revisada' | 'aprobada' | 'generando'
|
||||
estado_conversacion: 'ACTIVA' | 'ARCHIVANDO' | 'ARCHIVADA' | 'ERROR'
|
||||
estado_mensaje_ia: 'PROCESANDO' | 'COMPLETADO' | 'ERROR'
|
||||
estado_tarea_revision: 'PENDIENTE' | 'COMPLETADA' | 'OMITIDA'
|
||||
fuente_cambio: 'HUMANO' | 'IA'
|
||||
nivel_plan_estudio:
|
||||
@@ -1400,14 +1605,9 @@ export const Constants = {
|
||||
},
|
||||
public: {
|
||||
Enums: {
|
||||
estado_asignatura: [
|
||||
'borrador',
|
||||
'revisada',
|
||||
'aprobada',
|
||||
'generando',
|
||||
'fallida',
|
||||
],
|
||||
estado_asignatura: ['borrador', 'revisada', 'aprobada', 'generando'],
|
||||
estado_conversacion: ['ACTIVA', 'ARCHIVANDO', 'ARCHIVADA', 'ERROR'],
|
||||
estado_mensaje_ia: ['PROCESANDO', 'COMPLETADO', 'ERROR'],
|
||||
estado_tarea_revision: ['PENDIENTE', 'COMPLETADA', 'OMITIDA'],
|
||||
fuente_cambio: ['HUMANO', 'IA'],
|
||||
nivel_plan_estudio: [
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"navigationFallback": {
|
||||
"rewrite": "/index.html",
|
||||
"exclude": [
|
||||
"/assets/*",
|
||||
"/*.css",
|
||||
"/*.js",
|
||||
"/*.ico",
|
||||
"/*.png",
|
||||
"/*.jpg",
|
||||
"/*.svg"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.d.ts",
|
||||
"eslint.config.js",
|
||||
"prettier.config.js",
|
||||
"vite.config.ts"
|
||||
|
||||
Reference in New Issue
Block a user