Compare commits
236 Commits
268d83fb4b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 314a96f2c5 | |||
| 7693f86951 | |||
| 8ad6c8096e | |||
| 28742615d8 | |||
| 0cb467cb78 | |||
| ff5ba3952d | |||
| f6b25ad86a | |||
| d7d4eff523 | |||
| 6773247b03 | |||
| ef614be2f1 | |||
| 2f0005baa7 | |||
| 3c37b5f313 | |||
| 4d1f102acb | |||
| f706456ff6 | |||
| b888bb22cd | |||
| 543e83609e | |||
| 4a8a9e1857 | |||
| d28b32d34e | |||
| 6db7c1c023 | |||
| cfc2153fa2 | |||
| f28804bb5b | |||
| f1d09a37ed | |||
| 3c63fdef69 | |||
| 3dc01c3fba | |||
| a51fa6b1fc | |||
| 1fddb75bf8 | |||
| ec994c9586 | |||
| 1acc37403d | |||
| f7ab1d61f0 | |||
| 6ed5d3541f | |||
| 3188a61431 | |||
| 5912a7c1fb | |||
| 1c45330da6 | |||
| 88ad7d74e3 | |||
| 08afd27f80 | |||
| b0a89ac57f | |||
| 440147edec | |||
| d415344ee7 | |||
| 5a0f7acac3 | |||
| f882d8c89d | |||
| 517b9497f1 | |||
| eb95dec097 | |||
| cf4caa2857 | |||
| 2de1e4237c | |||
| 7472e2cf97 | |||
| 15f60b7751 | |||
| 50c00293cd | |||
| 99ed75b2eb | |||
| cd16b3cb4f | |||
| 8444f2a87e | |||
| 02c415a91d | |||
| 7d45eb4dfa | |||
| 54b22b7adf | |||
| d4a034c2fc | |||
| 56d23f1aa5 | |||
| 13d9f1fe4a | |||
| 2624b0694d | |||
| 04909513bb | |||
| 5f8d758f67 | |||
| 41aecc4a45 | |||
| 1e4a58e4da | |||
| 2157ffe3bc | |||
| c280faef22 | |||
| d6c567195a | |||
| 9c588cfd8f | |||
| 46d8d6142e | |||
| 07d08e1b57 | |||
| ded54c18dd | |||
| 89f264bf5d | |||
| 675c76db74 | |||
| d74807c84e | |||
| 4d0f5815eb | |||
| 2f9e779bce | |||
| 0c57bdfc38 | |||
| 2250a1afd1 | |||
| 9102e756cb | |||
| e788eb788f | |||
| 2ec222694d | |||
| 58d4ee8b6e | |||
| d9a6852f43 | |||
| ba188329dc | |||
| 777be81d2a | |||
| 3afce0de77 | |||
| 4b8ec2c5ab | |||
| 0788002c9b | |||
| c7c631a701 | |||
| 9ba94f2c2c | |||
| 846e3abf74 | |||
| e646125116 | |||
| 417dec8c9b | |||
| f5cab5139a | |||
| 1caa5bef06 | |||
| 581dc566bc | |||
| 31a47934e5 | |||
| 958b558111 | |||
| 1f78284fb6 | |||
| b45aa4b59c | |||
| 09d8392a28 | |||
| 016f076e5e | |||
| 43aed3fb47 | |||
| a6a94fa42b | |||
| b1a233fa8c | |||
| f00fabeac5 | |||
| c82fac52f7 | |||
| db5465032e | |||
| fafe90e5e8 | |||
| 0e9648d61a | |||
| bd8bef142a | |||
| 261dec7fa9 | |||
| 1acb18711f | |||
| f046bdcc04 | |||
| 12c572a442 | |||
| 64d9aa336f | |||
| c27f05c5f6 | |||
| efab8eb2e4 | |||
| 867ecc53e0 | |||
| 4d8f7d7b41 | |||
| 36a369a207 | |||
| 2185901c7a | |||
| d0b05256b0 | |||
| 2c702d7d67 | |||
| a67fd72cb7 | |||
| 071f819341 | |||
| 8786aaae25 | |||
| 9065899616 | |||
| 9cad2a0f62 | |||
| dc85e2c946 | |||
| 4e00262ab0 | |||
| 35ea4caa39 | |||
| 5224e632f8 | |||
| ddb3a5023c | |||
| b35dcf3b54 | |||
| 9f23f047b1 | |||
| c29ae4f953 | |||
| 7c890a1aca | |||
| 8ec09389cf | |||
| 0ab4c41f9e | |||
| 67f11b94f5 | |||
| 9584cd0c04 | |||
| 80d875167a | |||
| 2b5e9e14f9 | |||
| 01742a1a74 | |||
| c15e2f941d | |||
| 3a8b0cc75f | |||
| 35e96bf52c | |||
| 695e069a9f | |||
| ffed64dbcd | |||
| e1751ef694 | |||
| 4950f7efbf | |||
| 7a7f07b20a | |||
| bf209aa843 | |||
| aa867e4612 | |||
| 0fddcfdc65 | |||
| b5e6565ae1 | |||
| 45952cbdc8 | |||
| a2f2956bf6 | |||
| 254f6383d7 | |||
| ddb17ab351 | |||
| c396ce8104 | |||
| 18f2bed3ea | |||
| 25acb9aeaa | |||
| 3399889cef | |||
| 95c93a2dd8 | |||
| 7d9512645c | |||
| 09d8f80cf3 | |||
| 4bf407ab7a | |||
| 9aad9aed00 | |||
| b4b5134cb2 | |||
| c4329785cc | |||
| b08d58e262 | |||
| 55c37b83b4 | |||
| bd0fcd5049 |
37
.gitea/workflows/deploy.yaml
Normal file
37
.gitea/workflows/deploy.yaml
Normal file
@@ -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"
|
||||
1
.github/copilot-instructions.md
vendored
Normal file
1
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Ignora los problemas de imports de eslint
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ count.txt
|
||||
.nitro
|
||||
.tanstack
|
||||
.wrangler
|
||||
diff.txt
|
||||
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
1
.vscode/launch.json
vendored
1
.vscode/launch.json
vendored
@@ -2,6 +2,7 @@
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
// close #40
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
|
||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -22,5 +22,11 @@
|
||||
],
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
}
|
||||
},
|
||||
"[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||
"[javascriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||
"[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||
"prettier.requireConfig": true
|
||||
}
|
||||
|
||||
13
package.json
13
package.json
@@ -17,10 +17,12 @@
|
||||
"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-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -40,23 +42,30 @@
|
||||
"@tanstack/react-router": "^1.132.0",
|
||||
"@tanstack/react-router-devtools": "^1.132.0",
|
||||
"@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",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.24.7",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"tw-animate-css": "^1.3.6"
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"use-debounce": "^10.1.0",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/devtools-vite": "^0.3.11",
|
||||
"@tanstack/eslint-config": "^0.3.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/bun": "^1.3.6",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
|
||||
757
public/csl/locales/locales-es-MX.xml
Normal file
757
public/csl/locales/locales-es-MX.xml
Normal file
@@ -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>
|
||||
2273
public/csl/styles/apa.csl
Normal file
2273
public/csl/styles/apa.csl
Normal file
File diff suppressed because it is too large
Load Diff
4189
public/csl/styles/chicago-author-date.csl
Normal file
4189
public/csl/styles/chicago-author-date.csl
Normal file
File diff suppressed because it is too large
Load Diff
519
public/csl/styles/ieee.csl
Normal file
519
public/csl/styles/ieee.csl
Normal file
@@ -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>
|
||||
520
public/csl/styles/nlm-citation-sequence.csl
Normal file
520
public/csl/styles/nlm-citation-sequence.csl
Normal file
@@ -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>
|
||||
BIN
public/fonts/indivisa/IndivisaTextSans-Black.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-Black.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSans-BlackItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-BlackItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSans-Bold.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-Bold.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSans-BoldItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-BoldItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSans-Light.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-Light.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSans-LightItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-LightItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSans-Regular.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-Regular.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSans-RegularItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSans-RegularItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-Black.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-Black.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-BlackItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-BlackItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-Bold.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-Bold.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-BoldItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-BoldItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-Light.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-Light.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-LightItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-LightItalic.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-Regular.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-Regular.otf
Normal file
Binary file not shown.
BIN
public/fonts/indivisa/IndivisaTextSerif-RegularItalic.otf
Normal file
BIN
public/fonts/indivisa/IndivisaTextSerif-RegularItalic.otf
Normal file
Binary file not shown.
118
public/lasalle-logo.svg
Normal file
118
public/lasalle-logo.svg
Normal file
@@ -0,0 +1,118 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 192.3 63.4" style="enable-background:new 0 0 192.3 63.4;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
.st1{fill:#FFFFFF;}
|
||||
.st2{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g>
|
||||
<g id="Group_1247_1_">
|
||||
<path id="Path_477_1_" class="st0" d="M50.7,50.6l4.4-7.8h-8.9l-12-21l-4.4,7.8l12,21C41.8,50.6,50.7,50.6,50.7,50.6z"/>
|
||||
<path id="Path_478_1_" class="st0" d="M34.3,1h-9l4.4,7.8l-12,20.8h9.1l12-20.8L34.3,1z"/>
|
||||
<path id="Path_479_1_" class="st0" d="M0,40.1l4.4,7.8l4.4-7.8h23.9l-4.4-7.8H4.4L0,40.1z"/>
|
||||
<path id="Path_480_1_" class="st1" d="M56.7,40.1l4.4-7.8h-9L40.3,11.4l-4.4,7.8l12,20.8H56.7z"/>
|
||||
<path id="Path_481_1_" class="st1" d="M22.3,1h-8.9l4.4,7.8l-12,20.8h9l12-20.8L22.3,1z"/>
|
||||
<path id="Path_482_1_" class="st1" d="M5.9,50.6l4.4,7.8l4.4-7.8h23.9l-4.4-7.8H10.5L5.9,50.6z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st1" d="M67.9,3.9c0-0.8,0-1.6-0.1-2.4l1.7-0.1l0.1,0.1v6.3c0,0.7,0.1,1.2,0.5,1.6C70.6,9.8,71,10,71.7,10
|
||||
c0.5,0,1.1-0.1,1.3-0.5c0.4-0.4,0.5-0.9,0.5-1.6V3.5c0-0.8,0-1.5-0.1-2.2l1.9-0.1v6.7c0,1.1-0.4,2-1.1,2.6
|
||||
c-0.7,0.5-1.6,0.9-2.7,0.9c-1.1,0-2-0.3-2.7-0.9C68.2,10,67.8,9,67.8,7.9L67.9,3.9L67.9,3.9z"/>
|
||||
<path class="st1" d="M83,11.3h-1.7V6.6c0-0.7-0.1-1.5-0.3-2.3L82.6,4c0.1,0.4,0.3,0.7,0.3,1.1C83.5,4.4,84.3,4,85.1,4
|
||||
c0.7,0,1.1,0.1,1.5,0.5C87,5,87.1,5.5,87.1,6.2v5.1h-1.7V6.6c0-0.8-0.4-1.3-1.1-1.3c-0.4,0-0.9,0.1-1.3,0.5L83,11.3L83,11.3z"/>
|
||||
<path class="st1" d="M95.1,1.6c0,0.3-0.1,0.7-0.4,0.8c-0.3,0.3-0.5,0.4-0.8,0.4S93.3,2.7,93,2.6c-0.1-0.1-0.3-0.4-0.3-0.7
|
||||
s0.1-0.7,0.4-0.8c0.3-0.1,0.5-0.4,0.9-0.4c0.3,0,0.5,0.1,0.7,0.3S95.1,1.4,95.1,1.6z M93,11.3V6.6c0-0.8,0-1.6-0.1-2.4l1.7-0.3
|
||||
L94.8,4v7.1L93,11.3L93,11.3z"/>
|
||||
<path class="st1" d="M106.4,4.3l-2.3,7h-1.9l-2.3-7.1l1.9-0.1l0.9,3.6c0.3,1.1,0.5,1.9,0.5,2.4l0,0c0.1-0.5,0.3-1.3,0.7-2.4
|
||||
l0.9-3.6L106.4,4.3L106.4,4.3z"/>
|
||||
<path class="st1" d="M116.7,7.7l-0.3,0.3h-4c0,0.8,0.3,1.3,0.7,1.7c0.4,0.4,0.8,0.5,1.5,0.5c0.5,0,1.2-0.1,1.7-0.5l0.1,1.2
|
||||
c-0.7,0.4-1.3,0.7-2.4,0.7c-1.1,0-1.9-0.3-2.6-0.9s-0.9-1.5-0.9-2.7s0.3-2,0.9-2.8s1.5-1.1,2.4-1.1c0.8,0,1.5,0.3,2,0.8
|
||||
c0.5,0.5,0.8,1.2,0.8,2.2C116.7,7.3,116.7,7.5,116.7,7.7z M113.9,5.1c-0.4,0-0.8,0.1-0.9,0.5c-0.3,0.4-0.4,0.9-0.4,1.6l2.6-0.1
|
||||
c0-0.1,0-0.3,0-0.5c0-0.4-0.1-0.8-0.3-1.1C114.6,5.3,114.3,5.1,113.9,5.1z"/>
|
||||
<path class="st1" d="M124,11.3h-1.7V6.6c0-0.7-0.1-1.5-0.3-2.3l1.6-0.3c0.1,0.5,0.3,0.9,0.4,1.5c0.5-0.9,1.2-1.5,1.9-1.5
|
||||
c0.3,0,0.5,0,0.7,0.1l-0.1,1.7c-0.3-0.1-0.5-0.1-0.8-0.1c-0.5,0-1.1,0.1-1.6,0.5C124,6.3,124,11.3,124,11.3z"/>
|
||||
<path class="st1" d="M135.8,4.4l-0.1,1.3c-0.7-0.4-1.3-0.5-1.9-0.5c-0.4,0-0.7,0.1-0.8,0.3c-0.3,0.1-0.3,0.3-0.3,0.5
|
||||
c0,0.3,0.1,0.4,0.4,0.7c0.3,0.1,0.5,0.4,0.8,0.5c0.3,0.1,0.7,0.3,1.1,0.4c0.4,0.1,0.7,0.4,0.8,0.7c0.3,0.3,0.4,0.7,0.4,1.1
|
||||
c0,0.7-0.3,1.2-0.8,1.5c-0.5,0.4-1.2,0.5-2.2,0.5s-1.7-0.1-2.4-0.5l0.1-1.3c0.8,0.4,1.5,0.7,2.3,0.7c0.4,0,0.7-0.1,0.8-0.3
|
||||
c0.1-0.1,0.3-0.3,0.3-0.5c0-0.3-0.1-0.4-0.4-0.7c-0.3-0.1-0.5-0.4-0.8-0.5s-0.7-0.3-0.9-0.4s-0.7-0.4-0.8-0.7
|
||||
c-0.3-0.3-0.4-0.7-0.4-1.1c0-0.7,0.3-1.2,0.8-1.6c0.5-0.4,1.2-0.5,2-0.5C134.5,4,135.1,4.2,135.8,4.4z"/>
|
||||
<path class="st1" d="M143.3,1.6c0,0.3-0.1,0.7-0.4,0.8c-0.3,0.1-0.5,0.4-0.8,0.4c-0.3,0-0.5-0.1-0.8-0.3c-0.1-0.1-0.1-0.4-0.1-0.7
|
||||
s0.1-0.7,0.4-0.8c0.3-0.3,0.5-0.4,0.9-0.4c0.3,0,0.5,0.1,0.7,0.3S143.3,1.4,143.3,1.6z M141.3,11.3V6.6c0-0.8,0-1.6-0.1-2.4
|
||||
l1.7-0.3l0.1,0.1v7.1L141.3,11.3L141.3,11.3z"/>
|
||||
<path class="st1" d="M153,0.7l1.7-0.3l0.1,0.1v8.7c0,0.7,0.1,1.3,0.3,1.9l-1.6,0.1c-0.1-0.1-0.3-0.5-0.4-0.9l0,0
|
||||
c-0.5,0.7-1.1,0.9-2,0.9c-0.9,0-1.5-0.3-2-0.9c-0.5-0.7-0.8-1.5-0.8-2.6c0-1.2,0.4-2.2,1.1-3c0.7-0.7,1.6-1.1,2.7-1.1
|
||||
c0.3,0,0.5,0,0.9,0.1V3C153.2,2,153.2,1.4,153,0.7z M150.5,7.7c0,0.8,0.1,1.5,0.4,1.9c0.3,0.5,0.7,0.7,1.2,0.7
|
||||
c0.4,0,0.8-0.1,1.1-0.4V5c-0.3,0-0.5-0.1-0.7-0.1c-0.7,0-1.1,0.3-1.5,0.7C150.6,6.2,150.5,6.9,150.5,7.7z"/>
|
||||
<path class="st1" d="M166.1,9.3c0,0.7,0.1,1.3,0.3,2l-1.5,0.1c-0.1-0.3-0.3-0.5-0.4-0.9l0,0c-0.3,0.3-0.5,0.5-0.9,0.7
|
||||
c-0.4,0.1-0.8,0.3-1.2,0.3c-0.5,0-1.1-0.1-1.3-0.4c-0.4-0.3-0.5-0.7-0.5-1.2c0-0.8,0.4-1.3,1.1-1.7c0.7-0.4,1.6-0.7,2.8-0.7V6.7
|
||||
c0-0.8-0.4-1.3-1.3-1.3c-0.7,0-1.5,0.3-2.2,0.7l-0.1-1.3c0.9-0.4,1.7-0.5,2.7-0.5s1.6,0.3,2,0.7c0.4,0.4,0.7,0.9,0.7,1.7
|
||||
c0,0.4,0,0.8,0,1.5C166.1,8.6,166.1,9,166.1,9.3z M162.2,9.4c0,0.3,0.1,0.5,0.3,0.7c0.1,0.1,0.4,0.3,0.7,0.3
|
||||
c0.4,0,0.8-0.1,1.2-0.5V7.9c-0.7,0-1.1,0.3-1.5,0.4C162.3,8.6,162.2,9,162.2,9.4z"/>
|
||||
<path class="st1" d="M175.6,0.7l1.7-0.3l0.1,0.1v8.7c0,0.7,0.1,1.3,0.3,1.9l-1.6,0.1c-0.1-0.1-0.3-0.5-0.4-0.9l0,0
|
||||
c-0.5,0.7-1.1,0.9-2,0.9s-1.5-0.3-2-0.9c-0.5-0.7-0.8-1.5-0.8-2.6c0-1.2,0.4-2.2,1.1-3c0.7-0.7,1.6-1.1,2.7-1.1
|
||||
c0.3,0,0.5,0,0.9,0.1V3C175.7,2,175.7,1.4,175.6,0.7z M173.1,7.7c0,0.8,0.1,1.5,0.4,1.9c0.3,0.5,0.7,0.7,1.2,0.7
|
||||
c0.4,0,0.8-0.1,1.1-0.4V5c-0.3,0-0.5-0.1-0.7-0.1c-0.7,0-1.1,0.3-1.5,0.7C173.2,6.2,173.1,6.9,173.1,7.7z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st1" d="M78.8,51.2l0.3,0.3l1.2,11h-2l-0.4-4c-0.1-1.2-0.3-3-0.4-5l0,0c-0.3,1.2-0.7,2.8-1.2,5l-1.2,4h-2.3l-1.1-4
|
||||
c-0.5-1.9-0.9-3.5-1.2-5l0,0c-0.1,1.2-0.3,2.8-0.4,5l-0.4,4h-1.7l1.2-11.2l2.7-0.1l1.3,4.6c0.4,1.6,0.8,3.2,1.1,4.8l0,0
|
||||
c0.3-1.6,0.5-3.2,1.1-4.8l1.3-4.4L78.8,51.2z"/>
|
||||
<path class="st1" d="M89.4,58.5l-0.3,0.3h-4.4c0.1,0.8,0.3,1.5,0.8,1.9c0.5,0.4,0.9,0.7,1.6,0.7s1.3-0.1,2-0.5l0.1,1.3
|
||||
c-0.7,0.4-1.6,0.7-2.7,0.7c-1.2,0-2.2-0.4-3-1.1c-0.7-0.7-1.1-1.7-1.1-3c0-1.3,0.4-2.3,1.1-3.1c0.7-0.8,1.6-1.2,2.7-1.2
|
||||
c0.9,0,1.7,0.3,2.3,0.9c0.5,0.5,0.8,1.3,0.8,2.4C89.4,58,89.4,58.4,89.4,58.5z M87.7,50.4l0.1,0.4c-0.7,0.9-1.5,1.9-2.6,2.7
|
||||
l-0.8-0.1c0.7-1.1,1.1-2.2,1.3-3H87.7z M86.3,55.5c-0.4,0-0.8,0.3-1.1,0.7c-0.3,0.4-0.4,1.1-0.5,1.7l2.8-0.1c0-0.1,0-0.3,0-0.5
|
||||
c0-0.5-0.1-0.9-0.3-1.2C87,55.7,86.7,55.5,86.3,55.5z"/>
|
||||
<path class="st1" d="M96.4,62.5l-1.7-3.1L93,62.5h-1.6l-0.1-0.3l2.3-3.8l-2.3-4l2-0.3l1.7,3.4l1.6-3.4l1.6,0.1l0.1,0.3L96,58.4
|
||||
l2.4,4h-2V62.5z"/>
|
||||
<path class="st1" d="M103.1,51.8c0,0.4-0.1,0.7-0.4,0.9s-0.5,0.4-0.9,0.4c-0.4,0-0.7-0.1-0.8-0.4c-0.3-0.3-0.3-0.5-0.3-0.8
|
||||
c0-0.4,0.1-0.7,0.4-0.9c0.3-0.3,0.5-0.4,0.9-0.4c0.3,0,0.5,0.1,0.8,0.3C103,51.1,103.1,51.4,103.1,51.8z M100.8,62.5v-5.2
|
||||
c0-0.9,0-1.9-0.1-2.7l2-0.3l0.3,0.3v7.9H100.8z"/>
|
||||
<path class="st1" d="M112.3,55l-0.4,1.6c-0.7-0.4-1.2-0.7-1.9-0.7c-0.5,0-1.1,0.3-1.5,0.7c-0.4,0.4-0.5,1.1-0.5,1.9
|
||||
c0,0.9,0.1,1.6,0.7,2c0.4,0.5,0.9,0.8,1.7,0.8c0.5,0,1.2-0.1,1.7-0.5l0.1,1.3c-0.7,0.4-1.5,0.7-2.4,0.7c-1.2,0-2.2-0.4-2.8-1.1
|
||||
s-1.1-1.7-1.1-3c0-1.3,0.4-2.3,1.1-3.1c0.8-0.8,1.7-1.2,2.8-1.2C110.8,54.3,111.6,54.6,112.3,55z"/>
|
||||
<path class="st1" d="M121.4,58.5c0,1.3-0.4,2.4-1.1,3.1s-1.6,1.1-2.7,1.1c-1.1,0-1.9-0.4-2.6-1.1s-1.1-1.7-1.1-3
|
||||
c0-1.3,0.4-2.4,1.1-3.1s1.6-1.2,2.7-1.2c1.1,0,2,0.4,2.7,1.1C121.1,56.2,121.4,57.3,121.4,58.5z M116,58.5c0,2,0.5,3,1.6,3
|
||||
c0.5,0,0.9-0.3,1.2-0.8c0.3-0.5,0.4-1.2,0.4-2.2c0-2-0.5-3.1-1.6-3.1c-0.5,0-0.9,0.3-1.2,0.8C116.3,56.8,116,57.6,116,58.5z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st2" d="M83.5,41v3.8H68.3V25.3c0-2.4-0.1-4.6-0.4-6.5l4.6-0.4L73,19v22.2L83.5,41z"/>
|
||||
<path class="st2" d="M100.4,39.5c0,1.9,0.3,3.6,0.8,5.1l-3.9,0.4c-0.4-0.7-0.8-1.6-1.1-2.6h-0.1c-0.5,0.7-1.3,1.3-2.3,1.9
|
||||
c-0.9,0.5-2.2,0.8-3.2,0.8c-1.5,0-2.7-0.4-3.6-1.2c-0.9-0.8-1.3-1.9-1.3-3.4c0-2,0.9-3.6,2.8-4.6c1.9-1.1,4.3-1.6,7.4-1.7v-1.7
|
||||
c0-2.3-1.2-3.4-3.5-3.4c-1.9,0-3.8,0.5-5.6,1.6l-0.3-3.6c2.4-0.9,4.7-1.5,7.1-1.5c2.3,0,4,0.5,5.2,1.6c1.2,1.1,1.7,2.6,1.7,4.6
|
||||
c0,0.9,0,2.3,0,4C100.4,37.7,100.4,38.9,100.4,39.5z M90.2,39.8c0,0.7,0.3,1.3,0.7,1.7c0.4,0.5,1.1,0.7,1.7,0.7
|
||||
c1.2,0,2.2-0.4,3.1-1.3v-5.1c-1.6,0.1-3,0.5-4,1.2C90.8,37.8,90.2,38.7,90.2,39.8z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st2" d="M126.3,37.3c0,2.4-0.8,4.2-2.6,5.5c-1.7,1.3-4,2-6.9,2c-3.1,0-5.5-0.5-7.3-1.5L110,39c0.9,0.5,2,1.1,3.5,1.3
|
||||
c1.3,0.3,2.6,0.4,3.6,0.4c1.3,0,2.4-0.3,3.2-0.9c0.8-0.5,1.1-1.3,1.1-2.4c0-0.3,0-0.5-0.1-0.8c0-0.3-0.1-0.5-0.3-0.7
|
||||
s-0.3-0.4-0.4-0.7c-0.1-0.3-0.4-0.4-0.4-0.5c-0.1-0.1-0.3-0.3-0.7-0.5c-0.3-0.3-0.5-0.4-0.7-0.4c-0.1-0.1-0.4-0.3-0.8-0.4
|
||||
c-0.4-0.3-0.7-0.4-0.8-0.4c-0.1-0.1-0.4-0.3-0.9-0.4c-0.4-0.3-0.7-0.4-0.8-0.4c-0.9-0.4-1.6-0.8-2.2-1.2c-0.5-0.4-1.2-0.8-1.7-1.5
|
||||
c-0.7-0.5-1.1-1.2-1.3-2c-0.3-0.8-0.4-1.6-0.4-2.7c0-2.4,0.9-4.2,2.7-5.5c1.7-1.3,4.2-2,7-2c2.4,0,4.4,0.4,6.2,1.1l-0.7,4.3
|
||||
c-1.7-1.1-3.6-1.5-5.6-1.5c-1.5,0-2.6,0.3-3.4,0.9c-0.8,0.7-1.2,1.3-1.2,2.4c0,0.4,0,0.7,0.1,1.1c0.1,0.3,0.3,0.7,0.5,0.9
|
||||
c0.3,0.3,0.5,0.5,0.7,0.8c0.1,0.1,0.5,0.4,0.9,0.7c0.5,0.3,0.8,0.5,1.1,0.5c0.1,0.1,0.5,0.3,1.2,0.7c0.7,0.3,1.1,0.5,1.2,0.5
|
||||
c0.8,0.4,1.5,0.8,2.2,1.2c0.5,0.4,1.2,0.8,1.7,1.5c0.7,0.7,1.1,1.3,1.3,2.2C126.1,35.4,126.3,36.3,126.3,37.3z"/>
|
||||
<path class="st2" d="M143.9,39.1c0,1.9,0.3,3.8,0.8,5.4l-4,0.4c-0.4-0.7-0.8-1.6-1.1-2.7h-0.1c-0.5,0.8-1.3,1.3-2.4,1.9
|
||||
c-1.1,0.5-2.2,0.8-3.4,0.8c-1.6,0-2.8-0.4-3.8-1.2c-0.9-0.8-1.3-2-1.3-3.5c0-2.2,0.9-3.6,3-4.7c1.9-1.1,4.4-1.6,7.7-1.7v-1.9
|
||||
c0-2.3-1.2-3.5-3.6-3.5c-2,0-3.9,0.5-5.9,1.7l-0.3-3.8c2.4-1.1,4.8-1.5,7.4-1.5c2.4,0,4.2,0.5,5.4,1.6c1.2,1.1,1.9,2.7,1.9,4.8
|
||||
c0,0.9,0,2.3,0,4.2C143.9,37.1,143.9,38.3,143.9,39.1z M133.4,39.3c0,0.7,0.3,1.3,0.7,1.9c0.4,0.5,1.1,0.8,1.9,0.8
|
||||
c1.2,0,2.3-0.4,3.2-1.3v-5.2c-1.7,0.1-3.1,0.5-4.2,1.2S133.4,38.2,133.4,39.3z"/>
|
||||
<path class="st2" d="M153.7,44.4h-4.8V21.9c0-2-0.1-4.2-0.4-6.5l4.8-0.5l0.5,0.5L153.7,44.4L153.7,44.4z"/>
|
||||
<path class="st2" d="M163.4,44.4h-4.8V21.9c0-2-0.1-4.2-0.4-6.5l4.8-0.5l0.5,0.5L163.4,44.4L163.4,44.4z"/>
|
||||
<path class="st2" d="M183.7,34.7l-0.5,0.5h-10.9c0.1,2,0.8,3.6,1.7,4.6c0.9,0.9,2.4,1.5,4,1.5c1.6,0,3.2-0.4,4.8-1.3l0.3,3.2
|
||||
c-1.7,1.1-3.9,1.6-6.5,1.6c-3,0-5.2-0.8-7-2.6s-2.6-4.2-2.6-7.3c0-3.1,0.8-5.6,2.6-7.5c1.7-1.9,3.9-2.8,6.6-2.8
|
||||
c2.3,0,4.2,0.7,5.5,2.2c1.3,1.5,2,3.4,2,5.6C183.8,33.5,183.8,34.2,183.7,34.7z M176,27.6c-1.1,0-2,0.5-2.7,1.6
|
||||
c-0.7,1.1-1.1,2.6-1.2,4.3l6.7-0.3c0-0.3,0-0.8,0-1.3c0-1.2-0.3-2.3-0.8-3.1S176.9,27.6,176,27.6z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path id="Path_483" class="st1" d="M187,39.4h1.5c0.3,0,0.7,0.1,1,0.2c0.3,0.2,0.5,0.5,0.5,0.8c0,0.3-0.1,0.6-0.3,0.8
|
||||
c-0.1,0.1-0.3,0.2-0.5,0.3c0.4,0.1,0.6,0.3,0.6,0.8c0,0.4,0.1,0.9,0.3,1.3h-0.6c-0.1-0.4-0.2-0.7-0.2-1.1
|
||||
c-0.1-0.6-0.2-0.8-0.9-0.8h-0.7v1.9H187V39.4 M187.5,41.2h0.9c0.2,0,0.4,0,0.6-0.1c0.2-0.1,0.3-0.3,0.3-0.6c0-0.7-0.6-0.7-0.8-0.7
|
||||
h-0.9V41.2z"/>
|
||||
<path id="Path_484" class="st1" d="M191.9,41.5c0,1.9-1.6,3.4-3.4,3.3s-3.4-1.6-3.3-3.4c0-1.9,1.5-3.3,3.4-3.3
|
||||
C190.4,38.1,191.9,39.6,191.9,41.5z M188.5,37.8c-2.1,0-3.7,1.6-3.8,3.7c0,2.1,1.6,3.7,3.7,3.8c2.1,0,3.7-1.6,3.8-3.7c0,0,0,0,0,0
|
||||
C192.2,39.4,190.5,37.8,188.5,37.8z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
19
scripts/update-types.ts
Normal file
19
scripts/update-types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// scripts/update-types.ts
|
||||
/* Uso:
|
||||
bun run scripts/update-types.ts
|
||||
*/
|
||||
import { $ } from "bun";
|
||||
|
||||
console.log("🔄 Generando tipos de Supabase...");
|
||||
|
||||
try {
|
||||
// Ejecutamos el comando y capturamos la salida como texto
|
||||
const output = await $`supabase gen types typescript --linked`.text();
|
||||
|
||||
// Escribimos el archivo directamente con Bun (garantiza UTF-8)
|
||||
await Bun.write("src/types/supabase.ts", output);
|
||||
|
||||
console.log("✅ Tipos actualizados correctamente con acentos.");
|
||||
} catch (error) {
|
||||
console.error("❌ Error generando tipos:", error);
|
||||
}
|
||||
@@ -18,11 +18,7 @@ export default function Header() {
|
||||
</button>
|
||||
<h1 className="ml-4 text-xl font-semibold">
|
||||
<Link to="/">
|
||||
<img
|
||||
src="/tanstack-word-logo-white.svg"
|
||||
alt="TanStack Logo"
|
||||
className="h-10"
|
||||
/>
|
||||
<img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" />
|
||||
</Link>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
870
src/components/asignaturas/detalle/AsignaturaDetailPage.tsx
Normal file
870
src/components/asignaturas/detalle/AsignaturaDetailPage.tsx
Normal file
@@ -0,0 +1,870 @@
|
||||
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
||||
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 { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
|
||||
|
||||
export interface BibliografiaEntry {
|
||||
id: string
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
||||
cita: string
|
||||
fuenteBibliotecaId?: string
|
||||
fuenteBiblioteca?: any
|
||||
}
|
||||
export interface BibliografiaTabProps {
|
||||
id: string
|
||||
bibliografia: Array<BibliografiaEntry>
|
||||
onSave: (bibliografia: Array<BibliografiaEntry>) => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export interface AsignaturaDatos {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
export interface AsignaturaResponse {
|
||||
datos: AsignaturaDatos
|
||||
}
|
||||
|
||||
type CriterioEvaluacionRow = {
|
||||
criterio: string
|
||||
porcentaje: number
|
||||
}
|
||||
|
||||
type CriterioEvaluacionRowDraft = {
|
||||
id: string
|
||||
criterio: string
|
||||
porcentaje: string // allow empty while editing
|
||||
}
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/planes/$planId/asignaturas/$asignaturaId',
|
||||
)({
|
||||
component: AsignaturaDetailPage,
|
||||
})
|
||||
|
||||
export default function AsignaturaDetailPage() {
|
||||
const { asignaturaId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
const { data: asignaturaApi } = useSubject(asignaturaId)
|
||||
|
||||
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
|
||||
const updateAsignatura = useUpdateAsignatura()
|
||||
|
||||
const handlePersistDatoGeneral = (clave: string, value: string) => {
|
||||
const baseDatos = asignatura?.datos ?? (asignaturaApi as any)?.datos ?? {}
|
||||
const mergedDatos = { ...baseDatos, [clave]: value }
|
||||
|
||||
// Mantener estado local coherente para merges posteriores.
|
||||
setAsignatura((prev) => ({
|
||||
...((prev ?? asignaturaApi ?? {}) as any),
|
||||
datos: mergedDatos,
|
||||
}))
|
||||
|
||||
updateAsignatura.mutate({
|
||||
asignaturaId,
|
||||
patch: {
|
||||
datos: mergedDatos,
|
||||
},
|
||||
})
|
||||
}
|
||||
/* ---------- sincronizar API ---------- */
|
||||
useEffect(() => {
|
||||
if (asignaturaApi) setAsignatura(asignaturaApi)
|
||||
}, [asignaturaApi])
|
||||
|
||||
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
|
||||
}
|
||||
|
||||
function DatosGenerales({
|
||||
onPersistDato,
|
||||
}: {
|
||||
onPersistDato: (clave: string, value: string) => void
|
||||
}) {
|
||||
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
|
||||
const definicion = isRecord(definicionRaw)
|
||||
? (definicionRaw as Record<string, unknown>)
|
||||
: null
|
||||
|
||||
const propertiesRaw = definicion ? (definicion as any).properties : undefined
|
||||
const structureProps = isRecord(propertiesRaw)
|
||||
? (propertiesRaw as Record<string, any>)
|
||||
: {}
|
||||
|
||||
// 2. Extraemos los valores reales (el contenido redactado)
|
||||
const datosRaw = data?.datos
|
||||
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 (
|
||||
<div className="animate-in fade-in mx-auto max-w-7xl space-y-8 px-4 py-8 duration-500">
|
||||
{/* Encabezado de la Sección */}
|
||||
<div className="flex flex-col justify-between gap-4 border-b pb-6 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||
Datos Generales
|
||||
</h2>
|
||||
<p className="mt-1 text-slate-500">
|
||||
Información oficial estructurada bajo los lineamientos de la SEP.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid de Información */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{/* Columna Principal (Más ancha) */}
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
{Object.entries(structureProps).map(
|
||||
([key, config]: [string, any]) => {
|
||||
const cardTitle = config.title || key
|
||||
const description = config.description || ''
|
||||
|
||||
const xColumn =
|
||||
typeof config?.['x-column'] === 'string'
|
||||
? config['x-column']
|
||||
: undefined
|
||||
|
||||
// Obtenemos el placeholder del arreglo 'examples' de la estructura
|
||||
const placeholder =
|
||||
config.examples && config.examples.length > 0
|
||||
? config.examples[0]
|
||||
: ''
|
||||
|
||||
const valActual = valoresActuales[key]
|
||||
|
||||
let currentContent = valActual ?? ''
|
||||
|
||||
if (xColumn) {
|
||||
const rawValue = (data as any)?.[xColumn]
|
||||
const parser = columnParsers[xColumn]
|
||||
currentContent = parser
|
||||
? parser(rawValue)
|
||||
: String(rawValue ?? '')
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoCard
|
||||
asignaturaId={asignaturaId}
|
||||
key={key}
|
||||
clave={key}
|
||||
title={cardTitle}
|
||||
initialContent={currentContent}
|
||||
placeholder={placeholder}
|
||||
description={description}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Columna Lateral (Información Secundaria) */}
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
{/* Tarjeta de Requisitos */}
|
||||
<InfoCard
|
||||
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',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Tarjeta de Evaluación */}
|
||||
<InfoCard
|
||||
title="Sistema de Evaluación"
|
||||
type="evaluation"
|
||||
initialContent={criteriosEvaluacion}
|
||||
containerRef={evaluationCardRef}
|
||||
forceEditToken={evaluationForceEditToken}
|
||||
highlightToken={evaluationHighlightToken}
|
||||
onPersist={({ value }) => persistCriteriosEvaluacion(value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface InfoCardProps {
|
||||
asignaturaId?: string
|
||||
clave?: string
|
||||
title: string
|
||||
initialContent: any
|
||||
placeholder?: string
|
||||
description?: string
|
||||
required?: boolean // Nueva prop para el asterisco
|
||||
type?: 'text' | 'requirements' | 'evaluation'
|
||||
onEnhanceAI?: (content: any) => 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
|
||||
}
|
||||
|
||||
function InfoCard({
|
||||
asignaturaId,
|
||||
clave,
|
||||
title,
|
||||
initialContent,
|
||||
placeholder,
|
||||
description,
|
||||
required,
|
||||
type = 'text',
|
||||
onPersist,
|
||||
onClickEditButton,
|
||||
containerRef,
|
||||
forceEditToken,
|
||||
highlightToken,
|
||||
}: 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',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setData(initialContent)
|
||||
setTempText(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 ?? ''))
|
||||
|
||||
if (type === 'evaluation') {
|
||||
const cleaned: Array<CriterioEvaluacionRow> = []
|
||||
for (const r of evalRows) {
|
||||
const criterio = String(r.criterio).trim()
|
||||
const porcentajeStr = String(r.porcentaje).trim()
|
||||
if (!criterio) continue
|
||||
if (!porcentajeStr) continue
|
||||
|
||||
const n = Number(porcentajeStr)
|
||||
if (!Number.isFinite(n)) continue
|
||||
const porcentaje = Math.trunc(n)
|
||||
if (porcentaje < 1 || porcentaje > 100) continue
|
||||
|
||||
cleaned.push({ criterio, porcentaje })
|
||||
}
|
||||
|
||||
setData(cleaned)
|
||||
setEvalRows(
|
||||
cleaned.map((x) => ({
|
||||
id: crypto.randomUUID(),
|
||||
criterio: x.criterio,
|
||||
porcentaje: String(x.porcentaje),
|
||||
})),
|
||||
)
|
||||
setIsEditing(false)
|
||||
|
||||
void onPersist?.({ type, clave, value: cleaned })
|
||||
return
|
||||
}
|
||||
|
||||
setData(tempText)
|
||||
setIsEditing(false)
|
||||
|
||||
if (type === 'text') {
|
||||
void onPersist?.({ type, clave, value: String(tempText ?? '') })
|
||||
}
|
||||
}
|
||||
|
||||
const handleIARequest = (campoClave: string) => {
|
||||
console.log(placeholder)
|
||||
|
||||
// Añadimos un timestamp a la state para forzar que la navegación
|
||||
// genere una nueva ubicación incluso si la ruta y los params son iguales.
|
||||
navigate({
|
||||
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
|
||||
params: { planId, asignaturaId: asignaturaId! },
|
||||
state: {
|
||||
activeTab: 'ia',
|
||||
prefillCampo: campoClave,
|
||||
prefillContenido: data,
|
||||
_ts: Date.now(),
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
const evaluationTotal = useMemo(() => {
|
||||
if (type !== 'evaluation') return 0
|
||||
return evalRows.reduce((acc, r) => {
|
||||
const v = String(r.porcentaje).trim()
|
||||
if (!v) return acc
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n)) return acc
|
||||
const porcentaje = Math.trunc(n)
|
||||
if (porcentaje < 1 || porcentaje > 100) return acc
|
||||
return acc + porcentaje
|
||||
}, 0)
|
||||
}, [type, evalRows])
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
|
||||
onClick={() => clave && 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>
|
||||
|
||||
<CardContent className="pt-4">
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
{type === 'evaluation' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{evalRows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="grid grid-cols-[2fr_1fr_1ch_32px] items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
value={row.criterio}
|
||||
placeholder="Criterio"
|
||||
onChange={(e) => {
|
||||
const nextCriterio = e.target.value
|
||||
setEvalRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === row.id
|
||||
? { ...r, criterio: nextCriterio }
|
||||
: r,
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
value={row.porcentaje}
|
||||
placeholder="%"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
// Solo permitir '' o dígitos
|
||||
if (raw !== '' && !/^\d+$/.test(raw)) return
|
||||
|
||||
if (raw === '') {
|
||||
setEvalRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === row.id
|
||||
? {
|
||||
id: r.id,
|
||||
criterio: r.criterio,
|
||||
porcentaje: '',
|
||||
}
|
||||
: r,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n)) return
|
||||
const porcentaje = Math.trunc(n)
|
||||
if (porcentaje < 1 || porcentaje > 100) return
|
||||
|
||||
// No permitir suma > 100
|
||||
setEvalRows((prev) => {
|
||||
const next = prev.map((r) =>
|
||||
r.id === row.id
|
||||
? {
|
||||
id: r.id,
|
||||
criterio: r.criterio,
|
||||
porcentaje: raw,
|
||||
}
|
||||
: r,
|
||||
)
|
||||
|
||||
const total = next.reduce((acc, r) => {
|
||||
const v = String(r.porcentaje).trim()
|
||||
if (!v) return acc
|
||||
const nn = Number(v)
|
||||
if (!Number.isFinite(nn)) return acc
|
||||
const vv = Math.trunc(nn)
|
||||
if (vv < 1 || vv > 100) return acc
|
||||
return acc + vv
|
||||
}, 0)
|
||||
|
||||
return total > 100 ? prev : next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex w-[1ch] items-center justify-center text-sm text-slate-600"
|
||||
aria-hidden
|
||||
>
|
||||
%
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-600 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
setEvalRows((prev) =>
|
||||
prev.filter((r) => r.id !== row.id),
|
||||
)
|
||||
}}
|
||||
aria-label="Quitar renglón"
|
||||
title="Quitar"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={
|
||||
'text-sm ' +
|
||||
(evaluationTotal === 100
|
||||
? 'text-muted-foreground'
|
||||
: 'text-destructive font-semibold')
|
||||
}
|
||||
>
|
||||
Total: {evaluationTotal}/100
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-emerald-700 hover:bg-emerald-50"
|
||||
onClick={() => {
|
||||
// Agregar una fila vacía (siempre permitido)
|
||||
setEvalRows((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
criterio: '',
|
||||
porcentaje: '',
|
||||
},
|
||||
])
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
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)
|
||||
if (type === 'evaluation') {
|
||||
const raw = Array.isArray(data) ? data : []
|
||||
setEvalRows(
|
||||
raw.map((r: CriterioEvaluacionRow) => ({
|
||||
id: crypto.randomUUID(),
|
||||
criterio:
|
||||
typeof r.criterio === 'string' ? r.criterio : '',
|
||||
porcentaje:
|
||||
typeof r.porcentaje === 'number'
|
||||
? String(Math.trunc(r.porcentaje))
|
||||
: typeof r.porcentaje === 'string'
|
||||
? String(Math.trunc(Number(r.porcentaje)))
|
||||
: '',
|
||||
})),
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#00a878] hover:bg-[#008f66]"
|
||||
onClick={handleSave}
|
||||
disabled={type === 'evaluation' && evaluationTotal > 100}
|
||||
>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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 as Array<CriterioEvaluacionRow>} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Vista de Requisitos
|
||||
function RequirementsView({ items }: { items: Array<any> }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((req, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-slate-100 bg-slate-50 p-3"
|
||||
>
|
||||
<p className="text-[10px] font-bold tracking-tight text-slate-400 uppercase">
|
||||
{req.type}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{req.code} {req.name}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Vista de Evaluación
|
||||
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) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex justify-between border-b border-slate-50 pb-1.5 text-sm italic"
|
||||
>
|
||||
<span className="text-slate-500">{item.criterio}</span>
|
||||
<span className="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>
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
|
||||
contenido_tematico: parseContenidoTematicoToPlainText,
|
||||
criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText,
|
||||
}
|
||||
@@ -1,130 +1,177 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Search, BookOpen, Trash2, Library, Edit3, Save } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
//import { toast } from 'sonner';
|
||||
//import { mockLibraryResources } from '@/data/mockMateriaData';
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
|
||||
export const mockLibraryResources = [
|
||||
{
|
||||
id: 'lib-1',
|
||||
titulo: 'Deep Learning',
|
||||
autor: 'Goodfellow, I., Bengio, Y., & Courville, A.',
|
||||
editorial: 'MIT Press',
|
||||
anio: 2016,
|
||||
isbn: '9780262035613',
|
||||
disponible: true
|
||||
},
|
||||
{
|
||||
id: 'lib-2',
|
||||
titulo: 'Artificial Intelligence: A Modern Approach',
|
||||
autor: 'Russell, S., & Norvig, P.',
|
||||
editorial: 'Pearson',
|
||||
anio: 2020,
|
||||
isbn: '9780134610993',
|
||||
disponible: true
|
||||
},
|
||||
{
|
||||
id: 'lib-3',
|
||||
titulo: 'Hands-On Machine Learning',
|
||||
autor: 'Aurélien Géron',
|
||||
editorial: 'O\'Reilly Media',
|
||||
anio: 2019,
|
||||
isbn: '9781492032649',
|
||||
disponible: false
|
||||
}
|
||||
];
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import { useNavigate, useParams } from '@tanstack/react-router'
|
||||
import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
useCreateBibliografia,
|
||||
useDeleteBibliografia,
|
||||
useSubjectBibliografia,
|
||||
useUpdateBibliografia,
|
||||
} from '@/data/hooks/useSubjects'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// --- Interfaces ---
|
||||
export interface BibliografiaEntry {
|
||||
id: string;
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA';
|
||||
cita: string;
|
||||
fuenteBibliotecaId?: string;
|
||||
fuenteBiblioteca?: any;
|
||||
id: string
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
||||
cita: string
|
||||
tipo_fuente?: 'MANUAL' | 'BIBLIOTECA'
|
||||
biblioteca_item_id?: string | null
|
||||
fuenteBibliotecaId?: string
|
||||
fuenteBiblioteca?: any
|
||||
}
|
||||
|
||||
interface BibliografiaTabProps {
|
||||
bibliografia: BibliografiaEntry[];
|
||||
onSave: (bibliografia: BibliografiaEntry[]) => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
export function BibliographyItem() {
|
||||
const navigate = useNavigate()
|
||||
const { planId, asignaturaId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
|
||||
export function BibliographyItem({ bibliografia, onSave, isSaving }: BibliografiaTabProps) {
|
||||
const [entries, setEntries] = useState<BibliografiaEntry[]>(bibliografia);
|
||||
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');
|
||||
// --- 1. Única fuente de verdad: La Query ---
|
||||
const { data: bibliografia = [], isLoading } =
|
||||
useSubjectBibliografia(asignaturaId)
|
||||
|
||||
const basicaEntries = entries.filter(e => e.tipo === 'BASICA');
|
||||
const complementariaEntries = entries.filter(e => e.tipo === 'COMPLEMENTARIA');
|
||||
// --- 2. Mutaciones ---
|
||||
const { mutate: crearBibliografia } = useCreateBibliografia()
|
||||
const { mutate: actualizarBibliografia } = useUpdateBibliografia(asignaturaId)
|
||||
const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId)
|
||||
|
||||
const handleAddManual = (cita: string) => {
|
||||
const newEntry: BibliografiaEntry = { id: `manual-${Date.now()}`, tipo: newEntryType, cita };
|
||||
setEntries([...entries, newEntry]);
|
||||
setIsAddDialogOpen(false);
|
||||
//toast.success('Referencia manual añadida');
|
||||
};
|
||||
// --- 3. Estados de UI (Solo para diálogos y edición) ---
|
||||
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const handleAddFromLibrary = (resource: any, tipo: 'BASICA' | 'COMPLEMENTARIA') => {
|
||||
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`;
|
||||
const newEntry: BibliografiaEntry = {
|
||||
id: `lib-ref-${Date.now()}`,
|
||||
tipo,
|
||||
cita,
|
||||
fuenteBibliotecaId: resource.id,
|
||||
fuenteBiblioteca: resource,
|
||||
};
|
||||
setEntries([...entries, newEntry]);
|
||||
setIsLibraryDialogOpen(false);
|
||||
//toast.success('Añadido desde biblioteca');
|
||||
};
|
||||
console.log('Datos actuales en el front:', bibliografia)
|
||||
// --- 4. Derivación de datos (Se calculan en cada render) ---
|
||||
const basicaEntries = bibliografia.filter((e) => e.tipo === 'BASICA')
|
||||
const complementariaEntries = bibliografia.filter(
|
||||
(e) => e.tipo === 'COMPLEMENTARIA',
|
||||
)
|
||||
|
||||
const handleUpdateCita = (id: string, cita: string) => {
|
||||
setEntries(entries.map(e => e.id === id ? { ...e, cita } : e));
|
||||
};
|
||||
// --- Handlers Conectados a la Base de Datos ---
|
||||
|
||||
const handleAddFromLibrary = (
|
||||
resource: any,
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA',
|
||||
) => {
|
||||
const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.`
|
||||
crearBibliografia(
|
||||
{
|
||||
asignatura_id: asignaturaId,
|
||||
tipo,
|
||||
cita,
|
||||
tipo_fuente: 'BIBLIOTECA',
|
||||
biblioteca_item_id: resource.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => setIsLibraryDialogOpen(false),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleUpdateCita = (id: string, nuevaCita: string) => {
|
||||
actualizarBibliografia(
|
||||
{
|
||||
id,
|
||||
updates: { cita: nuevaCita },
|
||||
},
|
||||
{
|
||||
onSuccess: () => setEditingId(null),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const onConfirmDelete = () => {
|
||||
if (deleteId) {
|
||||
eliminarBibliografia(deleteId, {
|
||||
onSuccess: () => setDeleteId(null),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading)
|
||||
return <div className="p-10 text-center">Cargando bibliografía...</div>
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-10 space-y-8 animate-in fade-in duration-500">
|
||||
<div className="animate-in fade-in mx-auto max-w-5xl space-y-8 py-10 duration-500">
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-900 tracking-tight">Bibliografía</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
{basicaEntries.length} básica • {complementariaEntries.length} complementaria
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||
Bibliografía
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{basicaEntries.length} básica • {complementariaEntries.length}{' '}
|
||||
complementaria
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog open={isLibraryDialogOpen} onOpenChange={setIsLibraryDialogOpen}>
|
||||
<Dialog
|
||||
open={isLibraryDialogOpen}
|
||||
onOpenChange={setIsLibraryDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="border-blue-200 text-blue-700 hover:bg-blue-50">
|
||||
<Library className="w-4 h-4 mr-2" /> Buscar en biblioteca
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-blue-200 text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
<Library className="mr-2 h-4 w-4" /> Buscar en biblioteca
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<LibrarySearchDialog onSelect={handleAddFromLibrary} existingIds={entries.map(e => e.fuenteBibliotecaId || '')} />
|
||||
<LibrarySearchDialog
|
||||
// CORRECCIÓN: Usamos 'bibliografia' en lugar de 'bibliografia2'
|
||||
resources={[]} // Aquí deberías pasar el catálogo general, no la bibliografía de la asignatura
|
||||
onSelect={handleAddFromLibrary}
|
||||
// CORRECCIÓN: Usamos 'bibliografia' en lugar de 'entries'
|
||||
existingIds={bibliografia.map(
|
||||
(e) => e.biblioteca_item_id || '',
|
||||
)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline"><Plus className="w-4 h-4 mr-2" /> Añadir manual</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<AddManualDialog tipo={newEntryType} onTypeChange={setNewEntryType} onAdd={handleAddManual} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Button onClick={() => onSave(entries)} disabled={isSaving} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Save className="w-4 h-4 mr-2" /> {isSaving ? 'Guardando...' : 'Guardar'}
|
||||
<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>
|
||||
@@ -133,14 +180,16 @@ export function BibliographyItem({ bibliografia, onSave, isSaving }: Bibliografi
|
||||
{/* BASICA */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-1 bg-blue-600 rounded-full" />
|
||||
<h3 className="font-semibold text-slate-800">Bibliografía Básica</h3>
|
||||
<div className="h-4 w-1 rounded-full bg-blue-600" />
|
||||
<h3 className="font-semibold text-slate-800">
|
||||
Bibliografía Básica
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{basicaEntries.map(entry => (
|
||||
<BibliografiaCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
{basicaEntries.map((entry) => (
|
||||
<BibliografiaCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isEditing={editingId === entry.id}
|
||||
onEdit={() => setEditingId(entry.id)}
|
||||
onStopEditing={() => setEditingId(null)}
|
||||
@@ -154,14 +203,16 @@ export function BibliographyItem({ bibliografia, onSave, isSaving }: Bibliografi
|
||||
{/* COMPLEMENTARIA */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-1 bg-slate-400 rounded-full" />
|
||||
<h3 className="font-semibold text-slate-800">Bibliografía Complementaria</h3>
|
||||
<div className="h-4 w-1 rounded-full bg-slate-400" />
|
||||
<h3 className="font-semibold text-slate-800">
|
||||
Bibliografía Complementaria
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{complementariaEntries.map(entry => (
|
||||
<BibliografiaCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
{complementariaEntries.map((entry) => (
|
||||
<BibliografiaCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isEditing={editingId === entry.id}
|
||||
onEdit={() => setEditingId(entry.id)}
|
||||
onStopEditing={() => setEditingId(null)}
|
||||
@@ -177,115 +228,175 @@ export function BibliographyItem({ bibliografia, onSave, isSaving }: Bibliografi
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>¿Eliminar referencia?</AlertDialogTitle>
|
||||
<AlertDialogDescription>La referencia será quitada del plan de estudios.</AlertDialogDescription>
|
||||
<AlertDialogDescription>
|
||||
La referencia será quitada del plan de estudios.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => { setEntries(entries.filter(e => e.id !== deleteId)); setDeleteId(null); }} className="bg-red-600">Eliminar</AlertDialogAction>
|
||||
<AlertDialogAction onClick={onConfirmDelete} className="bg-red-600">
|
||||
Eliminar
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// --- Subcomponentes ---
|
||||
|
||||
function BibliografiaCard({ entry, isEditing, onEdit, onStopEditing, onUpdateCita, onDelete }: any) {
|
||||
const [localCita, setLocalCita] = useState(entry.cita);
|
||||
function BibliografiaCard({
|
||||
entry,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onStopEditing,
|
||||
onUpdateCita,
|
||||
onDelete,
|
||||
}: any) {
|
||||
const [localCita, setLocalCita] = useState(entry.cita)
|
||||
|
||||
return (
|
||||
<Card className={cn("group transition-all hover:shadow-md", isEditing && "ring-2 ring-blue-500")}>
|
||||
<Card
|
||||
className={cn(
|
||||
'group transition-all hover:shadow-md',
|
||||
isEditing && 'ring-2 ring-blue-500',
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<BookOpen className={cn("w-5 h-5 mt-1", entry.tipo === 'BASICA' ? "text-blue-600" : "text-slate-400")} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<BookOpen
|
||||
className={cn(
|
||||
'mt-1 h-5 w-5',
|
||||
entry.tipo === 'BASICA' ? 'text-blue-600' : 'text-slate-400',
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea value={localCita} onChange={(e) => setLocalCita(e.target.value)} className="min-h-[80px]" />
|
||||
<Textarea
|
||||
value={localCita}
|
||||
onChange={(e) => setLocalCita(e.target.value)}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onStopEditing}>Cancelar</Button>
|
||||
<Button size="sm" className="bg-emerald-600" onClick={() => { onUpdateCita(entry.id, localCita); onStopEditing(); }}>Guardar</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onStopEditing}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-emerald-600"
|
||||
onClick={() => {
|
||||
onUpdateCita(entry.id, localCita)
|
||||
onStopEditing()
|
||||
}}
|
||||
>
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div onClick={onEdit} className="cursor-pointer">
|
||||
<p className="text-sm leading-relaxed text-slate-700">{entry.cita}</p>
|
||||
<p className="text-sm leading-relaxed text-slate-700">
|
||||
{entry.cita}
|
||||
</p>
|
||||
{entry.fuenteBiblioteca && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Badge variant="secondary" className="text-[10px] bg-slate-100 text-slate-600">Biblioteca</Badge>
|
||||
{entry.fuenteBiblioteca.disponible && <Badge className="text-[10px] bg-emerald-50 text-emerald-700 border-emerald-100">Disponible</Badge>}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-slate-100 text-[10px] text-slate-600"
|
||||
>
|
||||
Biblioteca
|
||||
</Badge>
|
||||
{entry.fuenteBiblioteca.disponible && (
|
||||
<Badge className="border-emerald-100 bg-emerald-50 text-[10px] text-emerald-700">
|
||||
Disponible
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<div className="flex opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-600" onClick={onEdit}><Edit3 className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-500" onClick={onDelete}><Trash2 className="w-4 h-4" /></Button>
|
||||
<div className="flex opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-600"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-500"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
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 uppercase text-slate-500">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 uppercase text-slate-500">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({ onSelect, existingIds }: any) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA');
|
||||
const filtered = mockLibraryResources.filter(r =>
|
||||
!existingIds.includes(r.id) && r.titulo.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
function LibrarySearchDialog({ resources, onSelect, existingIds }: any) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA')
|
||||
const filtered = (resources || []).filter(
|
||||
(r: any) =>
|
||||
!existingIds.includes(r.id) &&
|
||||
r.titulo?.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
console.log(filtered)
|
||||
console.log(resources)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-2">
|
||||
<DialogHeader><DialogTitle>Catálogo de Biblioteca</DialogTitle></DialogHeader>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Catálogo de Biblioteca</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Buscar por título o autor..." className="pl-10" />
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar por título o autor..."
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={tipo} onValueChange={(v:any) => setTipo(v)}><SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="BASICA">Básica</SelectItem><SelectItem value="COMPLEMENTARIA">Complem.</SelectItem></SelectContent>
|
||||
<Select value={tipo} onValueChange={(v: any) => setTipo(v)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="BASICA">Básica</SelectItem>
|
||||
<SelectItem value="COMPLEMENTARIA">Complem.</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto pr-2 space-y-2">
|
||||
{filtered.map(res => (
|
||||
<div key={res.id} onClick={() => onSelect(res, tipo)} className="p-3 border rounded-lg hover:bg-slate-50 cursor-pointer flex justify-between items-center group">
|
||||
<div className="max-h-[300px] space-y-2 overflow-y-auto pr-2">
|
||||
{filtered.map((res: any) => (
|
||||
<div
|
||||
key={res.id}
|
||||
onClick={() => onSelect(res, tipo)}
|
||||
className="group flex cursor-pointer items-center justify-between rounded-lg border p-3 hover:bg-slate-50"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-700">{res.titulo}</p>
|
||||
<p className="text-sm font-semibold text-slate-700">
|
||||
{res.titulo}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{res.autor}</p>
|
||||
</div>
|
||||
<Plus className="w-4 h-4 text-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<Plus className="h-4 w-4 text-blue-600 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { FileText, Download, RefreshCw, Calendar, FileCheck, AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FileCheck, Download, RefreshCw, Loader2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -13,75 +11,78 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { DocumentoMateria, Materia, MateriaStructure } from '@/types/materia';
|
||||
import { cn } from '@/lib/utils';
|
||||
//import { toast } from 'sonner';
|
||||
//import { format } from 'date-fns';
|
||||
//import { es } from 'date-fns/locale';
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
|
||||
interface DocumentoSEPTabProps {
|
||||
documento: DocumentoMateria | null;
|
||||
materia: Materia;
|
||||
estructura: MateriaStructure;
|
||||
datosGenerales: Record<string, any>;
|
||||
onRegenerate: () => void;
|
||||
isRegenerating: boolean;
|
||||
pdfUrl: string | null
|
||||
isLoading: boolean
|
||||
onDownload: () => void
|
||||
onRegenerate: () => void
|
||||
isRegenerating: boolean
|
||||
}
|
||||
|
||||
export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales, onRegenerate, isRegenerating }: DocumentoSEPTabProps) {
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
// Check completeness
|
||||
const camposObligatorios = estructura.campos.filter(c => c.obligatorio);
|
||||
const camposCompletos = camposObligatorios.filter(c => datosGenerales[c.id]?.trim());
|
||||
const completeness = Math.round((camposCompletos.length / camposObligatorios.length) * 100);
|
||||
const isComplete = completeness === 100;
|
||||
export function DocumentoSEPTab({
|
||||
pdfUrl,
|
||||
isLoading,
|
||||
onDownload,
|
||||
onRegenerate,
|
||||
isRegenerating,
|
||||
}: DocumentoSEPTabProps) {
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
||||
|
||||
const handleRegenerate = () => {
|
||||
setShowConfirmDialog(false);
|
||||
onRegenerate();
|
||||
//toast.success('Regenerando documento...');
|
||||
};
|
||||
setShowConfirmDialog(false)
|
||||
onRegenerate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="animate-fade-in space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<FileCheck className="w-6 h-6 text-accent" />
|
||||
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
|
||||
<FileCheck className="text-accent h-6 w-6" />
|
||||
Documento SEP
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Previsualización del documento oficial para la SEP
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Previsualización del documento oficial generado
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{documento?.estado === 'listo' && (
|
||||
<Button variant="outline" onClick={() => console.log("descargando") /*toast.info('Descarga iniciada')*/}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{pdfUrl && !isLoading && (
|
||||
<Button variant="outline" onClick={onDownload}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Descargar
|
||||
</Button>
|
||||
)}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
|
||||
<AlertDialog
|
||||
open={showConfirmDialog}
|
||||
onOpenChange={setShowConfirmDialog}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button disabled={isRegenerating || !isComplete}>
|
||||
<Button disabled={isRegenerating}>
|
||||
{isRegenerating ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Se creará una nueva versión del documento con los datos actuales de la materia.
|
||||
La versión anterior quedará en el historial.
|
||||
Se generará una nueva versión del documento con la información
|
||||
actual.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleRegenerate}>
|
||||
@@ -93,229 +94,24 @@ export function DocumentoSEPTab({ documento, materia, estructura, datosGenerales
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Document preview */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="card-elevated h-[700px] overflow-hidden">
|
||||
{documento?.estado === 'listo' ? (
|
||||
<div className="h-full bg-muted/30 flex flex-col">
|
||||
{/* Simulated document header */}
|
||||
<div className="bg-card border-b p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
<span className="font-medium text-foreground">
|
||||
Programa de Estudios - {materia.clave}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline">Versión {documento.version}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document content simulation */}
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="max-w-2xl mx-auto bg-card rounded-lg shadow-lg p-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center border-b pb-6">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Secretaría de Educación Pública
|
||||
</p>
|
||||
<h1 className="font-display text-2xl font-bold text-primary mb-1">
|
||||
{materia.nombre}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Clave: {materia.clave} | Créditos: {materia.creditos || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Datos de la institución */}
|
||||
<div className="space-y-1 text-sm">
|
||||
<p><strong>Carrera:</strong> {materia.carrera}</p>
|
||||
<p><strong>Facultad:</strong> {materia.facultad}</p>
|
||||
<p><strong>Plan de estudios:</strong> {materia.planNombre}</p>
|
||||
{materia.ciclo && <p><strong>Ciclo:</strong> {materia.ciclo}</p>}
|
||||
</div>
|
||||
|
||||
{/* Campos del documento */}
|
||||
{estructura.campos.map((campo) => {
|
||||
const valor = datosGenerales[campo.id];
|
||||
if (!valor) return null;
|
||||
return (
|
||||
<div key={campo.id} className="space-y-2">
|
||||
<h3 className="font-semibold text-foreground border-b pb-1">
|
||||
{campo.nombre}
|
||||
</h3>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
||||
{valor}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t pt-6 mt-8 text-center text-xs text-muted-foreground">
|
||||
<p>Documento generado el {/*format(documento.fechaGeneracion, "d 'de' MMMM 'de' yyyy", { locale: es })*/}</p>
|
||||
<p className="mt-1">Universidad La Salle</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : documento?.estado === 'generando' ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 mx-auto text-accent animate-spin mb-4" />
|
||||
<p className="text-muted-foreground">Generando documento...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center max-w-sm">
|
||||
<FileText className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
No hay documento generado aún
|
||||
</p>
|
||||
{!isComplete && (
|
||||
<div className="p-4 bg-warning/10 rounded-lg text-sm text-warning-foreground">
|
||||
<AlertTriangle className="w-4 h-4 inline mr-2" />
|
||||
Completa todos los campos obligatorios para generar el documento
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Info sidebar */}
|
||||
<div className="space-y-4">
|
||||
{/* Status */}
|
||||
<Card className="card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Estado del documento</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{documento && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Versión</span>
|
||||
<Badge variant="outline">{documento.version}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Generado</span>
|
||||
<span className="text-sm">
|
||||
{/*format(documento.fechaGeneracion, "d MMM yyyy, HH:mm", { locale: es })*/}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Estado</span>
|
||||
<Badge className={cn(
|
||||
documento.estado === 'listo' && "bg-success text-success-foreground",
|
||||
documento.estado === 'generando' && "bg-info text-info-foreground",
|
||||
documento.estado === 'error' && "bg-destructive text-destructive-foreground"
|
||||
)}>
|
||||
{documento.estado === 'listo' && 'Listo'}
|
||||
{documento.estado === 'generando' && 'Generando'}
|
||||
{documento.estado === 'error' && 'Error'}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Completeness */}
|
||||
<Card className="card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completitud de datos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Campos obligatorios</span>
|
||||
<span className="font-medium">{camposCompletos.length}/{camposObligatorios.length}</span>
|
||||
</div>
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-all duration-500",
|
||||
completeness === 100 ? "bg-success" : "bg-accent"
|
||||
)}
|
||||
style={{ width: `${completeness}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className={cn(
|
||||
"text-xs",
|
||||
completeness === 100 ? "text-success" : "text-muted-foreground"
|
||||
)}>
|
||||
{completeness === 100
|
||||
? 'Todos los campos obligatorios están completos'
|
||||
: `Faltan ${camposObligatorios.length - camposCompletos.length} campos por completar`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Missing fields */}
|
||||
{!isComplete && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Campos faltantes:</p>
|
||||
{camposObligatorios.filter(c => !datosGenerales[c.id]?.trim()).map((campo) => (
|
||||
<div key={campo.id} className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="w-3 h-3 text-warning" />
|
||||
<span className="text-foreground">{campo.nombre}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Requirements */}
|
||||
<Card className="card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Requisitos SEP</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5",
|
||||
datosGenerales['objetivo_general'] ? "bg-success/20" : "bg-muted"
|
||||
)}>
|
||||
{datosGenerales['objetivo_general'] && <Check className="w-3 h-3 text-success" />}
|
||||
</div>
|
||||
<span className="text-muted-foreground">Objetivo general definido</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5",
|
||||
datosGenerales['competencias'] ? "bg-success/20" : "bg-muted"
|
||||
)}>
|
||||
{datosGenerales['competencias'] && <Check className="w-3 h-3 text-success" />}
|
||||
</div>
|
||||
<span className="text-muted-foreground">Competencias especificadas</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center mt-0.5",
|
||||
datosGenerales['evaluacion'] ? "bg-success/20" : "bg-muted"
|
||||
)}>
|
||||
{datosGenerales['evaluacion'] && <Check className="w-3 h-3 text-success" />}
|
||||
</div>
|
||||
<span className="text-muted-foreground">Criterios de evaluación</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{/* PDF Preview */}
|
||||
<Card className="h-[800px] overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin" />
|
||||
</div>
|
||||
) : pdfUrl ? (
|
||||
<iframe
|
||||
src={`${pdfUrl}#toolbar=0`}
|
||||
className="h-full w-full border-none"
|
||||
title="Documento SEP"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center">
|
||||
No se pudo cargar el documento.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Check({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,196 +1,356 @@
|
||||
import { useState } from 'react';
|
||||
import { History, FileText, List, BookMarked, Sparkles, FileCheck, User, Filter, Calendar } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import {
|
||||
History,
|
||||
FileText,
|
||||
List,
|
||||
BookMarked,
|
||||
Sparkles,
|
||||
FileCheck,
|
||||
Filter,
|
||||
Calendar,
|
||||
Loader2,
|
||||
Eye,
|
||||
} from 'lucide-react'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { CambioMateria } from '@/types/materia';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useSubjectHistorial } from '@/data/hooks/useSubjects'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface HistorialTabProps {
|
||||
historial: CambioMateria[];
|
||||
}
|
||||
const tipoConfig: Record<string, { label: string; icon: any; color: string }> =
|
||||
{
|
||||
datos: { label: 'Datos generales', icon: FileText, color: 'text-info' },
|
||||
contenido: {
|
||||
label: 'Contenido temático',
|
||||
icon: List,
|
||||
color: 'text-accent',
|
||||
},
|
||||
bibliografia: {
|
||||
label: 'Bibliografía',
|
||||
icon: BookMarked,
|
||||
color: 'text-success',
|
||||
},
|
||||
ia: { label: 'IA', icon: Sparkles, color: 'text-amber-500' },
|
||||
documento: {
|
||||
label: 'Documento SEP',
|
||||
icon: FileCheck,
|
||||
color: 'text-primary',
|
||||
},
|
||||
}
|
||||
|
||||
const tipoConfig: Record<string, { label: string; icon: React.ComponentType<{ className?: string }>; color: string }> = {
|
||||
datos: { label: 'Datos generales', icon: FileText, color: 'text-info' },
|
||||
contenido: { label: 'Contenido temático', icon: List, color: 'text-accent' },
|
||||
bibliografia: { label: 'Bibliografía', icon: BookMarked, color: 'text-success' },
|
||||
ia: { label: 'IA', icon: Sparkles, color: 'text-amber-500' },
|
||||
documento: { label: 'Documento SEP', icon: FileCheck, color: 'text-primary' },
|
||||
};
|
||||
export function HistorialTab() {
|
||||
const { asignaturaId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId/historial',
|
||||
})
|
||||
// 1. Obtenemos los datos directamente dentro del componente
|
||||
const { data: rawData, isLoading } = useSubjectHistorial(asignaturaId)
|
||||
|
||||
export function HistorialTab({ historial }: HistorialTabProps) {
|
||||
const [filtros, setFiltros] = useState<Set<string>>(new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']));
|
||||
const [filtros, setFiltros] = useState<Set<string>>(
|
||||
new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']),
|
||||
)
|
||||
|
||||
// ESTADOS PARA EL MODAL
|
||||
const [selectedChange, setSelectedChange] = useState<any>(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const RenderValue = ({ value }: { value: any }) => {
|
||||
// 1. Caso: Nulo o vacío
|
||||
if (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
value === 'Sin información previa'
|
||||
) {
|
||||
return (
|
||||
<span className="text-muted-foreground italic">Sin información</span>
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Caso: Es un ARRAY (como tu lista de unidades)
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{value.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-white/50 p-3 shadow-sm"
|
||||
>
|
||||
<RenderValue value={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Caso: Es un OBJETO (como cada unidad con titulo, temas, etc.)
|
||||
if (typeof value === 'object') {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{Object.entries(value).map(([key, val]) => (
|
||||
<div key={key} className="flex flex-col">
|
||||
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<div className="text-sm text-slate-700">
|
||||
{/* Llamada recursiva para manejar lo que haya dentro del valor */}
|
||||
{typeof val === 'object' ? (
|
||||
<div className="mt-1 border-l-2 border-slate-100 pl-2">
|
||||
<RenderValue value={val} />
|
||||
</div>
|
||||
) : (
|
||||
String(val)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Caso: Texto o número simple
|
||||
return <span className="text-sm leading-relaxed">{String(value)}</span>
|
||||
}
|
||||
|
||||
const historialTransformado = useMemo(() => {
|
||||
if (!rawData) return []
|
||||
return rawData.map((item: any) => ({
|
||||
id: item.id,
|
||||
tipo: item.campo === 'contenido_tematico' ? 'contenido' : 'datos',
|
||||
descripcion: `Se actualizó el campo ${item.campo.replace('_', ' ')}`,
|
||||
fecha: parseISO(item.cambiado_en),
|
||||
usuario: item.fuente === 'HUMANO' ? 'Usuario Staff' : 'Sistema IA',
|
||||
detalles: {
|
||||
campo: item.campo,
|
||||
valor_anterior: item.valor_anterior || 'Sin datos previos', // Asumiendo que existe en tu API
|
||||
valor_nuevo: item.valor_nuevo,
|
||||
},
|
||||
}))
|
||||
}, [rawData])
|
||||
|
||||
const openCompareModal = (cambio: any) => {
|
||||
setSelectedChange(cambio)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const toggleFiltro = (tipo: string) => {
|
||||
const newFiltros = new Set(filtros);
|
||||
if (newFiltros.has(tipo)) {
|
||||
newFiltros.delete(tipo);
|
||||
} else {
|
||||
newFiltros.add(tipo);
|
||||
}
|
||||
setFiltros(newFiltros);
|
||||
};
|
||||
const newFiltros = new Set(filtros)
|
||||
if (newFiltros.has(tipo)) newFiltros.delete(tipo)
|
||||
else newFiltros.add(tipo)
|
||||
setFiltros(newFiltros)
|
||||
}
|
||||
|
||||
const filteredHistorial = historial.filter(cambio => filtros.has(cambio.tipo));
|
||||
// 3. Aplicamos filtros y agrupamiento sobre los datos transformados
|
||||
const filteredHistorial = historialTransformado.filter((cambio) =>
|
||||
filtros.has(cambio.tipo),
|
||||
)
|
||||
|
||||
// Group by date
|
||||
const groupedHistorial = filteredHistorial.reduce((groups, cambio) => {
|
||||
const dateKey = format(cambio.fecha, 'yyyy-MM-dd');
|
||||
if (!groups[dateKey]) {
|
||||
groups[dateKey] = [];
|
||||
}
|
||||
groups[dateKey].push(cambio);
|
||||
return groups;
|
||||
}, {} as Record<string, CambioMateria[]>);
|
||||
const groupedHistorial = filteredHistorial.reduce(
|
||||
(groups, cambio) => {
|
||||
const dateKey = format(cambio.fecha, 'yyyy-MM-dd')
|
||||
if (!groups[dateKey]) groups[dateKey] = []
|
||||
groups[dateKey].push(cambio)
|
||||
return groups
|
||||
},
|
||||
{} as Record<string, Array<any>>,
|
||||
)
|
||||
|
||||
const sortedDates = Object.keys(groupedHistorial).sort((a, b) => b.localeCompare(a));
|
||||
const sortedDates = Object.keys(groupedHistorial).sort((a, b) =>
|
||||
b.localeCompare(a),
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<History className="w-6 h-6 text-accent" />
|
||||
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
|
||||
<History className="text-accent h-6 w-6" />
|
||||
Historial de cambios
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{historial.length} cambios registrados
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{historialTransformado.length} cambios registrados
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dropdown de Filtros (Igual al anterior) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Filtrar ({filtros.size})
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{Object.entries(tipoConfig).map(([tipo, config]) => {
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={tipo}
|
||||
checked={filtros.has(tipo)}
|
||||
onCheckedChange={() => toggleFiltro(tipo)}
|
||||
>
|
||||
<Icon className={cn("w-4 h-4 mr-2", config.color)} />
|
||||
{config.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
{Object.entries(tipoConfig).map(([tipo, config]) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={tipo}
|
||||
checked={filtros.has(tipo)}
|
||||
onCheckedChange={() => toggleFiltro(tipo)}
|
||||
>
|
||||
<config.icon className={cn('mr-2 h-4 w-4', config.color)} />
|
||||
{config.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{filteredHistorial.length === 0 ? (
|
||||
<Card className="card-elevated">
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<History className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{historial.length === 0
|
||||
? 'No hay cambios registrados aún'
|
||||
: 'No hay cambios con los filtros seleccionados'
|
||||
}
|
||||
</p>
|
||||
<History className="text-muted-foreground/50 mx-auto mb-4 h-12 w-12" />
|
||||
<p className="text-muted-foreground">No se encontraron cambios.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{sortedDates.map((dateKey) => {
|
||||
const cambios = groupedHistorial[dateKey];
|
||||
const date = new Date(dateKey);
|
||||
const isToday = format(new Date(), 'yyyy-MM-dd') === dateKey;
|
||||
const isYesterday = format(new Date(Date.now() - 86400000), 'yyyy-MM-dd') === dateKey;
|
||||
{sortedDates.map((dateKey) => (
|
||||
<div key={dateKey}>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<Calendar className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="text-foreground font-semibold">
|
||||
{format(parseISO(dateKey), "EEEE, d 'de' MMMM", {
|
||||
locale: es,
|
||||
})}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div key={dateKey}>
|
||||
{/* Date header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">
|
||||
{isToday ? 'Hoy' : isYesterday ? 'Ayer' : format(date, "EEEE, d 'de' MMMM", { locale: es })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cambios.length} {cambios.length === 1 ? 'cambio' : 'cambios'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="ml-4 border-l-2 border-border pl-6 space-y-4">
|
||||
{cambios.map((cambio) => {
|
||||
const config = tipoConfig[cambio.tipo];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<div key={cambio.id} className="relative">
|
||||
{/* Timeline dot */}
|
||||
<div className={cn(
|
||||
"absolute -left-[31px] w-4 h-4 rounded-full border-2 border-background",
|
||||
`bg-current ${config.color}`
|
||||
)} />
|
||||
|
||||
<Card className="card-interactive">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={cn(
|
||||
"p-2 rounded-lg bg-muted flex-shrink-0",
|
||||
config.color
|
||||
)}>
|
||||
<Icon className="w-4 h-4" />
|
||||
<div className="border-border ml-4 space-y-4 border-l-2 pl-6">
|
||||
{groupedHistorial[dateKey].map((cambio) => {
|
||||
const config = tipoConfig[cambio.tipo] || tipoConfig.datos
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<div key={cambio.id} className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
'border-background absolute -left-[31px] h-4 w-4 rounded-full border-2',
|
||||
`bg-current ${config.color}`,
|
||||
)}
|
||||
/>
|
||||
<Card className="card-interactive">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'bg-muted rounded-lg p-2',
|
||||
config.color,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between">
|
||||
<p className="font-medium">
|
||||
{cambio.descripcion}
|
||||
</p>
|
||||
{/* BOTÓN PARA VER CAMBIOS */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
||||
onClick={() => openCompareModal(cambio)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Ver cambios
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{format(cambio.fecha, 'HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">
|
||||
{cambio.descripcion}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.label}
|
||||
</Badge>
|
||||
{cambio.detalles?.campo && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Campo: {cambio.detalles.campo}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{format(cambio.fecha, 'HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3 text-xs text-muted-foreground">
|
||||
<User className="w-3 h-3" />
|
||||
<span>{cambio.usuario}</span>
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span>
|
||||
{formatDistanceToNow(cambio.fecha, { addSuffix: true, locale: es })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs italic">
|
||||
por {cambio.usuario}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* MODAL DE COMPARACIÓN */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col overflow-hidden">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||
<History className="h-5 w-5 text-blue-500" />
|
||||
Comparación de cambios
|
||||
</DialogTitle>
|
||||
{/* ... info de usuario y fecha */}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="custom-scrollbar mt-4 flex-1 overflow-y-auto pr-2">
|
||||
<div className="grid h-full grid-cols-2 gap-6">
|
||||
{/* Lado Antes */}
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white pb-2">
|
||||
<div className="h-2 w-2 rounded-full bg-red-400" />
|
||||
<span className="text-xs font-bold text-slate-500 uppercase">
|
||||
Versión Anterior
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 rounded-xl border border-red-100 bg-red-50/30 p-4">
|
||||
<RenderValue
|
||||
value={selectedChange?.detalles.valor_anterior}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lado Después */}
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white pb-2">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
<span className="text-xs font-bold text-slate-500 uppercase">
|
||||
Nueva Versión
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 rounded-xl border border-emerald-100 bg-emerald-50/40 p-4">
|
||||
<RenderValue value={selectedChange?.detalles.valor_nuevo} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-shrink-0 items-center justify-center gap-2 rounded-lg border border-slate-100 bg-slate-50 p-3 text-xs text-slate-500">
|
||||
Campo modificado:{' '}
|
||||
<Badge variant="secondary">{selectedChange?.detalles.campo}</Badge>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
795
src/components/asignaturas/detalle/IAAsignaturaTab.tsx
Normal file
795
src/components/asignaturas/detalle/IAAsignaturaTab.tsx
Normal file
@@ -0,0 +1,795 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import {
|
||||
Sparkles,
|
||||
Send,
|
||||
Target,
|
||||
UserCheck,
|
||||
Lightbulb,
|
||||
FileText,
|
||||
GraduationCap,
|
||||
BookOpen,
|
||||
Check,
|
||||
X,
|
||||
MessageSquarePlus,
|
||||
Archive,
|
||||
History,
|
||||
Edit2, // Agregado
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
|
||||
import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps'
|
||||
|
||||
import type { IASugerencia } from '@/types/asignatura'
|
||||
|
||||
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Drawer, DrawerContent } from '@/components/ui/drawer'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
useAISubjectChat,
|
||||
useConversationBySubject,
|
||||
useMessagesBySubjectChat,
|
||||
useSubject,
|
||||
useUpdateSubjectConversationName,
|
||||
useUpdateSubjectConversationStatus,
|
||||
} from '@/data'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SelectedField {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface IAAsignaturaTabProps {
|
||||
asignatura?: Record<string, any>
|
||||
onAcceptSuggestion: (sugerencia: IASugerencia) => void
|
||||
onRejectSuggestion: (messageId: string) => void
|
||||
}
|
||||
|
||||
export function IAAsignaturaTab({
|
||||
onAcceptSuggestion,
|
||||
onRejectSuggestion,
|
||||
}: IAAsignaturaTabProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { asignaturaId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
|
||||
// --- ESTADOS ---
|
||||
const [activeChatId, setActiveChatId] = useState<string | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// --- DATA QUERIES ---
|
||||
const { data: datosGenerales } = useSubject(asignaturaId)
|
||||
const { data: todasConversaciones, isLoading: loadingConv } =
|
||||
useConversationBySubject(asignaturaId)
|
||||
const { data: rawMessages } = useMessagesBySubjectChat(activeChatId, {
|
||||
enabled: !!activeChatId,
|
||||
})
|
||||
const { mutateAsync: sendMessage } = useAISubjectChat()
|
||||
const { mutate: updateStatus } = useUpdateSubjectConversationStatus()
|
||||
const [isCreatingNewChat, setIsCreatingNewChat] = useState(false)
|
||||
const hasInitialSelected = useRef(false)
|
||||
const { mutate: updateName } = useUpdateSubjectConversationName()
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [tempName, setTempName] = useState('')
|
||||
const [openIA, setOpenIA] = useState(false)
|
||||
|
||||
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
|
||||
[],
|
||||
)
|
||||
const [selectedRepositorioIds, setSelectedRepositorioIds] = useState<
|
||||
Array<string>
|
||||
>([])
|
||||
const [uploadedFiles, setUploadedFiles] = useState<Array<File>>([])
|
||||
|
||||
// Cálculo del total para el Badge del botón
|
||||
const totalReferencias =
|
||||
selectedArchivoIds.length +
|
||||
selectedRepositorioIds.length +
|
||||
uploadedFiles.length
|
||||
|
||||
const isAiThinking = useMemo(() => {
|
||||
if (isSending) return true
|
||||
if (!rawMessages || rawMessages.length === 0) return false
|
||||
|
||||
// Verificamos si el último mensaje está en estado de procesamiento
|
||||
const lastMessage = rawMessages[rawMessages.length - 1]
|
||||
return (
|
||||
lastMessage.estado === 'PROCESANDO' || lastMessage.estado === 'PENDIENTE'
|
||||
)
|
||||
}, [isSending, rawMessages])
|
||||
|
||||
// --- AUTO-SCROLL ---
|
||||
useEffect(() => {
|
||||
const viewport = scrollRef.current?.querySelector(
|
||||
'[data-radix-scroll-area-viewport]',
|
||||
)
|
||||
if (viewport) {
|
||||
viewport.scrollTop = viewport.scrollHeight
|
||||
}
|
||||
}, [rawMessages, isSending])
|
||||
|
||||
// --- FILTRADO DE CHATS ---
|
||||
const { activeChats, archivedChats } = useMemo(() => {
|
||||
const chats = todasConversaciones || []
|
||||
return {
|
||||
activeChats: chats.filter((c: any) => c.estado === 'ACTIVA'),
|
||||
archivedChats: chats.filter((c: any) => c.estado === 'ARCHIVADA'),
|
||||
}
|
||||
}, [todasConversaciones])
|
||||
|
||||
const availableFields = useMemo(() => {
|
||||
// 1. Obtenemos los campos dinámicos de la DB
|
||||
const dynamicFields = datosGenerales?.datos
|
||||
? Object.keys(datosGenerales.datos).map((key) => {
|
||||
const estructuraProps =
|
||||
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
|
||||
return {
|
||||
key,
|
||||
label:
|
||||
estructuraProps[key]?.title ||
|
||||
key.replace(/_/g, ' ').toUpperCase(),
|
||||
value: String(datosGenerales.datos[key] || ''),
|
||||
}
|
||||
})
|
||||
: []
|
||||
|
||||
// 2. Definimos tus campos manuales (hardcoded)
|
||||
const hardcodedFields = [
|
||||
{
|
||||
key: 'contenido_tematico',
|
||||
label: 'Contenido temático',
|
||||
value: '', // Puedes dejarlo vacío o buscarlo en datosGenerales si existiera
|
||||
},
|
||||
{
|
||||
key: 'criterios_de_evaluacion',
|
||||
label: 'Criterios de evaluación',
|
||||
value: '',
|
||||
},
|
||||
]
|
||||
|
||||
// 3. Unimos ambos, filtrando duplicados por si acaso el backend ya los envía
|
||||
const combined = [...dynamicFields]
|
||||
|
||||
hardcodedFields.forEach((hf) => {
|
||||
if (!combined.some((f) => f.key === hf.key)) {
|
||||
combined.push(hf)
|
||||
}
|
||||
})
|
||||
|
||||
return combined
|
||||
}, [datosGenerales])
|
||||
|
||||
// --- PROCESAMIENTO DE MENSAJES ---
|
||||
// --- PROCESAMIENTO DE MENSAJES ---
|
||||
const messages = useMemo(() => {
|
||||
const msgs: Array<any> = []
|
||||
|
||||
// 1. Mensajes existentes de la DB
|
||||
if (rawMessages) {
|
||||
rawMessages.forEach((m) => {
|
||||
// Mensaje del usuario
|
||||
msgs.push({ id: `${m.id}-user`, role: 'user', content: m.mensaje })
|
||||
|
||||
// Respuesta de la IA (si existe)
|
||||
if (m.respuesta) {
|
||||
const sugerencias =
|
||||
m.propuesta?.recommendations?.map((rec: any, index: number) => ({
|
||||
id: `${m.id}-sug-${index}`,
|
||||
messageId: m.id,
|
||||
campoKey: rec.campo_afectado,
|
||||
campoNombre: rec.campo_afectado.replace(/_/g, ' '),
|
||||
valorSugerido: rec.texto_mejora,
|
||||
aceptada: rec.aplicada,
|
||||
})) || []
|
||||
|
||||
msgs.push({
|
||||
id: `${m.id}-ai`,
|
||||
role: 'assistant',
|
||||
content: m.respuesta,
|
||||
sugerencias: sugerencias,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 2. INYECCIÓN OPTIMISTA: Si estamos enviando, mostramos el texto actual del input como mensaje de usuario
|
||||
if (isSending && input.trim()) {
|
||||
msgs.push({
|
||||
id: 'optimistic-user-msg',
|
||||
role: 'user',
|
||||
content: input,
|
||||
})
|
||||
}
|
||||
|
||||
return msgs
|
||||
}, [rawMessages, isSending, input])
|
||||
|
||||
// Auto-selección inicial
|
||||
useEffect(() => {
|
||||
// Si ya hay un chat, o si el usuario ya interactuó (hasInitialSelected), abortamos.
|
||||
if (activeChatId || hasInitialSelected.current) return
|
||||
|
||||
if (activeChats.length > 0 && !loadingConv) {
|
||||
setActiveChatId(activeChats[0].id)
|
||||
hasInitialSelected.current = true
|
||||
}
|
||||
}, [activeChats, loadingConv])
|
||||
|
||||
const filteredFields = useMemo(() => {
|
||||
if (!showSuggestions) return availableFields
|
||||
|
||||
// Extraemos lo que hay después del último ':' para filtrar
|
||||
const lastColonIndex = input.lastIndexOf(':')
|
||||
const query = input.slice(lastColonIndex + 1).toLowerCase()
|
||||
|
||||
return availableFields.filter(
|
||||
(f) =>
|
||||
f.label.toLowerCase().includes(query) ||
|
||||
f.key.toLowerCase().includes(query),
|
||||
)
|
||||
}, [availableFields, input, showSuggestions])
|
||||
|
||||
// 2. Efecto para cerrar con ESC
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setShowSuggestions(false)
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// 3. Función para insertar el campo y limpiar el prompt
|
||||
const handleSelectField = (field: SelectedField) => {
|
||||
// 1. Agregamos al array de objetos (para tu lógica de API)
|
||||
if (!selectedFields.find((f) => f.key === field.key)) {
|
||||
setSelectedFields((prev) => [...prev, field])
|
||||
}
|
||||
|
||||
// 2. Lógica de autocompletado en el texto
|
||||
const lastColonIndex = input.lastIndexOf(':')
|
||||
if (lastColonIndex !== -1) {
|
||||
// Tomamos lo que había antes del ":" + el Nombre del Campo + un espacio
|
||||
const nuevoTexto = input.slice(0, lastColonIndex) + `${field.label} `
|
||||
setInput(nuevoTexto)
|
||||
}
|
||||
|
||||
// 3. Cerramos el buscador y devolvemos el foco al textarea
|
||||
setShowSuggestions(false)
|
||||
|
||||
// Opcional: Si tienes una ref del textarea, puedes hacer:
|
||||
// textareaRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleSaveName = (id: string) => {
|
||||
if (tempName.trim()) {
|
||||
updateName({ id, nombre: tempName })
|
||||
}
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const handleSend = async (promptOverride?: string) => {
|
||||
const text = promptOverride || input
|
||||
if (!text.trim() && selectedFields.length === 0) return
|
||||
|
||||
setIsSending(true)
|
||||
try {
|
||||
const response = await sendMessage({
|
||||
subjectId: asignaturaId as any, // Importante: se usa para crear la conv si activeChatId es undefined
|
||||
content: text,
|
||||
campos: selectedFields.map((f) => f.key),
|
||||
conversacionId: activeChatId, // Si es undefined, la mutación crea el chat automáticamente
|
||||
})
|
||||
|
||||
// IMPORTANTE: Después de la respuesta, actualizamos el ID activo con el que creó el backend
|
||||
if (response.conversacionId) {
|
||||
setActiveChatId(response.conversacionId)
|
||||
}
|
||||
|
||||
setInput('')
|
||||
// setSelectedFields([])
|
||||
|
||||
// Invalidamos la lista de conversaciones para que el nuevo chat aparezca en el historial (panel izquierdo)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['conversation-by-subject', asignaturaId],
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error al enviar mensaje:', error)
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleField = (field: SelectedField) => {
|
||||
setSelectedFields((prev) =>
|
||||
prev.find((f) => f.key === field.key)
|
||||
? prev.filter((f) => f.key !== field.key)
|
||||
: [...prev, field],
|
||||
)
|
||||
}
|
||||
|
||||
const createNewChat = () => {
|
||||
setActiveChatId(undefined) // Al ser undefined, el próximo mensaje creará la charla en el backend
|
||||
setInput('')
|
||||
setSelectedFields([])
|
||||
// Opcional: podrías forzar el foco al textarea aquí con una ref
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
{
|
||||
id: 'mejorar-obj',
|
||||
label: 'Mejorar objetivo',
|
||||
icon: Target,
|
||||
prompt: 'Mejora la redacción del objetivo...',
|
||||
},
|
||||
{
|
||||
id: 'sugerir-cont',
|
||||
label: 'Sugerir contenido',
|
||||
icon: BookOpen,
|
||||
prompt: 'Genera un desglose de temas...',
|
||||
},
|
||||
{
|
||||
id: 'actividades',
|
||||
label: 'Actividades',
|
||||
icon: GraduationCap,
|
||||
prompt: 'Sugiere actividades prácticas...',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
|
||||
{/* PANEL IZQUIERDO */}
|
||||
<div className="flex w-64 flex-col border-r pr-4">
|
||||
<div className="mb-4 flex items-center justify-between px-2">
|
||||
<h2 className="flex items-center gap-2 text-xs font-bold text-slate-500 uppercase">
|
||||
<History size={14} /> Historial
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-8 w-8',
|
||||
showArchived && 'bg-teal-50 text-teal-600',
|
||||
)}
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
>
|
||||
<Archive size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveChatId(undefined)
|
||||
hasInitialSelected.current = true
|
||||
setIsCreatingNewChat(true)
|
||||
setInput('')
|
||||
setSelectedFields([])
|
||||
|
||||
// 4. Opcional: Limpiar el caché de mensajes actual para que la pantalla se vea vacía al instante
|
||||
queryClient.setQueryData(['subject-messages', undefined], [])
|
||||
}}
|
||||
variant="outline"
|
||||
className="mb-4 w-full justify-start gap-2 border-dashed border-slate-300 hover:border-teal-500"
|
||||
>
|
||||
<MessageSquarePlus size={18} /> Nuevo Chat
|
||||
</Button>
|
||||
|
||||
{/* PANEL IZQUIERDO - Cambios en ScrollArea y contenedor */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="flex flex-col gap-1 pr-3">
|
||||
{' '}
|
||||
{/* Eliminado space-y-1 para mejor control con gap */}
|
||||
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={cn(
|
||||
// Agregamos 'overflow-hidden' para que nada salga de este cuadro
|
||||
'group relative flex w-full min-w-0 items-center justify-between gap-2 overflow-hidden rounded-lg px-3 py-2 text-sm transition-all',
|
||||
activeChatId === chat.id
|
||||
? 'bg-teal-50 text-teal-900'
|
||||
: 'text-slate-600 hover:bg-slate-100',
|
||||
)}
|
||||
onDoubleClick={() => {
|
||||
setEditingId(chat.id)
|
||||
setTempName(chat.nombre || chat.titulo || 'Conversacion')
|
||||
}}
|
||||
>
|
||||
{editingId === chat.id ? (
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
|
||||
value={tempName}
|
||||
onChange={(e) => setTempName(e.target.value)}
|
||||
onBlur={() => handleSaveName(chat.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveName(chat.id)
|
||||
if (e.key === 'Escape') setEditingId(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* CLAVE 2: 'truncate' y 'min-w-0' en el span para que ceda ante los botones */}
|
||||
<span
|
||||
onClick={() => setActiveChatId(chat.id)}
|
||||
className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1"
|
||||
title={chat.nombre || chat.titulo}
|
||||
>
|
||||
{chat.nombre || chat.titulo || 'Conversación'}
|
||||
</span>
|
||||
|
||||
{/* CLAVE 3: 'shrink-0' asegura que los botones NUNCA desaparezcan */}
|
||||
<div
|
||||
className={cn(
|
||||
'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100',
|
||||
activeChatId === chat.id
|
||||
? 'bg-teal-50'
|
||||
: 'bg-slate-100',
|
||||
)}
|
||||
>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingId(chat.id)
|
||||
setTempName(chat.nombre || chat.titulo || '')
|
||||
}}
|
||||
className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-[10px]">
|
||||
Editar nombre
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const nuevoEstado =
|
||||
chat.estado === 'ACTIVA'
|
||||
? 'ARCHIVADA'
|
||||
: 'ACTIVA'
|
||||
updateStatus({
|
||||
id: chat.id,
|
||||
estado: nuevoEstado,
|
||||
})
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md p-1 transition-colors hover:bg-slate-200',
|
||||
chat.estado === 'ACTIVA'
|
||||
? 'hover:text-red-500'
|
||||
: 'hover:text-teal-600',
|
||||
)}
|
||||
>
|
||||
{chat.estado === 'ACTIVA' ? (
|
||||
<Archive size={14} />
|
||||
) : (
|
||||
<History size={14} className="scale-x-[-1]" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-[10px]">
|
||||
{chat.estado === 'ACTIVA'
|
||||
? 'Archivar'
|
||||
: 'Desarchivar'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* PANEL CENTRAL */}
|
||||
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
|
||||
<div className="flex shrink-0 items-center justify-between border-b bg-white p-3">
|
||||
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
|
||||
Asistente IA
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setOpenIA(true)}
|
||||
className="flex items-center gap-2 rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium transition hover:bg-slate-200"
|
||||
>
|
||||
<FileText size={14} className="text-slate-500" />
|
||||
Referencias
|
||||
{totalReferencias > 0 && (
|
||||
<span className="animate-in zoom-in flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
|
||||
{totalReferencias}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<ScrollArea ref={scrollRef} className="h-full w-full">
|
||||
<div className="mx-auto max-w-3xl space-y-8 p-6">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={cn(
|
||||
'flex gap-4',
|
||||
msg.role === 'user' ? 'flex-row-reverse' : 'flex-row',
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
className={cn(
|
||||
'h-9 w-9 shrink-0 border shadow-sm',
|
||||
msg.role === 'assistant'
|
||||
? 'bg-teal-600 text-white'
|
||||
: 'bg-slate-100',
|
||||
)}
|
||||
>
|
||||
<AvatarFallback>
|
||||
{msg.role === 'assistant' ? (
|
||||
<Sparkles size={16} />
|
||||
) : (
|
||||
<UserCheck size={16} />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex max-w-[85%] flex-col gap-3',
|
||||
msg.role === 'user' ? 'items-end' : 'items-start',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-2xl border shadow-sm',
|
||||
msg.role === 'user'
|
||||
? 'rounded-tr-none border-teal-700 bg-teal-600 px-4 py-3 text-white'
|
||||
: 'w-full rounded-tl-none border-slate-200 bg-white text-slate-800',
|
||||
)}
|
||||
>
|
||||
{/* Texto del mensaje principal */}
|
||||
<div
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
className={cn(
|
||||
'text-sm leading-relaxed',
|
||||
msg.role === 'assistant' && 'p-4',
|
||||
)}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
|
||||
{/* CONTENEDOR DE SUGERENCIAS INTEGRADO */}
|
||||
{msg.role === 'assistant' &&
|
||||
msg.sugerencias &&
|
||||
msg.sugerencias.length > 0 && (
|
||||
<div className="space-y-3 border-t bg-slate-50/50 p-3">
|
||||
<p className="mb-1 text-[10px] font-bold text-slate-400 uppercase">
|
||||
Mejoras disponibles:
|
||||
</p>
|
||||
{msg.sugerencias.map((sug: any) => (
|
||||
<ImprovementCard
|
||||
key={sug.id}
|
||||
sug={sug}
|
||||
asignaturaId={asignaturaId}
|
||||
onApplied={(campoFinalizado) => {
|
||||
// Filtramos el array para conservar todos MENOS el que se aplicó
|
||||
console.log(campoFinalizado)
|
||||
console.log('campos:', selectedFields)
|
||||
|
||||
setSelectedFields((prev) =>
|
||||
prev.filter((fieldObj) => {
|
||||
// Accedemos a .key porque fieldObj es { key: "...", label: "..." }
|
||||
return fieldObj.key !== campoFinalizado
|
||||
}),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isAiThinking && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
|
||||
<Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
|
||||
<AvatarFallback>
|
||||
<Sparkles size={16} className="animate-pulse" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex gap-1">
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span>
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]"></span>
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-slate-400 italic">
|
||||
La IA está analizando tu solicitud...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Espacio extra al final para que el scroll no tape el último mensaje */}
|
||||
<div className="h-4" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* INPUT */}
|
||||
<div className="shrink-0 border-t bg-white p-4">
|
||||
<div className="relative mx-auto max-w-4xl">
|
||||
{showSuggestions && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full left-0 z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
|
||||
<div className="flex justify-between border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
|
||||
<span>Filtrando campos...</span>
|
||||
<span className="rounded bg-slate-200 px-1 text-[9px] text-slate-400">
|
||||
ESC para cerrar
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto p-1">
|
||||
{filteredFields.length > 0 ? (
|
||||
filteredFields.map((field) => (
|
||||
<button
|
||||
key={field.key}
|
||||
onClick={() => handleSelectField(field)}
|
||||
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-slate-700">
|
||||
{field.label}
|
||||
</span>
|
||||
</div>
|
||||
{selectedFields.find((f) => f.key === field.key) && (
|
||||
<Check size={14} className="text-teal-600" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-xs text-slate-400 italic">
|
||||
No se encontraron coincidencias
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
|
||||
{selectedFields.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 px-2 pt-1">
|
||||
{selectedFields.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-50 px-2 py-0.5 text-[11px] font-bold text-teal-700 shadow-sm"
|
||||
>
|
||||
<Target size={10} />
|
||||
{field.label}
|
||||
<button
|
||||
onClick={() => toggleField(field)}
|
||||
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200/50"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value)
|
||||
if (e.target.value.endsWith(':')) setShowSuggestions(true)
|
||||
else if (showSuggestions && !e.target.value.includes(':'))
|
||||
setShowSuggestions(false)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder='Escribe ":" para referenciar un campo...'
|
||||
className="max-h-[120px] min-h-[40px] flex-1 resize-none border-none bg-transparent text-sm shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={
|
||||
(!input.trim() && selectedFields.length === 0) || isSending
|
||||
}
|
||||
size="icon"
|
||||
className="h-9 w-9 bg-teal-600 hover:bg-teal-700"
|
||||
>
|
||||
<Send size={16} className="text-white" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PANEL DERECHO ACCIONES */}
|
||||
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
|
||||
<h4 className="flex items-center gap-2 text-sm font-bold text-slate-800">
|
||||
<Lightbulb size={18} className="text-orange-500" /> Atajos
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => handleSend(preset.prompt)}
|
||||
className="group flex w-full items-center gap-3 rounded-xl border bg-white p-3 text-left text-sm transition-all hover:border-teal-500 hover:bg-teal-50"
|
||||
>
|
||||
<div className="rounded-lg bg-slate-100 p-2 group-hover:bg-teal-100 group-hover:text-teal-600">
|
||||
<preset.icon size={16} />
|
||||
</div>
|
||||
<span className="font-medium text-slate-700">{preset.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* --- DRAWER DE REFERENCIAS --- */}
|
||||
<Drawer open={openIA} onOpenChange={setOpenIA}>
|
||||
<DrawerContent className="fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3">
|
||||
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
|
||||
Referencias para la IA
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setOpenIA(false)}
|
||||
className="text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<ReferenciasParaIA
|
||||
selectedArchivoIds={selectedArchivoIds}
|
||||
selectedRepositorioIds={selectedRepositorioIds}
|
||||
uploadedFiles={uploadedFiles}
|
||||
onToggleArchivo={(id, checked) => {
|
||||
setSelectedArchivoIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((a) => a !== id),
|
||||
)
|
||||
}}
|
||||
onToggleRepositorio={(id, checked) => {
|
||||
setSelectedRepositorioIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((r) => r !== id),
|
||||
)
|
||||
}}
|
||||
onFilesChange={(files) => setUploadedFiles(files)}
|
||||
/>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Sparkles, Bot, User, Check, X, RefreshCw, Lightbulb, Wand2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import type { IAMessage, IASugerencia, CampoEstructura } from '@/types/materia';
|
||||
import { cn } from '@/lib/utils';
|
||||
//import { toast } from 'sonner';
|
||||
|
||||
interface IAMateriaTabProps {
|
||||
campos: CampoEstructura[];
|
||||
datosGenerales: Record<string, any>;
|
||||
messages: IAMessage[];
|
||||
onSendMessage: (message: string, campoId?: string) => void;
|
||||
onAcceptSuggestion: (sugerencia: IASugerencia) => void;
|
||||
onRejectSuggestion: (messageId: string) => void;
|
||||
}
|
||||
|
||||
const quickActions = [
|
||||
{ id: 'mejorar-objetivos', label: 'Mejorar objetivos', icon: Wand2, prompt: 'Mejora el :objetivo_general para que sea más específico y medible' },
|
||||
{ id: 'generar-contenido', label: 'Generar contenido temático', icon: Lightbulb, prompt: 'Sugiere un contenido temático completo basado en los objetivos y competencias' },
|
||||
{ id: 'alinear-perfil', label: 'Alinear con perfil de egreso', icon: RefreshCw, prompt: 'Revisa las :competencias y alinéalas con el perfil de egreso del plan' },
|
||||
{ id: 'ajustar-biblio', label: 'Recomendar bibliografía', icon: Sparkles, prompt: 'Recomienda bibliografía actualizada basándote en el contenido temático' },
|
||||
];
|
||||
|
||||
export function IAMateriaTab({ campos, datosGenerales, messages, onSendMessage, onAcceptSuggestion, onRejectSuggestion }: IAMateriaTabProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showFieldSelector, setShowFieldSelector] = useState(false);
|
||||
const [fieldSelectorPosition, setFieldSelectorPosition] = useState({ top: 0, left: 0 });
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const pos = e.target.selectionStart;
|
||||
setInput(value);
|
||||
setCursorPosition(pos);
|
||||
|
||||
// Check for : character to trigger field selector
|
||||
const lastChar = value.charAt(pos - 1);
|
||||
if (lastChar === ':') {
|
||||
const rect = textareaRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
setFieldSelectorPosition({ top: rect.bottom + 8, left: rect.left });
|
||||
setShowFieldSelector(true);
|
||||
}
|
||||
} else if (showFieldSelector && (lastChar === ' ' || !value.includes(':'))) {
|
||||
setShowFieldSelector(false);
|
||||
}
|
||||
};
|
||||
|
||||
const insertFieldMention = (campoId: string) => {
|
||||
const beforeCursor = input.slice(0, cursorPosition);
|
||||
const afterCursor = input.slice(cursorPosition);
|
||||
const lastColonIndex = beforeCursor.lastIndexOf(':');
|
||||
const newInput = beforeCursor.slice(0, lastColonIndex) + `:${campoId}` + afterCursor;
|
||||
setInput(newInput);
|
||||
setShowFieldSelector(false);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
// Extract field mention if any
|
||||
const fieldMatch = input.match(/:(\w+)/);
|
||||
const campoId = fieldMatch ? fieldMatch[1] : undefined;
|
||||
|
||||
setIsLoading(true);
|
||||
onSendMessage(input, campoId);
|
||||
setInput('');
|
||||
|
||||
// Simulate AI response delay
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleQuickAction = (prompt: string) => {
|
||||
setInput(prompt);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const renderMessageContent = (content: string) => {
|
||||
// Render field mentions as styled badges
|
||||
return content.split(/(:[\w_]+)/g).map((part, i) => {
|
||||
if (part.startsWith(':')) {
|
||||
const campo = campos.find(c => c.id === part.slice(1));
|
||||
return (
|
||||
<span key={i} className="field-mention mx-0.5">
|
||||
{campo?.nombre || part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-display text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6 text-accent" />
|
||||
IA de la materia
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Usa <kbd className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">:</kbd> para mencionar campos específicos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Chat area */}
|
||||
<Card className="lg:col-span-2 card-elevated flex flex-col h-[600px]">
|
||||
<CardHeader className="pb-2 border-b">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Conversación
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col p-0">
|
||||
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
|
||||
<div className="space-y-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Bot className="w-12 h-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
Inicia una conversación para mejorar tu materia con IA
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div key={message.id} className={cn(
|
||||
"flex gap-3",
|
||||
message.role === 'user' ? "justify-end" : "justify-start"
|
||||
)}>
|
||||
{message.role === 'assistant' && (
|
||||
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(
|
||||
"max-w-[80%] rounded-lg px-4 py-3",
|
||||
message.role === 'user'
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted"
|
||||
)}>
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{renderMessageContent(message.content)}
|
||||
</p>
|
||||
{message.sugerencia && !message.sugerencia.aceptada && (
|
||||
<div className="mt-3 p-3 bg-background/80 rounded-md border">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Sugerencia para: {message.sugerencia.campoNombre}
|
||||
</p>
|
||||
<div className="text-sm text-foreground bg-accent/10 p-2 rounded mb-3 max-h-32 overflow-y-auto">
|
||||
{message.sugerencia.valorSugerido}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onAcceptSuggestion(message.sugerencia!)}
|
||||
className="bg-success hover:bg-success/90 text-success-foreground"
|
||||
>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
Aplicar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onRejectSuggestion(message.id)}
|
||||
>
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
Rechazar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{message.sugerencia?.aceptada && (
|
||||
<Badge className="mt-2 badge-library">
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
Sugerencia aplicada
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{message.role === 'user' && (
|
||||
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-4 h-4 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||
<Bot className="w-4 h-4 text-accent animate-pulse" />
|
||||
</div>
|
||||
<div className="bg-muted rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-bounce [animation-delay:-0.3s]" />
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-bounce [animation-delay:-0.15s]" />
|
||||
<div className="w-2 h-2 bg-accent rounded-full animate-bounce" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="p-4 border-t">
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Escribe tu mensaje... Usa : para mencionar campos"
|
||||
className="min-h-[80px] pr-12 resize-none"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="absolute bottom-3 right-3 h-8 w-8 p-0"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Field selector popover */}
|
||||
{showFieldSelector && (
|
||||
<div className="absolute z-50 mt-1 w-64 bg-popover border rounded-lg shadow-lg">
|
||||
<Command>
|
||||
<CommandInput placeholder="Buscar campo..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No se encontró el campo</CommandEmpty>
|
||||
<CommandGroup heading="Campos disponibles">
|
||||
{campos.map((campo) => (
|
||||
<CommandItem
|
||||
key={campo.id}
|
||||
value={campo.id}
|
||||
onSelect={() => insertFieldMention(campo.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="font-mono text-xs text-accent mr-2">
|
||||
:{campo.id}
|
||||
</span>
|
||||
<span>{campo.nombre}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sidebar with quick actions and fields */}
|
||||
<div className="space-y-4">
|
||||
{/* Quick actions */}
|
||||
<Card className="card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Acciones rápidas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{quickActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left h-auto py-3"
|
||||
onClick={() => handleQuickAction(action.prompt)}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-2 text-accent flex-shrink-0" />
|
||||
<span className="text-sm">{action.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Available fields */}
|
||||
<Card className="card-elevated">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Campos de la materia</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[280px]">
|
||||
<div className="space-y-2">
|
||||
{campos.map((campo) => {
|
||||
const hasValue = !!datosGenerales[campo.id];
|
||||
return (
|
||||
<div
|
||||
key={campo.id}
|
||||
className={cn(
|
||||
"p-2 rounded-md border cursor-pointer transition-colors hover:bg-muted/50",
|
||||
hasValue ? "border-success/30" : "border-warning/30"
|
||||
)}
|
||||
onClick={() => {
|
||||
setInput(prev => prev + `:${campo.id} `);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-mono text-accent">:{campo.id}</span>
|
||||
{hasValue ? (
|
||||
<Badge variant="outline" className="text-xs text-success border-success/30">
|
||||
Completo
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-warning border-warning/30">
|
||||
Vacío
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-foreground mt-1">{campo.nombre}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
ArrowLeft,
|
||||
GraduationCap,
|
||||
Edit2, Save,
|
||||
Pencil
|
||||
} from 'lucide-react'
|
||||
import { ContenidoTematico } from './ContenidoTematico'
|
||||
import { BibliographyItem } from './BibliographyItem'
|
||||
import { IAMateriaTab } from './IAMateriaTab'
|
||||
import type {
|
||||
CampoEstructura,
|
||||
IAMessage,
|
||||
IASugerencia,
|
||||
UnidadTematica,
|
||||
} from '@/types/materia';
|
||||
import {
|
||||
mockMateria,
|
||||
mockEstructura,
|
||||
mockDocumentoSep,
|
||||
mockHistorial
|
||||
} from '@/data/mockMateriaData';
|
||||
import { DocumentoSEPTab } from './DocumentoSEPTab'
|
||||
import { HistorialTab } from './HistorialTab'
|
||||
|
||||
export interface BibliografiaEntry {
|
||||
id: string;
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA';
|
||||
cita: string;
|
||||
fuenteBibliotecaId?: string;
|
||||
fuenteBiblioteca?: any;
|
||||
}
|
||||
export interface BibliografiaTabProps {
|
||||
bibliografia: BibliografiaEntry[];
|
||||
onSave: (bibliografia: BibliografiaEntry[]) => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export default function MateriaDetailPage() {
|
||||
|
||||
// 1. Asegúrate de tener estos estados en tu componente principal
|
||||
const [messages, setMessages] = useState<IAMessage[]>([]);
|
||||
const [datosGenerales, setDatosGenerales] = useState({});
|
||||
const [campos, setCampos] = useState<CampoEstructura[]>([]);
|
||||
|
||||
// 2. Funciones de manejo para la IA
|
||||
const handleSendMessage = (text: string, campoId?: string) => {
|
||||
const newMessage: IAMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date(),
|
||||
campoAfectado: campoId
|
||||
};
|
||||
setMessages([...messages, newMessage]);
|
||||
|
||||
// Aquí llamarías a tu API de OpenAI/Claude
|
||||
//toast.info("Enviando consulta a la IA...");
|
||||
};
|
||||
|
||||
const handleAcceptSuggestion = (sugerencia: IASugerencia) => {
|
||||
// Lógica para actualizar el valor del campo en tu estado de datosGenerales
|
||||
//toast.success(`Sugerencia aplicada a ${sugerencia.campoNombre}`);
|
||||
};
|
||||
|
||||
// Dentro de tu componente principal (donde están los Tabs)
|
||||
const [bibliografia, setBibliografia] = useState<BibliografiaEntry[]>([
|
||||
{
|
||||
id: '1',
|
||||
tipo: 'BASICA',
|
||||
cita: 'Russell, S., & Norvig, P. (2020). Artificial Intelligence: A Modern Approach. Pearson.'
|
||||
}
|
||||
]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSaveBibliografia = (data: BibliografiaEntry[]) => {
|
||||
setIsSaving(true);
|
||||
// Aquí iría tu llamada a la API
|
||||
setBibliografia(data);
|
||||
|
||||
// Simulamos un guardado
|
||||
setTimeout(() => {
|
||||
setIsSaving(false);
|
||||
//toast.success("Cambios guardados");
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
|
||||
const handleRegenerateDocument = useCallback(() => {
|
||||
setIsRegenerating(true);
|
||||
setTimeout(() => {
|
||||
setIsRegenerating(false);
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* ================= HEADER ================= */}
|
||||
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
|
||||
<div className="max-w-7xl mx-auto px-6 py-10">
|
||||
<Link
|
||||
to="/planes"
|
||||
className="flex items-center gap-2 text-sm text-blue-200 hover:text-white mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Volver al plan
|
||||
</Link>
|
||||
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="space-y-3">
|
||||
<Badge className="bg-blue-900/50 border border-blue-700">
|
||||
IA-401
|
||||
</Badge>
|
||||
|
||||
<h1 className="text-3xl font-bold">
|
||||
Inteligencia Artificial Aplicada
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm text-blue-200">
|
||||
<span className="flex items-center gap-1">
|
||||
<GraduationCap className="w-4 h-4" />
|
||||
Ingeniería en Sistemas Computacionales
|
||||
</span>
|
||||
|
||||
<span>Facultad de Ingeniería</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-blue-300">
|
||||
Pertenece al plan:{' '}
|
||||
<span className="underline cursor-pointer">
|
||||
Licenciatura en Ingeniería en Sistemas Computacionales 2024
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 items-end">
|
||||
<Badge variant="secondary">8 créditos</Badge>
|
||||
<Badge variant="secondary">7° semestre</Badge>
|
||||
<Badge variant="secondary">Sistemas Inteligentes</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ================= TABS ================= */}
|
||||
<section className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<Tabs defaultValue="datos">
|
||||
<TabsList className="h-auto bg-transparent p-0 gap-6">
|
||||
<TabsTrigger value="datos">Datos generales</TabsTrigger>
|
||||
<TabsTrigger value="contenido">Contenido temático</TabsTrigger>
|
||||
<TabsTrigger value="bibliografia">Bibliografía</TabsTrigger>
|
||||
<TabsTrigger value="ia">IA de la materia</TabsTrigger>
|
||||
<TabsTrigger value="sep">Documento SEP</TabsTrigger>
|
||||
<TabsTrigger value="historial">Historial</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Separator className="mt-2" />
|
||||
|
||||
{/* ================= TAB: DATOS GENERALES ================= */}
|
||||
<TabsContent value="datos">
|
||||
<DatosGenerales />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="contenido">
|
||||
<ContenidoTematico></ContenidoTematico>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bibliografia">
|
||||
<BibliographyItem
|
||||
bibliografia={bibliografia}
|
||||
onSave={handleSaveBibliografia}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ia">
|
||||
<IAMateriaTab
|
||||
campos={campos}
|
||||
datosGenerales={datosGenerales}
|
||||
messages={messages}
|
||||
onSendMessage={handleSendMessage}
|
||||
onAcceptSuggestion={handleAcceptSuggestion}
|
||||
onRejectSuggestion={(id) => console.log("Rechazada") /*toast.error("Sugerencia rechazada")*/}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sep">
|
||||
<DocumentoSEPTab
|
||||
documento={mockDocumentoSep}
|
||||
materia={mockMateria}
|
||||
estructura={mockEstructura}
|
||||
datosGenerales={datosGenerales}
|
||||
onRegenerate={handleRegenerateDocument}
|
||||
isRegenerating={isRegenerating}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="historial">
|
||||
<HistorialTab historial={mockHistorial} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ================= TAB CONTENT ================= */
|
||||
|
||||
function DatosGenerales() {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4 space-y-8 animate-in fade-in duration-500">
|
||||
|
||||
{/* Encabezado de la Sección */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b pb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">Datos Generales</h2>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Información oficial estructurada bajo los lineamientos de la SEP.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Edit2 className="w-4 h-4" /> Editar borrador
|
||||
</Button>
|
||||
<Button size="sm" className="gap-2 bg-blue-600 hover:bg-blue-700">
|
||||
<Save className="w-4 h-4" /> Guardar cambios
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid de Información */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
{/* Columna Principal (Más ancha) */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<InfoCard
|
||||
title="Competencias a Desarrollar"
|
||||
subtitle="Competencias profesionales que se desarrollarán"
|
||||
isList={true}
|
||||
initialContent={`• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes\n• Evaluar y optimizar modelos de IA considerando métricas`}
|
||||
/>
|
||||
|
||||
<InfoCard
|
||||
title="Objetivo General"
|
||||
initialContent="Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<InfoCard
|
||||
title="Justificación"
|
||||
initialContent="La inteligencia artificial es una de las tecnologías más disruptivas..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Columna Lateral (Información Secundaria) */}
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
{/* Tarjeta de Requisitos */}
|
||||
<InfoCard
|
||||
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" }
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Tarjeta de Evaluación */}
|
||||
<InfoCard
|
||||
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%" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface InfoCardProps {
|
||||
title: string,
|
||||
subtitle?: string
|
||||
isList?:boolean
|
||||
initialContent: any // Puede ser string o array de objetos
|
||||
type?: 'text' | 'list' | 'requirements' | 'evaluation'
|
||||
}
|
||||
|
||||
function InfoCard({ title, initialContent, type = 'text' }: InfoCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [data, setData] = useState(initialContent)
|
||||
// Estado temporal para el área de texto (siempre editamos como texto por simplicidad)
|
||||
const [tempText, setTempText] = useState(
|
||||
type === 'text' || type === 'list'
|
||||
? initialContent
|
||||
: JSON.stringify(initialContent, null, 2) // O un formato legible
|
||||
)
|
||||
|
||||
const handleSave = () => {
|
||||
// Aquí podrías parsear el texto de vuelta si es necesario
|
||||
setData(tempText)
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="transition-all hover:border-slate-300">
|
||||
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0">
|
||||
<CardTitle className="text-sm font-bold text-slate-700">{title}</CardTitle>
|
||||
{!isEditing && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400" onClick={() => setIsEditing(true)}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={tempText}
|
||||
onChange={(e) => setTempText(e.target.value)}
|
||||
className="text-xs min-h-[100px]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setIsEditing(false)}>Cancelar</Button>
|
||||
<Button size="sm" className="bg-[#00a878]" onClick={handleSave}>Guardar</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm">
|
||||
{type === 'requirements' && <RequirementsView items={data} />}
|
||||
{type === 'evaluation' && <EvaluationView items={data} />}
|
||||
{type === 'text' && <p className="text-slate-600">{data}</p>}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Vista de Requisitos
|
||||
function RequirementsView({ items }: { items: any[] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((req, i) => (
|
||||
<div key={i} className="p-3 bg-slate-50 rounded-lg border border-slate-100">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-tight">{req.type}</p>
|
||||
<p className="text-sm font-medium text-slate-700">{req.code} {req.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Vista de Evaluación
|
||||
function EvaluationView({ items }: { items: any[] }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex justify-between text-sm border-b border-slate-50 pb-1.5 italic">
|
||||
<span className="text-slate-500">{item.label}</span>
|
||||
<span className="font-bold text-blue-600">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
function EmptyTab({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
{title} (pendiente)
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Check, Loader2, BookOpen, Clock, ListChecks } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { IASugerencia } from '@/types/asignatura'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
useUpdateAsignatura,
|
||||
useSubject,
|
||||
useUpdateSubjectRecommendation,
|
||||
} from '@/data'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ImprovementCardProps {
|
||||
sug: IASugerencia
|
||||
asignaturaId: string
|
||||
onApplied: (campoKey: string) => void
|
||||
}
|
||||
|
||||
export function ImprovementCard({
|
||||
sug,
|
||||
asignaturaId,
|
||||
onApplied,
|
||||
}: ImprovementCardProps) {
|
||||
const { data: asignatura } = useSubject(asignaturaId)
|
||||
const updateAsignatura = useUpdateAsignatura()
|
||||
const updateRecommendation = useUpdateSubjectRecommendation()
|
||||
|
||||
const [isApplying, setIsApplying] = useState(false)
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!asignatura) return
|
||||
|
||||
setIsApplying(true)
|
||||
try {
|
||||
// 1. Identificar a qué columna debe ir el guardado
|
||||
let patchData = {}
|
||||
|
||||
if (sug.campoKey === 'contenido_tematico') {
|
||||
// Se guarda directamente en la columna contenido_tematico
|
||||
patchData = { contenido_tematico: sug.valorSugerido }
|
||||
} else if (sug.campoKey === 'criterios_de_evaluacion') {
|
||||
// Se guarda directamente en la columna criterios_de_evaluacion
|
||||
patchData = { criterios_de_evaluacion: sug.valorSugerido }
|
||||
} else {
|
||||
// Otros campos (ciclo, fines, etc.) se siguen guardando en el JSON de la columna 'datos'
|
||||
patchData = {
|
||||
datos: {
|
||||
...asignatura.datos,
|
||||
[sug.campoKey]: sug.valorSugerido,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Ejecutar la actualización con la estructura correcta
|
||||
await updateAsignatura.mutateAsync({
|
||||
asignaturaId: asignaturaId as any,
|
||||
patch: patchData as any,
|
||||
})
|
||||
|
||||
// 3. Marcar la recomendación como aplicada
|
||||
await updateRecommendation.mutateAsync({
|
||||
mensajeId: sug.messageId,
|
||||
campoAfectado: sug.campoKey,
|
||||
})
|
||||
console.log(sug.campoKey)
|
||||
|
||||
onApplied(sug.campoKey)
|
||||
} catch (error) {
|
||||
console.error('Error al aplicar mejora:', error)
|
||||
} finally {
|
||||
setIsApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- FUNCIÓN PARA RENDERIZAR EL CONTENIDO DE FORMA SEGURA ---
|
||||
const renderContenido = (valor: any) => {
|
||||
// Si no es un array, es texto simple
|
||||
if (!Array.isArray(valor)) {
|
||||
return <p className="italic">"{String(valor)}"</p>
|
||||
}
|
||||
|
||||
// --- CASO 1: CONTENIDO TEMÁTICO (Detectamos si el primer objeto tiene 'unidad') ---
|
||||
if (valor[0]?.hasOwnProperty('unidad')) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{valor.map((u: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="rounded-md border border-teal-100 bg-white p-2 shadow-sm"
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 border-b border-slate-50 pb-1 text-[11px] font-bold text-teal-800">
|
||||
<BookOpen size={12} /> Unidad {u.unidad}: {u.titulo}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{u.temas?.map((t: any, tidx: number) => (
|
||||
<li
|
||||
key={tidx}
|
||||
className="flex items-start justify-between gap-2 text-[10px] text-slate-600"
|
||||
>
|
||||
<span className="leading-tight">• {t.nombre}</span>
|
||||
<span className="flex shrink-0 items-center gap-0.5 font-mono text-slate-400">
|
||||
<Clock size={10} /> {t.horasEstimadas}h
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- CASO 2: CRITERIOS DE EVALUACIÓN (Detectamos si tiene 'criterio') ---
|
||||
if (valor[0]?.hasOwnProperty('criterio')) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase">
|
||||
<ListChecks size={12} /> Desglose de evaluación
|
||||
</div>
|
||||
{valor.map((c: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-slate-100 bg-white p-2 shadow-sm"
|
||||
>
|
||||
<span className="text-[11px] leading-tight text-slate-700">
|
||||
{c.criterio}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-full border border-orange-100 bg-orange-50 px-2 py-0.5 text-[10px] font-bold text-orange-600">
|
||||
{c.porcentaje}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Opcional: Suma total para verificar que de 100% */}
|
||||
<div className="pt-1 text-right text-[9px] font-medium text-slate-400">
|
||||
Total:{' '}
|
||||
{valor.reduce(
|
||||
(acc: number, curr: any) => acc + (curr.porcentaje || 0),
|
||||
0,
|
||||
)}
|
||||
%
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Caso por defecto (Array genérico)
|
||||
return (
|
||||
<pre className="text-[10px]">
|
||||
{/* JSON.stringify(valor, null, 2)*/ 'hola'}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
// --- ESTADO APLICADO ---
|
||||
if (sug.aceptada) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-slate-100 bg-white p-3 opacity-80 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-bold text-slate-800">
|
||||
{sug.campoNombre}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-400">
|
||||
<Check size={14} />
|
||||
Aplicado
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-teal-100 bg-teal-50/30 p-3 text-xs leading-relaxed text-slate-500">
|
||||
{renderContenido(sug.valorSugerido)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- ESTADO PENDIENTE ---
|
||||
return (
|
||||
<div className="group flex flex-col rounded-xl border border-teal-100 bg-white p-3 shadow-sm transition-all hover:border-teal-200">
|
||||
<div className="mb-3 flex items-center justify-between gap-4">
|
||||
<span className="max-w-[150px] truncate rounded-lg border border-teal-100 bg-teal-50/50 px-2.5 py-1 text-[10px] font-bold tracking-wider text-teal-700 uppercase">
|
||||
{sug.campoNombre}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isApplying || !asignatura}
|
||||
className="h-8 w-auto bg-teal-600 px-4 text-xs font-semibold shadow-sm hover:bg-teal-700"
|
||||
onClick={handleApply}
|
||||
>
|
||||
{isApplying ? (
|
||||
<Loader2 size={14} className="mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Check size={14} className="mr-1.5" />
|
||||
)}
|
||||
{isApplying ? 'Aplicando...' : 'Aplicar mejora'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-dashed border-slate-200 bg-slate-50/50 p-3 text-xs leading-relaxed text-slate-600',
|
||||
!Array.isArray(sug.valorSugerido) && 'line-clamp-4 italic',
|
||||
)}
|
||||
>
|
||||
{renderContenido(sug.valorSugerido)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import type {
|
||||
NewSubjectWizardState,
|
||||
TipoAsignatura,
|
||||
} from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
ESTRUCTURAS_SEP,
|
||||
TIPOS_MATERIA,
|
||||
} from '@/features/asignaturas/nueva/catalogs'
|
||||
|
||||
export function PasoBasicosForm({
|
||||
wizard,
|
||||
onChange,
|
||||
}: {
|
||||
wizard: NewSubjectWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1 sm:col-span-2">
|
||||
<Label htmlFor="nombre">Nombre de la asignatura</Label>
|
||||
<Input
|
||||
id="nombre"
|
||||
placeholder="Ej. Matemáticas Discretas"
|
||||
value={wizard.datosBasicos.nombre}
|
||||
onChange={(e) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nombre: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="clave">Clave (Opcional)</Label>
|
||||
<Input
|
||||
id="clave"
|
||||
placeholder="Ej. MAT-101"
|
||||
value={wizard.datosBasicos.clave || ''}
|
||||
onChange={(e) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, clave: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="tipo">Tipo</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.tipo}
|
||||
onValueChange={(val) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, tipo: val as TipoAsignatura },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="tipo"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIPOS_MATERIA.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="creditos">Créditos</Label>
|
||||
<Input
|
||||
id="creditos"
|
||||
type="number"
|
||||
min={0}
|
||||
value={wizard.datosBasicos.creditos}
|
||||
onChange={(e) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
creditos: Number(e.target.value || 0),
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="horas">Horas / Semana</Label>
|
||||
<Input
|
||||
id="horas"
|
||||
type="number"
|
||||
min={0}
|
||||
value={wizard.datosBasicos.horasSemana || 0}
|
||||
onChange={(e) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
horasSemana: Number(e.target.value || 0),
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1 sm:col-span-2">
|
||||
<Label htmlFor="estructura">Estructura de la asignatura</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.estructuraId}
|
||||
onValueChange={(val) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, estructuraId: val },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="estructura"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue placeholder="Selecciona plantilla..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ESTRUCTURAS_SEP.map((e) => (
|
||||
<SelectItem key={e.id} value={e.id}>
|
||||
{e.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import PasoSugerenciasForm from './PasoSugerenciasForm'
|
||||
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
import type { Database } from '@/types/supabase'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useSubjectEstructuras } from '@/data'
|
||||
import { TIPOS_MATERIA } from '@/features/asignaturas/nueva/catalogs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function PasoBasicosForm({
|
||||
wizard,
|
||||
onChange,
|
||||
}: {
|
||||
wizard: NewSubjectWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||
}) {
|
||||
const { data: estructuras } = useSubjectEstructuras()
|
||||
|
||||
const [creditosInput, setCreditosInput] = useState<string>(() => {
|
||||
const c = Number(wizard.datosBasicos.creditos ?? 0)
|
||||
let newC = c
|
||||
console.log('antes', newC)
|
||||
|
||||
if (Number.isFinite(c) && c > 999) {
|
||||
newC = 999
|
||||
}
|
||||
console.log('desp', newC)
|
||||
return newC > 0 ? newC.toFixed(2) : ''
|
||||
})
|
||||
const [creditosFocused, setCreditosFocused] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (creditosFocused) return
|
||||
const c = Number(wizard.datosBasicos.creditos ?? 0)
|
||||
let newC = c
|
||||
if (Number.isFinite(c) && c > 999) {
|
||||
newC = 999
|
||||
}
|
||||
setCreditosInput(newC > 0 ? newC.toFixed(2) : '')
|
||||
}, [wizard.datosBasicos.creditos, creditosFocused])
|
||||
|
||||
if (wizard.tipoOrigen !== 'IA_MULTIPLE') {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1 sm:col-span-2">
|
||||
<Label htmlFor="nombre">Nombre de la asignatura</Label>
|
||||
<Input
|
||||
id="nombre"
|
||||
placeholder="Ej. Matemáticas Discretas"
|
||||
maxLength={200}
|
||||
value={wizard.datosBasicos.nombre}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nombre: e.target.value },
|
||||
}),
|
||||
)
|
||||
}
|
||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="codigo">
|
||||
Código
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
(Opcional)
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="codigo"
|
||||
placeholder="Ej. MAT-101"
|
||||
maxLength={200}
|
||||
value={wizard.datosBasicos.codigo || ''}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, codigo: e.target.value },
|
||||
}),
|
||||
)
|
||||
}
|
||||
className="placeholder:text-muted-foreground/70 placeholder:italicplaceholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="tipo">Tipo</Label>
|
||||
<Select
|
||||
value={(wizard.datosBasicos.tipo ?? '') as string}
|
||||
onValueChange={(value: string) =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
tipo: value as NewSubjectWizardState['datosBasicos']['tipo'],
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="tipo"
|
||||
className={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.tipo
|
||||
? 'text-muted-foreground font-normal italic opacity-70'
|
||||
: 'font-medium not-italic',
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Ej. Obligatoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIPOS_MATERIA.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="creditos">Créditos</Label>
|
||||
<Input
|
||||
id="creditos"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
maxLength={6}
|
||||
pattern="^\\d*(?:[.,]\\d{0,2})?$"
|
||||
value={creditosInput}
|
||||
onKeyDown={(e) => {
|
||||
if (['-', 'e', 'E', '+'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onFocus={() => setCreditosFocused(true)}
|
||||
onBlur={() => {
|
||||
setCreditosFocused(false)
|
||||
|
||||
const raw = creditosInput.trim()
|
||||
if (!raw) {
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, creditos: 0 },
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const normalized = raw.replace(',', '.')
|
||||
let asNumber = Number.parseFloat(normalized)
|
||||
if (!Number.isFinite(asNumber) || asNumber <= 0) {
|
||||
setCreditosInput('')
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, creditos: 0 },
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
// Cap to 999
|
||||
if (asNumber > 999) asNumber = 999
|
||||
|
||||
const fixed = asNumber.toFixed(2)
|
||||
setCreditosInput(fixed)
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, creditos: Number(fixed) },
|
||||
}))
|
||||
}}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nextRaw = e.target.value
|
||||
if (nextRaw === '') {
|
||||
setCreditosInput('')
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, creditos: 0 },
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^\d*(?:[.,]\d{0,2})?$/.test(nextRaw)) return
|
||||
|
||||
// If typed number exceeds 999, cap it immediately (prevents entering >999)
|
||||
const asNumberRaw = Number.parseFloat(nextRaw.replace(',', '.'))
|
||||
if (Number.isFinite(asNumberRaw) && asNumberRaw > 999) {
|
||||
// show capped value to the user
|
||||
const cappedStr = '999.00'
|
||||
setCreditosInput(cappedStr)
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
creditos: 999,
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
setCreditosInput(nextRaw)
|
||||
|
||||
const asNumber = Number.parseFloat(nextRaw.replace(',', '.'))
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
creditos:
|
||||
Number.isFinite(asNumber) && asNumber > 0 ? asNumber : 0,
|
||||
},
|
||||
}))
|
||||
}}
|
||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
placeholder="Ej. 4.50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="estructura">Estructura de la asignatura</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.estructuraId as string}
|
||||
onValueChange={(val) =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, estructuraId: val },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="estructura"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue placeholder="Selecciona plantilla..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{estructuras?.map(
|
||||
(
|
||||
e: Database['public']['Tables']['estructuras_asignatura']['Row'],
|
||||
) => (
|
||||
<SelectItem key={e.id} value={e.id}>
|
||||
{e.nombre}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="horasAcademicas">
|
||||
Horas Académicas
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
(Opcional)
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="horasAcademicas"
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={wizard.datosBasicos.horasAcademicas ?? ''}
|
||||
onKeyDown={(e) => {
|
||||
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
horasAcademicas: (() => {
|
||||
const raw = e.target.value
|
||||
if (raw === '') return null
|
||||
const asNumber = Number(raw)
|
||||
if (Number.isNaN(asNumber)) return null
|
||||
// Coerce to positive integer (natural numbers without zero)
|
||||
const n = Math.floor(Math.abs(asNumber))
|
||||
const capped = Math.min(n >= 1 ? n : 1, 999)
|
||||
return capped
|
||||
})(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
placeholder="Ej. 48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="horasIndependientes">
|
||||
Horas Independientes
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
(Opcional)
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="horasIndependientes"
|
||||
type="number"
|
||||
min={1}
|
||||
max={999}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={wizard.datosBasicos.horasIndependientes ?? ''}
|
||||
onKeyDown={(e) => {
|
||||
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
horasIndependientes: (() => {
|
||||
const raw = e.target.value
|
||||
if (raw === '') return null
|
||||
const asNumber = Number(raw)
|
||||
if (Number.isNaN(asNumber)) return null
|
||||
// Coerce to positive integer (natural numbers without zero)
|
||||
const n = Math.floor(Math.abs(asNumber))
|
||||
const capped = Math.min(n >= 1 ? n : 1, 999)
|
||||
return capped
|
||||
})(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
placeholder="Ej. 24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <PasoSugerenciasForm wizard={wizard} onChange={onChange} />
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { RefreshCw, Sparkles, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { generate_subject_suggestions, usePlan } from '@/data'
|
||||
import { AIProgressLoader } from '@/features/asignaturas/nueva/AIProgressLoader'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function PasoSugerenciasForm({
|
||||
wizard,
|
||||
onChange,
|
||||
}: {
|
||||
wizard: NewSubjectWizardState
|
||||
onChange: Dispatch<SetStateAction<NewSubjectWizardState>>
|
||||
}) {
|
||||
const enfoque = wizard.iaMultiple?.enfoque ?? ''
|
||||
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 5
|
||||
const isLoading = wizard.iaMultiple?.isLoading ?? false
|
||||
|
||||
const [showConservacionTooltip, setShowConservacionTooltip] = useState(false)
|
||||
|
||||
const setIaMultiple = (
|
||||
patch: Partial<NonNullable<NewSubjectWizardState['iaMultiple']>>,
|
||||
) =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
iaMultiple: {
|
||||
enfoque: w.iaMultiple?.enfoque ?? '',
|
||||
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
|
||||
isLoading: w.iaMultiple?.isLoading ?? false,
|
||||
...patch,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const { data: plan } = usePlan(wizard.plan_estudio_id)
|
||||
|
||||
const toggleAsignatura = (id: string, checked: boolean) => {
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
sugerencias: w.sugerencias.map((s) =>
|
||||
s.id === id ? { ...s, selected: checked } : s,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
const onGenerarSugerencias = async () => {
|
||||
const hadNoSugerenciasBefore = wizard.sugerencias.length === 0
|
||||
const sugerenciasConservadas = wizard.sugerencias.filter((s) => s.selected)
|
||||
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
errorMessage: null,
|
||||
sugerencias: sugerenciasConservadas,
|
||||
iaMultiple: {
|
||||
enfoque: w.iaMultiple?.enfoque ?? '',
|
||||
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
|
||||
isLoading: true,
|
||||
},
|
||||
}))
|
||||
|
||||
try {
|
||||
const cantidad = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
|
||||
if (!Number.isFinite(cantidad) || cantidad <= 0 || cantidad > 15) {
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
errorMessage: 'La cantidad de sugerencias debe ser entre 1 y 15.',
|
||||
iaMultiple: {
|
||||
enfoque: w.iaMultiple?.enfoque ?? '',
|
||||
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
|
||||
isLoading: false,
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const enfoqueTrim = wizard.iaMultiple?.enfoque.trim() ?? ''
|
||||
|
||||
const nuevasSugerencias = await generate_subject_suggestions({
|
||||
plan_estudio_id: wizard.plan_estudio_id,
|
||||
enfoque: enfoqueTrim ? enfoqueTrim : undefined,
|
||||
cantidad_de_sugerencias: cantidad,
|
||||
sugerencias_conservadas: sugerenciasConservadas.map((s) => ({
|
||||
nombre: s.nombre,
|
||||
descripcion: s.descripcion,
|
||||
})),
|
||||
})
|
||||
|
||||
if (hadNoSugerenciasBefore && nuevasSugerencias.length > 0) {
|
||||
setShowConservacionTooltip(true)
|
||||
}
|
||||
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
sugerencias: [...nuevasSugerencias, ...sugerenciasConservadas],
|
||||
iaMultiple: {
|
||||
enfoque: w.iaMultiple?.enfoque ?? '',
|
||||
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
|
||||
isLoading: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Error generando sugerencias.'
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
errorMessage: message,
|
||||
iaMultiple: {
|
||||
enfoque: w.iaMultiple?.enfoque ?? '',
|
||||
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
|
||||
isLoading: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* --- BLOQUE SUPERIOR: PARÁMETROS --- */}
|
||||
<div className="border-border/60 bg-muted/30 mb-4 rounded-xl border p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Sparkles className="text-primary h-4 w-4" />
|
||||
<span className="text-sm font-semibold">
|
||||
Parámetros de sugerencia
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="w-full">
|
||||
<Label className="text-muted-foreground mb-1 block text-xs">
|
||||
Enfoque (opcional)
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder="Ej. Enfocado en normativa mexicana y tecnología"
|
||||
value={enfoque}
|
||||
maxLength={7000}
|
||||
rows={4}
|
||||
onChange={(e) => setIaMultiple({ enfoque: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex w-full flex-col items-end justify-between gap-3 sm:flex-row">
|
||||
<div className="w-full sm:w-44">
|
||||
<Label className="text-muted-foreground mb-1 block text-xs">
|
||||
Cantidad de sugerencias
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="Ej. 5"
|
||||
value={cantidadDeSugerencias}
|
||||
type="number"
|
||||
min={1}
|
||||
max={15}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
onKeyDown={(e) => {
|
||||
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
if (raw === '') return
|
||||
const asNumber = Number(raw)
|
||||
if (!Number.isFinite(asNumber)) return
|
||||
const n = Math.floor(Math.abs(asNumber))
|
||||
const capped = Math.min(n >= 1 ? n : 1, 15)
|
||||
setIaMultiple({ cantidadDeSugerencias: capped })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 gap-1.5"
|
||||
onClick={onGenerarSugerencias}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{wizard.sugerencias.length > 0
|
||||
? 'Generar más sugerencias'
|
||||
: 'Generar sugerencias'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AIProgressLoader
|
||||
isLoading={isLoading}
|
||||
cantidadDeSugerencias={cantidadDeSugerencias}
|
||||
/>
|
||||
|
||||
{/* --- HEADER LISTA --- */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-foreground text-base font-semibold">
|
||||
Asignaturas sugeridas
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Basadas en el plan{' '}
|
||||
{plan ? `${plan.nivel} en ${plan.nombre}` : '...'}
|
||||
</p>
|
||||
</div>
|
||||
<Tooltip open={showConservacionTooltip}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="bg-muted text-foreground inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-sm font-semibold">
|
||||
<span aria-hidden>📌</span>
|
||||
{wizard.sugerencias.filter((s) => s.selected).length}{' '}
|
||||
seleccionadas
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={8} className="max-w-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="flex-1 text-sm">
|
||||
Al generar más sugerencias, se conservarán las asignaturas
|
||||
seleccionadas.
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => setShowConservacionTooltip(false)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* --- LISTA DE ASIGNATURAS --- */}
|
||||
<div className="max-h-100 space-y-1 overflow-y-auto pr-1">
|
||||
{wizard.sugerencias.map((asignatura) => {
|
||||
const isSelected = asignatura.selected
|
||||
|
||||
return (
|
||||
<Label
|
||||
key={asignatura.id}
|
||||
aria-checked={isSelected}
|
||||
className={cn(
|
||||
'border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAsignatura(asignatura.id, !!checked)
|
||||
}
|
||||
className={cn(
|
||||
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring mt-0.5 h-5 w-5 shrink-0 border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
// isSelected ? '' : 'invisible',
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Contenido de la tarjeta */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{asignatura.nombre}
|
||||
</span>
|
||||
|
||||
{/* Badges de Tipo */}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
||||
asignatura.tipo === 'OBLIGATORIA'
|
||||
? 'border-blue-200 bg-transparent text-blue-700 dark:border-blue-800 dark:text-blue-300'
|
||||
: 'border-yellow-200 bg-transparent text-yellow-700 dark:border-yellow-800 dark:text-yellow-300',
|
||||
)}
|
||||
>
|
||||
{asignatura.tipo}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{asignatura.creditos} cred. · {asignatura.horasAcademicas}h
|
||||
acad. · {asignatura.horasIndependientes}h indep.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{asignatura.descripcion}
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
import * as Icons from 'lucide-react'
|
||||
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
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 { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
ARCHIVOS_SISTEMA_MOCK,
|
||||
FACULTADES,
|
||||
MATERIAS_MOCK,
|
||||
PLANES_MOCK,
|
||||
} from '@/features/asignaturas/nueva/catalogs'
|
||||
|
||||
export function PasoConfiguracionPanel({
|
||||
wizard,
|
||||
onChange,
|
||||
onGenerarIA,
|
||||
}: {
|
||||
wizard: NewSubjectWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||
onGenerarIA: () => void
|
||||
}) {
|
||||
if (wizard.modoCreacion === 'MANUAL') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuración Manual</CardTitle>
|
||||
<CardDescription>
|
||||
La asignatura se creará vacía. Podrás editar el contenido detallado
|
||||
en la siguiente pantalla.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (wizard.modoCreacion === 'IA') {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-1">
|
||||
<Label>Descripción del enfoque</Label>
|
||||
<Textarea
|
||||
placeholder="Ej. Asignatura teórica-práctica enfocada en patrones de diseño..."
|
||||
value={wizard.iaConfig?.descripcionEnfoque}
|
||||
onChange={(e) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...w.iaConfig!,
|
||||
descripcionEnfoque: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="min-h-25"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label>Notas adicionales</Label>
|
||||
<Textarea
|
||||
placeholder="Restricciones, bibliografía sugerida, etc."
|
||||
value={wizard.iaConfig?.notasAdicionales}
|
||||
onChange={(e) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: { ...w.iaConfig!, notasAdicionales: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Archivos de contexto (Opcional)</Label>
|
||||
<div className="flex flex-col gap-2 rounded-md border p-3">
|
||||
{ARCHIVOS_SISTEMA_MOCK.map((file) => (
|
||||
<div key={file.id} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={file.id}
|
||||
checked={wizard.iaConfig?.archivosExistentesIds.includes(
|
||||
file.id,
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...w.iaConfig!,
|
||||
archivosExistentesIds: checked
|
||||
? [
|
||||
...(w.iaConfig?.archivosExistentesIds || []),
|
||||
file.id,
|
||||
]
|
||||
: w.iaConfig?.archivosExistentesIds.filter(
|
||||
(id) => id !== file.id,
|
||||
) || [],
|
||||
},
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={file.id} className="font-normal">
|
||||
{file.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={onGenerarIA} disabled={wizard.isLoading}>
|
||||
{wizard.isLoading ? (
|
||||
<>
|
||||
<Icons.Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Generando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icons.Sparkles className="mr-2 h-4 w-4" /> Generar Preview
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{wizard.resumen.previewAsignatura && (
|
||||
<Card className="bg-muted/50 border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Vista previa generada</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-muted-foreground text-sm">
|
||||
<p>
|
||||
<strong>Objetivo:</strong>{' '}
|
||||
{wizard.resumen.previewAsignatura.objetivo}
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Se detectaron {wizard.resumen.previewAsignatura.unidades}{' '}
|
||||
unidades temáticas y{' '}
|
||||
{wizard.resumen.previewAsignatura.bibliografiaCount} fuentes
|
||||
bibliográficas.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (wizard.subModoClonado === 'INTERNO') {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label>Facultad</Label>
|
||||
<Select
|
||||
onValueChange={(val) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonInterno: { ...w.clonInterno, facultadId: val },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Todas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FACULTADES.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Plan</Label>
|
||||
<Select
|
||||
onValueChange={(val) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonInterno: { ...w.clonInterno, planOrigenId: val },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Todos" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLANES_MOCK.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Buscar</Label>
|
||||
<Input placeholder="Nombre..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid max-h-75 gap-2 overflow-y-auto">
|
||||
{MATERIAS_MOCK.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id },
|
||||
}))
|
||||
}
|
||||
className={`hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 ${
|
||||
wizard.clonInterno?.asignaturaOrigenId === m.id
|
||||
? 'border-primary bg-primary/5 ring-primary ring-1'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{m.nombre}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{m.clave} • {m.creditos} créditos
|
||||
</div>
|
||||
</div>
|
||||
{wizard.clonInterno?.asignaturaOrigenId === m.id && (
|
||||
<Icons.CheckCircle2 className="text-primary h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (wizard.subModoClonado === 'TRADICIONAL') {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
<Icons.Upload className="text-muted-foreground mx-auto mb-4 h-10 w-10" />
|
||||
<h3 className="mb-1 text-sm font-medium">
|
||||
Sube el Word de la asignatura
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4 text-xs">
|
||||
Arrastra el archivo o haz clic para buscar (.doc, .docx)
|
||||
</p>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".doc,.docx"
|
||||
className="mx-auto max-w-xs"
|
||||
onChange={(e) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...w.clonTradicional!,
|
||||
archivoWordAsignaturaId:
|
||||
e.target.files?.[0]?.name || 'mock_file',
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{wizard.clonTradicional?.archivoWordAsignaturaId && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-green-50 p-3 text-sm text-green-700">
|
||||
<Icons.FileText className="h-4 w-4" />
|
||||
Archivo cargado listo para procesar.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
459
src/components/asignaturas/wizard/PasoDetallesPanel.tsx
Normal file
459
src/components/asignaturas/wizard/PasoDetallesPanel.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import * as Icons from 'lucide-react'
|
||||
|
||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
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 { Textarea } from '@/components/ui/textarea'
|
||||
import { usePlan, usePlanLineas, useSubjectEstructuras } from '@/data'
|
||||
import {
|
||||
FACULTADES,
|
||||
MATERIAS_MOCK,
|
||||
PLANES_MOCK,
|
||||
} from '@/features/asignaturas/nueva/catalogs'
|
||||
|
||||
export function PasoDetallesPanel({
|
||||
wizard,
|
||||
onChange,
|
||||
}: {
|
||||
wizard: NewSubjectWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||
}) {
|
||||
const { data: estructurasAsignatura } = useSubjectEstructuras()
|
||||
const { data: plan } = usePlan(wizard.plan_estudio_id)
|
||||
const { data: lineasPlan } = usePlanLineas(wizard.plan_estudio_id)
|
||||
|
||||
if (wizard.tipoOrigen === 'MANUAL') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configuración Manual</CardTitle>
|
||||
<CardDescription>
|
||||
La asignatura se creará vacía. Podrás editar el contenido detallado
|
||||
en la siguiente pantalla.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Descripción del enfoque académico</Label>
|
||||
<Textarea
|
||||
placeholder="Describe el enfoque, alcance y público objetivo. Ej.: Teórica-práctica enfocada en patrones de diseño, con proyectos semanales..."
|
||||
maxLength={7000}
|
||||
value={wizard.iaConfig?.descripcionEnfoqueAcademico}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...w.iaConfig!,
|
||||
descripcionEnfoqueAcademico: e.target.value,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
className="placeholder:text-muted-foreground/70 min-h-25 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>
|
||||
Instrucciones adicionales para la IA
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
(Opcional)
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder="Opcional: restricciones y preferencias. Ej.: incluye bibliografía en español, evita contenido avanzado, prioriza evaluación por proyectos..."
|
||||
maxLength={7000}
|
||||
value={wizard.iaConfig?.instruccionesAdicionalesIA}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...w.iaConfig!,
|
||||
instruccionesAdicionalesIA: e.target.value,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ReferenciasParaIA
|
||||
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
|
||||
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
|
||||
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
|
||||
onToggleArchivo={(id, checked) =>
|
||||
onChange((w): NewSubjectWizardState => {
|
||||
const prev = w.iaConfig?.archivosReferencia || []
|
||||
const next = checked
|
||||
? [...prev, id]
|
||||
: prev.filter((a) => a !== id)
|
||||
return {
|
||||
...w,
|
||||
iaConfig: {
|
||||
...w.iaConfig!,
|
||||
archivosReferencia: next,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
onToggleRepositorio={(id, checked) =>
|
||||
onChange((w): NewSubjectWizardState => {
|
||||
const prev = w.iaConfig?.repositoriosReferencia || []
|
||||
const next = checked
|
||||
? [...prev, id]
|
||||
: prev.filter((r) => r !== id)
|
||||
return {
|
||||
...w,
|
||||
iaConfig: {
|
||||
...w.iaConfig!,
|
||||
repositoriosReferencia: next,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
onFilesChange={(files: Array<UploadedFile>) =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...w.iaConfig!,
|
||||
archivosAdjuntos: files,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
|
||||
const maxCiclos = Math.max(1, plan?.numero_ciclos ?? 1)
|
||||
const sugerenciasSeleccionadas = wizard.sugerencias.filter(
|
||||
(s) => s.selected,
|
||||
)
|
||||
|
||||
const patchSugerencia = (
|
||||
id: string,
|
||||
patch: Partial<NewSubjectWizardState['sugerencias'][number]>,
|
||||
) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
sugerencias: w.sugerencias.map((s) =>
|
||||
s.id === id ? { ...s, ...patch } : s,
|
||||
),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="border-border/60 bg-muted/30 rounded-xl border p-4">
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
Estructura de la asignatura
|
||||
</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.estructuraId ?? undefined}
|
||||
onValueChange={(val) =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
estructuraId: val,
|
||||
datosBasicos: { ...w.datosBasicos, estructuraId: val },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecciona una estructura" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(estructurasAsignatura ?? []).map((e) => (
|
||||
<SelectItem key={e.id} value={e.id}>
|
||||
{e.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border/60 bg-muted/30 rounded-xl border p-4">
|
||||
<h3 className="text-foreground mx-3 mb-2 text-lg font-semibold">
|
||||
Materias seleccionadas
|
||||
</h3>
|
||||
{sugerenciasSeleccionadas.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Selecciona al menos una sugerencia para configurar su descripción,
|
||||
línea curricular y ciclo.
|
||||
</div>
|
||||
) : (
|
||||
<Accordion type="multiple" className="w-full space-y-2">
|
||||
{sugerenciasSeleccionadas.map((asig) => (
|
||||
<AccordionItem
|
||||
key={asig.id}
|
||||
value={asig.id}
|
||||
className="border-border/60 bg-background/40 rounded-lg border border-b-0 px-3"
|
||||
>
|
||||
<AccordionTrigger className="hover:bg-accent/30 data-[state=open]:bg-accent/20 data-[state=open]:text-accent-foreground -mx-3 px-3">
|
||||
{asig.nombre}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground">
|
||||
<div className="mx-1 grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
Descripción
|
||||
</Label>
|
||||
<Textarea
|
||||
value={asig.descripcion}
|
||||
maxLength={7000}
|
||||
rows={6}
|
||||
onChange={(e) =>
|
||||
patchSugerencia(asig.id, {
|
||||
descripcion: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid content-start gap-3">
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
Ciclo (opcional)
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxCiclos}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
placeholder={`1-${maxCiclos}`}
|
||||
value={asig.numero_ciclo ?? ''}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
['.', ',', '-', 'e', 'E', '+'].includes(e.key)
|
||||
) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
if (raw === '') {
|
||||
patchSugerencia(asig.id, { numero_ciclo: null })
|
||||
return
|
||||
}
|
||||
|
||||
const asNumber = Number(raw)
|
||||
if (!Number.isFinite(asNumber)) return
|
||||
|
||||
const n = Math.floor(Math.abs(asNumber))
|
||||
const capped = Math.min(
|
||||
Math.max(n >= 1 ? n : 1, 1),
|
||||
maxCiclos,
|
||||
)
|
||||
|
||||
patchSugerencia(asig.id, { numero_ciclo: capped })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
Línea curricular (opcional)
|
||||
</Label>
|
||||
<Select
|
||||
value={asig.linea_plan_id ?? '__none__'}
|
||||
onValueChange={(val) =>
|
||||
patchSugerencia(asig.id, {
|
||||
linea_plan_id: val === '__none__' ? null : val,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sin línea" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Ninguna</SelectItem>
|
||||
{(lineasPlan ?? []).map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label>Facultad</Label>
|
||||
<Select
|
||||
onValueChange={(val) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonInterno: { ...w.clonInterno, facultadId: val },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Todas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FACULTADES.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Plan</Label>
|
||||
<Select
|
||||
onValueChange={(val) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonInterno: { ...w.clonInterno, planOrigenId: val },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Todos" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLANES_MOCK.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Buscar</Label>
|
||||
<Input placeholder="Nombre..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid max-h-75 gap-2 overflow-y-auto">
|
||||
{MATERIAS_MOCK.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id },
|
||||
}))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id },
|
||||
}))
|
||||
}}
|
||||
className={`hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 ${
|
||||
wizard.clonInterno?.asignaturaOrigenId === m.id
|
||||
? 'border-primary bg-primary/5 ring-primary ring-1'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{m.nombre}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{m.clave} • {m.creditos} créditos
|
||||
</div>
|
||||
</div>
|
||||
{wizard.clonInterno?.asignaturaOrigenId === m.id && (
|
||||
<Icons.CheckCircle2 className="text-primary h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
<Icons.Upload className="text-muted-foreground mx-auto mb-4 h-10 w-10" />
|
||||
<h3 className="mb-1 text-sm font-medium">
|
||||
Sube el Word de la asignatura
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4 text-xs">
|
||||
Arrastra el archivo o haz clic para buscar (.doc, .docx)
|
||||
</p>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".doc,.docx"
|
||||
className="mx-auto max-w-xs"
|
||||
onChange={(e) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
clonTradicional: {
|
||||
...w.clonTradicional!,
|
||||
archivoWordAsignaturaId:
|
||||
e.target.files?.[0]?.name || 'mock_file',
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{wizard.clonTradicional?.archivoWordAsignaturaId && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-green-50 p-3 text-sm text-green-700">
|
||||
<Icons.FileText className="h-4 w-4" />
|
||||
Archivo cargado listo para procesar.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import * as Icons from 'lucide-react'
|
||||
|
||||
import type {
|
||||
ModoCreacion,
|
||||
NewSubjectWizardState,
|
||||
SubModoClonado,
|
||||
} from '@/features/asignaturas/nueva/types'
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -21,19 +17,33 @@ export function PasoMetodoCardGroup({
|
||||
wizard: NewSubjectWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||
}) {
|
||||
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
|
||||
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
|
||||
const isSelected = (modo: NewSubjectWizardState['tipoOrigen']) =>
|
||||
wizard.tipoOrigen === modo
|
||||
const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => {
|
||||
const key = e.key
|
||||
if (
|
||||
key === 'Enter' ||
|
||||
key === ' ' ||
|
||||
key === 'Spacebar' ||
|
||||
key === 'Space'
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card
|
||||
className={isSelected('MANUAL') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
modoCreacion: 'MANUAL',
|
||||
subModoClonado: undefined,
|
||||
}))
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'MANUAL',
|
||||
}),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -51,11 +61,12 @@ export function PasoMetodoCardGroup({
|
||||
<Card
|
||||
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
modoCreacion: 'IA',
|
||||
subModoClonado: undefined,
|
||||
}))
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'IA',
|
||||
}),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -66,11 +77,94 @@ export function PasoMetodoCardGroup({
|
||||
</CardTitle>
|
||||
<CardDescription>Generar contenido automático.</CardDescription>
|
||||
</CardHeader>
|
||||
{(wizard.tipoOrigen === 'IA' ||
|
||||
wizard.tipoOrigen === 'IA_SIMPLE' ||
|
||||
wizard.tipoOrigen === 'IA_MULTIPLE') && (
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'IA_SIMPLE',
|
||||
}),
|
||||
)
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'IA_SIMPLE',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
|
||||
isSelected('IA_SIMPLE')
|
||||
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
|
||||
: 'border-border text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icons.Edit3 className="h-6 w-6 flex-none" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Una asignatura</span>
|
||||
<span className="text-xs opacity-70">
|
||||
Crear una asignatura con control detallado de metadatos.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'IA_MULTIPLE',
|
||||
}),
|
||||
)
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'IA_MULTIPLE',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
|
||||
isSelected('IA_MULTIPLE')
|
||||
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
|
||||
: 'border-border text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icons.List className="h-6 w-6 flex-none" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Varias asignaturas</span>
|
||||
<span className="text-xs opacity-70">
|
||||
Generar varias asignaturas a partir de sugerencias de la IA.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({ ...w, tipoOrigen: 'CLONADO' }),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -80,51 +174,79 @@ export function PasoMetodoCardGroup({
|
||||
</CardTitle>
|
||||
<CardDescription>De otra asignatura o archivo Word.</CardDescription>
|
||||
</CardHeader>
|
||||
{wizard.modoCreacion === 'CLONADO' && (
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' }))
|
||||
}}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
|
||||
isSubSelected('INTERNO')
|
||||
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
|
||||
: 'border-border text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icons.Database className="h-6 w-6 flex-none" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Del sistema</span>
|
||||
<span className="text-xs opacity-70">
|
||||
Buscar en otros planes
|
||||
</span>
|
||||
</div>
|
||||
{(wizard.tipoOrigen === 'CLONADO' ||
|
||||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
||||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'CLONADO_INTERNO',
|
||||
}),
|
||||
)
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'CLONADO_INTERNO',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
|
||||
isSelected('CLONADO_INTERNO')
|
||||
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
|
||||
: 'border-border text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icons.Database className="h-6 w-6 flex-none" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Del sistema</span>
|
||||
<span className="text-xs opacity-70">
|
||||
Buscar en otros planes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' }))
|
||||
}}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
|
||||
isSubSelected('TRADICIONAL')
|
||||
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
|
||||
: 'border-border text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icons.Upload className="h-6 w-6 flex-none" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Desde archivos</span>
|
||||
<span className="text-xs opacity-70">
|
||||
Subir Word existente
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'CLONADO_TRADICIONAL',
|
||||
}),
|
||||
)
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange(
|
||||
(w): NewSubjectWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'CLONADO_TRADICIONAL',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer items-center gap-4 rounded-lg border p-4 text-left transition-all ${
|
||||
isSelected('CLONADO_TRADICIONAL')
|
||||
? 'bg-primary/5 text-primary ring-primary border-primary ring-1'
|
||||
: 'border-border text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icons.Upload className="h-6 w-6 flex-none" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">Desde archivos</span>
|
||||
<span className="text-xs opacity-70">Subir Word existente</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -9,9 +9,45 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { ESTRUCTURAS_SEP } from '@/features/asignaturas/nueva/catalogs'
|
||||
import { usePlan, usePlanLineas, useSubjectEstructuras } from '@/data'
|
||||
import { formatFileSize } from '@/features/planes/utils/format-file-size'
|
||||
|
||||
export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
|
||||
const { data: plan } = usePlan(wizard.plan_estudio_id)
|
||||
const { data: estructuras } = useSubjectEstructuras()
|
||||
const { data: lineasPlan } = usePlanLineas(wizard.plan_estudio_id)
|
||||
|
||||
const estructuraNombre = (() => {
|
||||
const estructuraId = wizard.datosBasicos.estructuraId
|
||||
if (!estructuraId) return '—'
|
||||
const hit = estructuras?.find((e) => e.id === estructuraId)
|
||||
return hit?.nombre ?? estructuraId
|
||||
})()
|
||||
|
||||
const modoLabel = (() => {
|
||||
if (wizard.tipoOrigen === 'MANUAL') return 'Manual (Vacía)'
|
||||
if (wizard.tipoOrigen === 'IA') return 'Generada con IA'
|
||||
if (wizard.tipoOrigen === 'IA_SIMPLE') return 'Generada con IA (Simple)'
|
||||
if (wizard.tipoOrigen === 'IA_MULTIPLE') return 'Generación múltiple (IA)'
|
||||
if (wizard.tipoOrigen === 'CLONADO_INTERNO') return 'Clonada (Sistema)'
|
||||
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') return 'Clonada (Archivo)'
|
||||
return '—'
|
||||
})()
|
||||
|
||||
const creditosText =
|
||||
typeof wizard.datosBasicos.creditos === 'number' &&
|
||||
Number.isFinite(wizard.datosBasicos.creditos)
|
||||
? wizard.datosBasicos.creditos.toFixed(2)
|
||||
: '—'
|
||||
|
||||
const archivosRef = wizard.iaConfig?.archivosReferencia ?? []
|
||||
const repositoriosRef = wizard.iaConfig?.repositoriosReferencia ?? []
|
||||
const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? []
|
||||
|
||||
const materiasSeleccionadas = wizard.sugerencias.filter((s) => s.selected)
|
||||
const iaMultipleEnfoque = wizard.iaMultiple?.enfoque.trim() ?? ''
|
||||
const iaMultipleCantidad = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -20,54 +56,238 @@ export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
|
||||
Verifica los datos antes de crear la asignatura.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Nombre:</span>
|
||||
<div className="font-medium">{wizard.datosBasicos.nombre}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Tipo:</span>
|
||||
<div className="font-medium">{wizard.datosBasicos.tipo}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Créditos:</span>
|
||||
<div className="font-medium">{wizard.datosBasicos.creditos}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Estructura:</span>
|
||||
<div className="font-medium">
|
||||
{
|
||||
ESTRUCTURAS_SEP.find(
|
||||
(e) => e.id === wizard.datosBasicos.estructuraId,
|
||||
)?.label
|
||||
}
|
||||
<CardContent>
|
||||
<div className="grid gap-4 text-sm">
|
||||
<div className="grid gap-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Plan de estudios: </span>
|
||||
<span className="font-medium">
|
||||
{plan?.nombre || wizard.plan_estudio_id || '—'}
|
||||
</span>
|
||||
</div>
|
||||
{plan?.carreras?.nombre ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Carrera: </span>
|
||||
<span className="font-medium">{plan.carreras.nombre}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted rounded-md p-3">
|
||||
<span className="text-muted-foreground">Modo de creación:</span>
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{wizard.modoCreacion === 'MANUAL' && (
|
||||
<>
|
||||
<Icons.Pencil className="h-4 w-4" /> Manual (Vacía)
|
||||
</>
|
||||
)}
|
||||
{wizard.modoCreacion === 'IA' && (
|
||||
<>
|
||||
<Icons.Sparkles className="h-4 w-4" /> Generada con IA
|
||||
</>
|
||||
)}
|
||||
{wizard.modoCreacion === 'CLONADO' && (
|
||||
<>
|
||||
<Icons.Copy className="h-4 w-4" /> Clonada
|
||||
{wizard.subModoClonado === 'INTERNO'
|
||||
? ' (Sistema)'
|
||||
: ' (Archivo)'}
|
||||
</>
|
||||
)}
|
||||
<div className="bg-muted rounded-md p-3">
|
||||
<span className="text-muted-foreground">Tipo de origen: </span>
|
||||
<span className="inline-flex items-center gap-2 font-medium">
|
||||
{wizard.tipoOrigen === 'MANUAL' && (
|
||||
<Icons.Pencil className="h-4 w-4" />
|
||||
)}
|
||||
{(wizard.tipoOrigen === 'IA' ||
|
||||
wizard.tipoOrigen === 'IA_SIMPLE' ||
|
||||
wizard.tipoOrigen === 'IA_MULTIPLE') && (
|
||||
<Icons.Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
{(wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
||||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
|
||||
<Icons.Copy className="h-4 w-4" />
|
||||
)}
|
||||
{modoLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{wizard.tipoOrigen === 'IA_MULTIPLE' ? (
|
||||
<>
|
||||
<div className="border-border/60 bg-muted/30 grid gap-3 rounded-xl border p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-foreground text-base font-semibold">
|
||||
Configuración
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Se crearán {materiasSeleccionadas.length} asignatura(s) a
|
||||
partir de tus selecciones.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-background/40 border-border/60 rounded-lg border p-3">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Estructura
|
||||
</div>
|
||||
<div className="text-foreground mt-1 text-sm font-medium">
|
||||
{estructuraNombre}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border/60 bg-muted/30 grid gap-3 rounded-xl border p-4">
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<div className="text-foreground text-base font-semibold">
|
||||
Materias seleccionadas
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{materiasSeleccionadas.length} en total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{materiasSeleccionadas.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No hay materias seleccionadas.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{materiasSeleccionadas.map((m) => {
|
||||
const lineaNombre = m.linea_plan_id
|
||||
? (lineasPlan?.find((l) => l.id === m.linea_plan_id)
|
||||
?.nombre ?? m.linea_plan_id)
|
||||
: '—'
|
||||
|
||||
const cicloText =
|
||||
typeof m.numero_ciclo === 'number' &&
|
||||
Number.isFinite(m.numero_ciclo)
|
||||
? String(m.numero_ciclo)
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="bg-background/40 border-border/60 grid gap-2 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-foreground text-sm font-semibold">
|
||||
{m.nombre}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="bg-accent/30 text-accent-foreground rounded-full px-2 py-0.5">
|
||||
Línea: {lineaNombre}
|
||||
</span>
|
||||
<span className="bg-accent/30 text-accent-foreground rounded-full px-2 py-0.5">
|
||||
Ciclo: {cicloText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-sm whitespace-pre-wrap">
|
||||
{m.descripcion || '—'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">Nombre: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.nombre || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Código: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.codigo || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Tipo: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.tipo || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Créditos: </span>
|
||||
<span className="font-medium">{creditosText}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Estructura: </span>
|
||||
<span className="font-medium">{estructuraNombre}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Horas académicas:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.horasAcademicas ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Horas independientes:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.horasIndependientes ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 rounded-md p-3">
|
||||
<div className="font-medium">Configuración IA</div>
|
||||
<div className="mt-2 grid gap-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Enfoque académico:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{wizard.iaConfig?.descripcionEnfoqueAcademico || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Instrucciones adicionales:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{wizard.iaConfig?.instruccionesAdicionalesIA || '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Archivos de referencia</div>
|
||||
{archivosRef.length ? (
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
{archivosRef.map((id) => (
|
||||
<li key={id}>{id}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-xs">—</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
Repositorios de referencia
|
||||
</div>
|
||||
{repositoriosRef.length ? (
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
{repositoriosRef.map((id) => (
|
||||
<li key={id}>{id}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-xs">—</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-medium">Archivos adjuntos</div>
|
||||
{adjuntos.length ? (
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
{adjuntos.map((f) => (
|
||||
<li key={f.id}>
|
||||
<span className="text-foreground">
|
||||
{f.file.name}
|
||||
</span>{' '}
|
||||
<span>· {formatFileSize(f.file.size)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-xs">—</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,66 +1,477 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { AISubjectUnifiedInput } from '@/data'
|
||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||
import type { TablesInsert } from '@/types/supabase'
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
supabaseBrowser,
|
||||
useGenerateSubjectAI,
|
||||
qk,
|
||||
useCreateSubjectManual,
|
||||
subjects_get_maybe,
|
||||
} from '@/data'
|
||||
|
||||
export function WizardControls({
|
||||
Wizard,
|
||||
methods,
|
||||
wizard,
|
||||
canContinueDesdeMetodo,
|
||||
canContinueDesdeBasicos,
|
||||
canContinueDesdeConfig,
|
||||
onCreate,
|
||||
setWizard,
|
||||
errorMessage,
|
||||
onPrev,
|
||||
onNext,
|
||||
disablePrev,
|
||||
disableNext,
|
||||
disableCreate,
|
||||
isLastStep,
|
||||
}: {
|
||||
Wizard: any
|
||||
methods: any
|
||||
wizard: NewSubjectWizardState
|
||||
canContinueDesdeMetodo: boolean
|
||||
canContinueDesdeBasicos: boolean
|
||||
canContinueDesdeConfig: boolean
|
||||
onCreate: () => void
|
||||
setWizard: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||
errorMessage?: string | null
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
disablePrev: boolean
|
||||
disableNext: boolean
|
||||
disableCreate: boolean
|
||||
isLastStep: boolean
|
||||
}) {
|
||||
const idx = Wizard.utils.getIndex(methods.current.id)
|
||||
const isLast = idx >= Wizard.steps.length - 1
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const generateSubjectAI = useGenerateSubjectAI()
|
||||
const createSubjectManual = useCreateSubjectManual()
|
||||
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
||||
const cancelledRef = useRef(false)
|
||||
const realtimeChannelRef = useRef<RealtimeChannel | null>(null)
|
||||
const watchSubjectIdRef = useRef<string | null>(null)
|
||||
const watchTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
cancelledRef.current = false
|
||||
return () => {
|
||||
cancelledRef.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopSubjectWatch = useCallback(() => {
|
||||
if (watchTimeoutRef.current) {
|
||||
window.clearTimeout(watchTimeoutRef.current)
|
||||
watchTimeoutRef.current = null
|
||||
}
|
||||
|
||||
watchSubjectIdRef.current = null
|
||||
|
||||
const ch = realtimeChannelRef.current
|
||||
if (ch) {
|
||||
realtimeChannelRef.current = null
|
||||
try {
|
||||
supabaseBrowser().removeChannel(ch)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopSubjectWatch()
|
||||
}
|
||||
}, [stopSubjectWatch])
|
||||
|
||||
const handleSubjectReady = (args: {
|
||||
id: string
|
||||
plan_estudio_id: string
|
||||
estado?: unknown
|
||||
}) => {
|
||||
if (cancelledRef.current) return
|
||||
|
||||
const estado = String(args.estado ?? '').toLowerCase()
|
||||
if (estado === 'generando') return
|
||||
|
||||
stopSubjectWatch()
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({ ...w, isLoading: false }))
|
||||
|
||||
navigate({
|
||||
to: `/planes/${args.plan_estudio_id}/asignaturas/${args.id}`,
|
||||
state: { showConfetti: true },
|
||||
})
|
||||
}
|
||||
|
||||
const beginSubjectWatch = (args: { subjectId: string; planId: string }) => {
|
||||
stopSubjectWatch()
|
||||
|
||||
watchSubjectIdRef.current = args.subjectId
|
||||
|
||||
// Timeout de seguridad (mismo límite que teníamos con polling)
|
||||
watchTimeoutRef.current = window.setTimeout(
|
||||
() => {
|
||||
if (cancelledRef.current) return
|
||||
if (watchSubjectIdRef.current !== args.subjectId) return
|
||||
|
||||
stopSubjectWatch()
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage:
|
||||
'La generación está tardando demasiado. Intenta de nuevo en unos minutos.',
|
||||
}))
|
||||
},
|
||||
6 * 60 * 1000,
|
||||
)
|
||||
|
||||
const supabase = supabaseBrowser()
|
||||
const channel = supabase.channel(`asignaturas-status-${args.subjectId}`)
|
||||
realtimeChannelRef.current = channel
|
||||
|
||||
channel.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'asignaturas',
|
||||
filter: `id=eq.${args.subjectId}`,
|
||||
},
|
||||
(payload) => {
|
||||
if (cancelledRef.current) return
|
||||
|
||||
const next: any = (payload as any)?.new
|
||||
if (!next?.id || !next?.plan_estudio_id) return
|
||||
handleSubjectReady({
|
||||
id: String(next.id),
|
||||
plan_estudio_id: String(next.plan_estudio_id),
|
||||
estado: next.estado,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
channel.subscribe((status) => {
|
||||
if (cancelledRef.current) return
|
||||
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
|
||||
stopSubjectWatch()
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage:
|
||||
'No se pudo suscribir al estado de la asignatura. Intenta de nuevo.',
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const uploadAiAttachments = async (args: {
|
||||
planId: string
|
||||
files: Array<{ file: File }>
|
||||
}): Promise<Array<string>> => {
|
||||
const supabase = supabaseBrowser()
|
||||
if (!args.files.length) return []
|
||||
|
||||
const runId = crypto.randomUUID()
|
||||
const basePath = `planes/${args.planId}/asignaturas/ai/${runId}`
|
||||
|
||||
const keys: Array<string> = []
|
||||
for (const f of args.files) {
|
||||
const safeName = (f.file.name || 'archivo').replace(/[\\/]+/g, '_')
|
||||
const key = `${basePath}/${crypto.randomUUID()}-${safeName}`
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from('ai-storage')
|
||||
.upload(key, f.file, {
|
||||
contentType: f.file.type || undefined,
|
||||
})
|
||||
|
||||
if (error) throw new Error(error.message)
|
||||
keys.push(key)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: true,
|
||||
errorMessage: null,
|
||||
}))
|
||||
|
||||
let startedWaiting = false
|
||||
|
||||
try {
|
||||
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
||||
if (!wizard.plan_estudio_id) {
|
||||
throw new Error('Plan de estudio inválido.')
|
||||
}
|
||||
if (!wizard.datosBasicos.estructuraId) {
|
||||
throw new Error('Estructura inválida.')
|
||||
}
|
||||
if (!wizard.datosBasicos.nombre.trim()) {
|
||||
throw new Error('Nombre inválido.')
|
||||
}
|
||||
if (wizard.datosBasicos.creditos == null) {
|
||||
throw new Error('Créditos inválidos.')
|
||||
}
|
||||
|
||||
console.log(`${new Date().toISOString()} - Insertando asignatura IA`)
|
||||
|
||||
const supabase = supabaseBrowser()
|
||||
const placeholder: TablesInsert<'asignaturas'> = {
|
||||
plan_estudio_id: wizard.plan_estudio_id,
|
||||
estructura_id: wizard.datosBasicos.estructuraId,
|
||||
nombre: wizard.datosBasicos.nombre,
|
||||
codigo: wizard.datosBasicos.codigo ?? null,
|
||||
tipo: wizard.datosBasicos.tipo ?? undefined,
|
||||
creditos: wizard.datosBasicos.creditos,
|
||||
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
|
||||
horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
|
||||
estado: 'generando',
|
||||
tipo_origen: 'IA',
|
||||
}
|
||||
|
||||
const { data: inserted, error: insertError } = await supabase
|
||||
.from('asignaturas')
|
||||
.insert(placeholder)
|
||||
.select('id,plan_estudio_id')
|
||||
.single()
|
||||
|
||||
if (insertError) throw new Error(insertError.message)
|
||||
const subjectId = inserted.id
|
||||
|
||||
setIsSpinningIA(true)
|
||||
|
||||
// Inicia watch realtime antes de disparar la Edge para no perder updates.
|
||||
startedWaiting = true
|
||||
beginSubjectWatch({ subjectId, planId: wizard.plan_estudio_id })
|
||||
|
||||
const archivosAdjuntos = await uploadAiAttachments({
|
||||
planId: wizard.plan_estudio_id,
|
||||
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
|
||||
file: x.file,
|
||||
})),
|
||||
})
|
||||
|
||||
const payload: AISubjectUnifiedInput = {
|
||||
datosUpdate: {
|
||||
id: subjectId,
|
||||
plan_estudio_id: wizard.plan_estudio_id,
|
||||
estructura_id: wizard.datosBasicos.estructuraId,
|
||||
nombre: wizard.datosBasicos.nombre,
|
||||
codigo: wizard.datosBasicos.codigo ?? null,
|
||||
tipo: wizard.datosBasicos.tipo ?? null,
|
||||
creditos: wizard.datosBasicos.creditos,
|
||||
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
|
||||
horas_independientes:
|
||||
wizard.datosBasicos.horasIndependientes ?? null,
|
||||
},
|
||||
iaConfig: {
|
||||
descripcionEnfoqueAcademico:
|
||||
wizard.iaConfig?.descripcionEnfoqueAcademico ?? undefined,
|
||||
instruccionesAdicionalesIA:
|
||||
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
|
||||
archivosAdjuntos,
|
||||
},
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${new Date().toISOString()} - Disparando Edge IA asignatura (unified)`,
|
||||
)
|
||||
|
||||
await generateSubjectAI.mutateAsync(payload as any)
|
||||
|
||||
// Fallback: una lectura puntual por si el UPDATE llegó antes de suscribir.
|
||||
const latest = await subjects_get_maybe(subjectId)
|
||||
if (latest) {
|
||||
handleSubjectReady({
|
||||
id: latest.id as any,
|
||||
plan_estudio_id: latest.plan_estudio_id as any,
|
||||
estado: (latest as any).estado,
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
|
||||
const selected = wizard.sugerencias.filter((s) => s.selected)
|
||||
|
||||
if (selected.length === 0) {
|
||||
throw new Error('Selecciona al menos una sugerencia.')
|
||||
}
|
||||
if (!wizard.plan_estudio_id) {
|
||||
throw new Error('Plan de estudio inválido.')
|
||||
}
|
||||
if (!wizard.estructuraId) {
|
||||
throw new Error('Selecciona una estructura para continuar.')
|
||||
}
|
||||
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
setIsSpinningIA(true)
|
||||
|
||||
const archivosAdjuntos = await uploadAiAttachments({
|
||||
planId: wizard.plan_estudio_id,
|
||||
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
|
||||
file: x.file,
|
||||
})),
|
||||
})
|
||||
|
||||
const placeholders: Array<TablesInsert<'asignaturas'>> = selected.map(
|
||||
(s): TablesInsert<'asignaturas'> => ({
|
||||
plan_estudio_id: wizard.plan_estudio_id,
|
||||
estructura_id: wizard.estructuraId,
|
||||
estado: 'generando',
|
||||
nombre: s.nombre,
|
||||
codigo: s.codigo ?? null,
|
||||
tipo: s.tipo ?? undefined,
|
||||
creditos: s.creditos ?? 0,
|
||||
horas_academicas: s.horasAcademicas ?? null,
|
||||
horas_independientes: s.horasIndependientes ?? null,
|
||||
linea_plan_id: s.linea_plan_id ?? null,
|
||||
numero_ciclo: s.numero_ciclo ?? null,
|
||||
}),
|
||||
)
|
||||
|
||||
const { data: inserted, error: insertError } = await supabase
|
||||
.from('asignaturas')
|
||||
.insert(placeholders)
|
||||
.select('id')
|
||||
|
||||
if (insertError) {
|
||||
throw new Error(insertError.message)
|
||||
}
|
||||
|
||||
const insertedIds = inserted.map((r) => r.id)
|
||||
if (insertedIds.length !== selected.length) {
|
||||
throw new Error('No se pudieron crear todas las asignaturas.')
|
||||
}
|
||||
|
||||
// Disparar generación en paralelo (no bloquear navegación)
|
||||
insertedIds.forEach((id, idx) => {
|
||||
const s = selected[idx]
|
||||
const creditosForEdge =
|
||||
typeof s.creditos === 'number' && s.creditos > 0
|
||||
? s.creditos
|
||||
: undefined
|
||||
const payload: AISubjectUnifiedInput = {
|
||||
datosUpdate: {
|
||||
id,
|
||||
plan_estudio_id: wizard.plan_estudio_id,
|
||||
estructura_id: wizard.estructuraId ?? undefined,
|
||||
nombre: s.nombre,
|
||||
codigo: s.codigo ?? null,
|
||||
tipo: s.tipo ?? null,
|
||||
creditos: creditosForEdge,
|
||||
horas_academicas: s.horasAcademicas ?? null,
|
||||
horas_independientes: s.horasIndependientes ?? null,
|
||||
numero_ciclo: s.numero_ciclo ?? null,
|
||||
linea_plan_id: s.linea_plan_id ?? null,
|
||||
},
|
||||
iaConfig: {
|
||||
descripcionEnfoqueAcademico: s.descripcion,
|
||||
instruccionesAdicionalesIA:
|
||||
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
|
||||
archivosAdjuntos,
|
||||
},
|
||||
}
|
||||
|
||||
void generateSubjectAI.mutateAsync(payload as any).catch((e) => {
|
||||
console.error('Error generando asignatura IA (multiple):', e)
|
||||
})
|
||||
})
|
||||
|
||||
// Invalidar la query del listado del plan (una vez) para que la lista
|
||||
// muestre el estado actualizado y recargue cuando lleguen updates.
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planAsignaturas(wizard.plan_estudio_id),
|
||||
})
|
||||
|
||||
navigate({
|
||||
to: `/planes/${wizard.plan_estudio_id}/asignaturas`,
|
||||
resetScroll: false,
|
||||
})
|
||||
|
||||
setIsSpinningIA(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (wizard.tipoOrigen === 'MANUAL') {
|
||||
if (!wizard.plan_estudio_id) {
|
||||
throw new Error('Plan de estudio inválido.')
|
||||
}
|
||||
|
||||
const asignatura = await createSubjectManual.mutateAsync({
|
||||
plan_estudio_id: wizard.plan_estudio_id,
|
||||
estructura_id: wizard.datosBasicos.estructuraId!,
|
||||
nombre: wizard.datosBasicos.nombre,
|
||||
codigo: wizard.datosBasicos.codigo ?? null,
|
||||
tipo: wizard.datosBasicos.tipo ?? undefined,
|
||||
creditos: wizard.datosBasicos.creditos ?? 0,
|
||||
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
|
||||
horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
|
||||
linea_plan_id: null,
|
||||
numero_ciclo: null,
|
||||
})
|
||||
|
||||
navigate({
|
||||
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
|
||||
state: { showConfetti: true },
|
||||
resetScroll: false,
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
setIsSpinningIA(false)
|
||||
stopSubjectWatch()
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage: err?.message ?? 'Error creando la asignatura',
|
||||
}))
|
||||
} finally {
|
||||
if (!startedWaiting) {
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({ ...w, isLoading: false }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-none border-t bg-white p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
{wizard.errorMessage && (
|
||||
<span className="text-destructive text-sm font-medium">
|
||||
{wizard.errorMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex grow items-center justify-between">
|
||||
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
|
||||
Anterior
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => methods.prev()}
|
||||
disabled={idx === 0 || wizard.isLoading}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
|
||||
{!isLast ? (
|
||||
<Button
|
||||
onClick={() => methods.next()}
|
||||
disabled={
|
||||
wizard.isLoading ||
|
||||
(idx === 0 && !canContinueDesdeMetodo) ||
|
||||
(idx === 1 && !canContinueDesdeBasicos) ||
|
||||
(idx === 2 && !canContinueDesdeConfig)
|
||||
}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onCreate} disabled={wizard.isLoading}>
|
||||
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-2 flex-1">
|
||||
{(errorMessage ?? wizard.errorMessage) && (
|
||||
<span className="text-destructive text-sm font-medium">
|
||||
{errorMessage ?? wizard.errorMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-2 flex w-5 items-center justify-center">
|
||||
<Loader2
|
||||
className={
|
||||
wizard.tipoOrigen === 'IA_SIMPLE' && isSpinningIA
|
||||
? 'text-muted-foreground h-6 w-6 animate-spin'
|
||||
: 'h-6 w-6 opacity-0'
|
||||
}
|
||||
aria-hidden={!(wizard.tipoOrigen === 'IA_SIMPLE' && isSpinningIA)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLastStep ? (
|
||||
<Button onClick={handleCreate} disabled={disableCreate}>
|
||||
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onNext} disabled={disableNext}>
|
||||
Siguiente
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
126
src/components/planes/detalle/Ia/ImprovementCard.tsx
Normal file
126
src/components/planes/detalle/Ia/ImprovementCard.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Check, Loader2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useUpdatePlanFields, useUpdateRecommendationApplied } from '@/data'
|
||||
|
||||
export const ImprovementCard = ({
|
||||
suggestions,
|
||||
onApply,
|
||||
planId,
|
||||
dbMessageId,
|
||||
currentDatos,
|
||||
activeChatId,
|
||||
onApplySuccess,
|
||||
}: {
|
||||
suggestions: Array<any>
|
||||
onApply?: (key: string, value: string) => void
|
||||
planId: string
|
||||
currentDatos: any
|
||||
dbMessageId: string
|
||||
activeChatId: any
|
||||
onApplySuccess?: (key: string) => void
|
||||
}) => {
|
||||
const [localApplied, setLocalApplied] = useState<Array<string>>([])
|
||||
const updatePlan = useUpdatePlanFields()
|
||||
const updateAppliedStatus = useUpdateRecommendationApplied()
|
||||
|
||||
const handleApply = (key: string, newValue: string) => {
|
||||
if (!currentDatos) return
|
||||
const currentValue = currentDatos[key]
|
||||
let finalValue: any
|
||||
|
||||
if (
|
||||
typeof currentValue === 'object' &&
|
||||
currentValue !== null &&
|
||||
'description' in currentValue
|
||||
) {
|
||||
finalValue = { ...currentValue, description: newValue }
|
||||
} else {
|
||||
finalValue = newValue
|
||||
}
|
||||
|
||||
const datosActualizados = {
|
||||
...currentDatos,
|
||||
[key]: finalValue,
|
||||
}
|
||||
|
||||
updatePlan.mutate(
|
||||
{
|
||||
planId: planId as any,
|
||||
patch: { datos: datosActualizados },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setLocalApplied((prev) => [...prev, key])
|
||||
|
||||
if (onApplySuccess) onApplySuccess(key)
|
||||
|
||||
// --- CAMBIO AQUÍ: Ahora enviamos el ID del mensaje ---
|
||||
if (dbMessageId) {
|
||||
updateAppliedStatus.mutate({
|
||||
conversacionId: dbMessageId, // Cambiamos el nombre de la propiedad si es necesario
|
||||
campoAfectado: key,
|
||||
})
|
||||
}
|
||||
|
||||
if (onApply) onApply(key, newValue)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex w-full flex-col gap-4">
|
||||
{suggestions.map((sug) => {
|
||||
const isApplied = sug.applied === true || localApplied.includes(sug.key)
|
||||
const isUpdating =
|
||||
updatePlan.isPending &&
|
||||
updatePlan.variables.patch.datos?.[sug.key] !== undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sug.key}
|
||||
className={`rounded-2xl border bg-white p-5 shadow-sm transition-all ${
|
||||
isApplied ? 'border-teal-200 bg-teal-50/20' : 'border-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-slate-900">{sug.label}</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApply(sug.key, sug.newValue)}
|
||||
disabled={isApplied || !!isUpdating}
|
||||
className={`h-8 rounded-full px-4 text-xs transition-all ${
|
||||
isApplied
|
||||
? 'cursor-not-allowed bg-slate-100 text-slate-400'
|
||||
: 'bg-[#00a189] text-white hover:bg-[#008f7a]'
|
||||
}`}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : isApplied ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Check size={12} /> Aplicado
|
||||
</span>
|
||||
) : (
|
||||
'Aplicar mejora'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${
|
||||
isApplied
|
||||
? 'border-teal-100 bg-teal-50/50 text-slate-700'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{sug.newValue}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { TemplateSelectorCard } from './TemplateSelectorCard'
|
||||
|
||||
import type { CARRERAS } from '@/features/planes/nuevo/catalogs'
|
||||
import type {
|
||||
EstructuraPlanRow,
|
||||
FacultadRow,
|
||||
NivelPlanEstudio,
|
||||
TipoCiclo,
|
||||
} from '@/data/types/domain'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -12,41 +15,52 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
FACULTADES,
|
||||
NIVELES,
|
||||
TIPOS_CICLO,
|
||||
PLANTILLAS_ANEXO_1,
|
||||
PLANTILLAS_ANEXO_2,
|
||||
} from '@/features/planes/nuevo/catalogs'
|
||||
import { useCatalogosPlanes } from '@/data/hooks/usePlans'
|
||||
import { NIVELES, TIPOS_CICLO } from '@/features/planes/nuevo/catalogs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function PasoBasicosForm({
|
||||
wizard,
|
||||
onChange,
|
||||
carrerasFiltradas,
|
||||
}: {
|
||||
wizard: NewPlanWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||
carrerasFiltradas: typeof CARRERAS
|
||||
}) {
|
||||
const { data: catalogos } = useCatalogosPlanes()
|
||||
|
||||
// Preferir los catálogos remotos si están disponibles; si no, usar los locales
|
||||
const facultadesList = catalogos?.facultades ?? []
|
||||
const rawCarreras = catalogos?.carreras ?? []
|
||||
const estructurasPlanList = catalogos?.estructurasPlan ?? []
|
||||
|
||||
const filteredCarreras = rawCarreras.filter((c: any) => {
|
||||
const facId = wizard.datosBasicos.facultad.id
|
||||
if (!facId) return true
|
||||
// soportar ambos shapes: `facultad_id` (BD) o `facultadId` (local)
|
||||
return c.facultad_id ? c.facultad_id === facId : c.facultadId === facId
|
||||
})
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-1 sm:col-span-2">
|
||||
<Label htmlFor="nombrePlan">
|
||||
Nombre del plan <span className="text-destructive">*</span>
|
||||
Nombre del plan {/* <span className="text-destructive">*</span> */}
|
||||
</Label>
|
||||
<Input
|
||||
id="nombrePlan"
|
||||
placeholder="Ej. Ingeniería en Sistemas 2026"
|
||||
placeholder="Ej. Ingeniería en Sistemas (2026)"
|
||||
value={wizard.datosBasicos.nombrePlan}
|
||||
maxLength={200}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value },
|
||||
}))
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
nombrePlan: e.target.value,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
/>
|
||||
@@ -55,23 +69,30 @@ export function PasoBasicosForm({
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="facultad">Facultad</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.facultadId}
|
||||
value={wizard.datosBasicos.facultad.id}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
facultadId: value,
|
||||
carreraId: '',
|
||||
},
|
||||
}))
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
facultad: {
|
||||
id: value,
|
||||
nombre:
|
||||
facultadesList.find((f) => f.id === value)?.nombre ||
|
||||
'',
|
||||
},
|
||||
carrera: { id: '', nombre: '' },
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="facultad"
|
||||
className={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.facultadId
|
||||
!wizard.datosBasicos.facultad.id
|
||||
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||
)}
|
||||
@@ -79,7 +100,7 @@ export function PasoBasicosForm({
|
||||
<SelectValue placeholder="Ej. Facultad de Ingeniería" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FACULTADES.map((f) => (
|
||||
{facultadesList.map((f: FacultadRow) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.nombre}
|
||||
</SelectItem>
|
||||
@@ -91,20 +112,30 @@ export function PasoBasicosForm({
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="carrera">Carrera</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.carreraId}
|
||||
value={wizard.datosBasicos.carrera.id}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, carreraId: value },
|
||||
}))
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
carrera: {
|
||||
id: value,
|
||||
nombre:
|
||||
filteredCarreras.find((c) => c.id === value)?.nombre ||
|
||||
'',
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
disabled={!wizard.datosBasicos.facultadId}
|
||||
disabled={!wizard.datosBasicos.facultad.id}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="carrera"
|
||||
className={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.carreraId
|
||||
!wizard.datosBasicos.carrera.id
|
||||
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||
)}
|
||||
@@ -112,7 +143,7 @@ export function PasoBasicosForm({
|
||||
<SelectValue placeholder="Ej. Ingeniería en Cibernética y Sistemas Computacionales" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{carrerasFiltradas.map((c) => (
|
||||
{filteredCarreras.map((c: any) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.nombre}
|
||||
</SelectItem>
|
||||
@@ -125,11 +156,13 @@ export function PasoBasicosForm({
|
||||
<Label htmlFor="nivel">Nivel</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.nivel}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nivel: value },
|
||||
}))
|
||||
onValueChange={(value: NivelPlanEstudio) =>
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
datosBasicos: { ...w.datosBasicos, nivel: value },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
@@ -157,14 +190,16 @@ export function PasoBasicosForm({
|
||||
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.tipoCiclo}
|
||||
onValueChange={(value) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
tipoCiclo: value as any,
|
||||
},
|
||||
}))
|
||||
onValueChange={(value: TipoCiclo) =>
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
tipoCiclo: value as any,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
@@ -180,8 +215,8 @@ export function PasoBasicosForm({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIPOS_CICLO.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
<SelectItem key={t} value={t}>
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -194,24 +229,80 @@ export function PasoBasicosForm({
|
||||
id="numCiclos"
|
||||
type="number"
|
||||
min={1}
|
||||
max={99}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={wizard.datosBasicos.numCiclos ?? ''}
|
||||
onKeyDown={(e) => {
|
||||
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
// Keep undefined when the input is empty so the field stays optional
|
||||
numCiclos:
|
||||
e.target.value === '' ? undefined : Number(e.target.value),
|
||||
},
|
||||
}))
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
// Keep undefined when the input is empty so the field stays optional
|
||||
numCiclos: (() => {
|
||||
const raw = e.target.value
|
||||
if (raw === '') return null
|
||||
const asNumber = Number(raw)
|
||||
if (Number.isNaN(asNumber)) return null
|
||||
// Coerce to positive integer (natural numbers without zero)
|
||||
const n = Math.floor(Math.abs(asNumber))
|
||||
const capped = Math.min(n >= 1 ? n : 1, 99)
|
||||
return capped
|
||||
})(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
||||
placeholder="Ej. 8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="estructuraPlan">Estructura de plan de estudios</Label>
|
||||
<Select
|
||||
value={wizard.datosBasicos.estructuraPlanId ?? ''}
|
||||
onValueChange={(value: string) =>
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
estructuraPlanId: value,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="tipoCiclo"
|
||||
className={cn(
|
||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||
!wizard.datosBasicos.estructuraPlanId
|
||||
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Ej. Plan base SEP/ULSA (2026)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{estructurasPlanList.map((t: EstructuraPlanRow) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
{/* <Separator className="my-3" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<TemplateSelectorCard
|
||||
cardTitle="Plantilla de plan de estudios"
|
||||
@@ -247,7 +338,7 @@ export function PasoBasicosForm({
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export function TemplateSelectorCard({
|
||||
|
||||
const handleTemplateChange = (value: string) => {
|
||||
const template = templatesData.find((t) => t.id === value)
|
||||
const firstVersion = template?.versions?.[0] ?? ''
|
||||
const firstVersion = template?.versions[0] ?? ''
|
||||
if (onChange) {
|
||||
onChange({ templateId: value, version: firstVersion })
|
||||
} else {
|
||||
|
||||
@@ -2,64 +2,97 @@ import { Upload, File, X, FileText } from 'lucide-react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatFileSize } from '@/features/planes/utils/format-file-size'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface UploadedFile {
|
||||
id: string
|
||||
name: string
|
||||
size: string
|
||||
type: string
|
||||
export interface UploadedFile {
|
||||
id: string // Necesario para React (key)
|
||||
file: File // La fuente de verdad (contiene name, size, type)
|
||||
preview?: string // Opcional: si fueran imágenes
|
||||
}
|
||||
|
||||
interface FileDropzoneProps {
|
||||
persistentFiles?: Array<UploadedFile>
|
||||
onFilesChange?: (files: Array<UploadedFile>) => void
|
||||
acceptedTypes?: string
|
||||
maxFiles?: number
|
||||
title?: string
|
||||
description?: string
|
||||
autoScrollToDropzone?: boolean
|
||||
}
|
||||
|
||||
export function FileDropzone({
|
||||
persistentFiles,
|
||||
onFilesChange,
|
||||
acceptedTypes = '.doc,.docx,.pdf',
|
||||
maxFiles = 5,
|
||||
title = 'Arrastra archivos aquí',
|
||||
description = 'o haz clic para seleccionar',
|
||||
autoScrollToDropzone = false,
|
||||
}: FileDropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [files, setFiles] = useState<Array<UploadedFile>>([])
|
||||
const [files, setFiles] = useState<Array<UploadedFile>>(persistentFiles ?? [])
|
||||
const onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const prevFilesLengthRef = useRef(files.length)
|
||||
|
||||
const addFiles = useCallback(
|
||||
(newFiles: Array<File>) => {
|
||||
const toUpload: Array<UploadedFile> = newFiles.map((file) => ({
|
||||
id:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? (crypto as any).randomUUID()
|
||||
: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
type: file.name.split('.').pop() || 'file',
|
||||
}))
|
||||
setFiles((prev) => {
|
||||
const room = Math.max(0, maxFiles - prev.length)
|
||||
const next = [...prev, ...toUpload.slice(0, room)].slice(0, maxFiles)
|
||||
return next
|
||||
(incomingFiles: Array<File>) => {
|
||||
console.log(
|
||||
'incoming files:',
|
||||
incomingFiles.map((file) => file.name),
|
||||
)
|
||||
|
||||
setFiles((previousFiles) => {
|
||||
console.log(
|
||||
'previous files',
|
||||
previousFiles.map((f) => f.file.name),
|
||||
)
|
||||
|
||||
// Evitar duplicados por nombre (comprobación global en los archivos existentes)
|
||||
const existingFileNames = new Set(
|
||||
previousFiles.map((uploaded) => uploaded.file.name),
|
||||
)
|
||||
const uniqueNewFiles = incomingFiles.filter(
|
||||
(incomingFile) => !existingFileNames.has(incomingFile.name),
|
||||
)
|
||||
|
||||
// Convertir archivos a objetos con ID único para manejo en React
|
||||
const filesToUpload: Array<UploadedFile> = uniqueNewFiles.map(
|
||||
(incomingFile) => ({
|
||||
id:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? (crypto as any).randomUUID()
|
||||
: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
file: incomingFile,
|
||||
}),
|
||||
)
|
||||
|
||||
// Calcular espacio disponible respetando el límite máximo
|
||||
const room = Math.max(0, maxFiles - previousFiles.length)
|
||||
const nextFiles = [
|
||||
...previousFiles,
|
||||
...filesToUpload.slice(0, room),
|
||||
].slice(0, maxFiles)
|
||||
return nextFiles
|
||||
})
|
||||
},
|
||||
[maxFiles],
|
||||
)
|
||||
|
||||
// Manejador para cuando se arrastran archivos sobre la zona
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
// Manejador para cuando se sale de la zona de arrastre
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
// Manejador para cuando se sueltan los archivos
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -70,39 +103,68 @@ export function FileDropzone({
|
||||
[addFiles],
|
||||
)
|
||||
|
||||
// Manejador para la selección de archivos mediante el input nativo
|
||||
const handleFileInput = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const selectedFiles = Array.from(e.target.files)
|
||||
addFiles(selectedFiles)
|
||||
// Corrección de bug: Limpiar el valor para permitir seleccionar el mismo archivo nuevamente si fue eliminado
|
||||
e.target.value = ''
|
||||
}
|
||||
},
|
||||
[addFiles],
|
||||
)
|
||||
|
||||
// Función para eliminar un archivo específico por su ID
|
||||
const removeFile = useCallback((fileId: string) => {
|
||||
setFiles((prev) => {
|
||||
const next = prev.filter((f) => f.id !== fileId)
|
||||
return next
|
||||
setFiles((previousFiles) => {
|
||||
console.log(
|
||||
'previous files',
|
||||
previousFiles.map((f) => f.file.name),
|
||||
)
|
||||
const remainingFiles = previousFiles.filter(
|
||||
(uploadedFile) => uploadedFile.id !== fileId,
|
||||
)
|
||||
return remainingFiles
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Keep latest callback in a ref to avoid retriggering effect on identity change
|
||||
// Mantener la referencia actualizada de la función callback externa para evitar loops en useEffect
|
||||
useEffect(() => {
|
||||
onFilesChangeRef.current = onFilesChange
|
||||
}, [onFilesChange])
|
||||
|
||||
// Only emit when files actually change to avoid parent update loops
|
||||
// Notificar al componente padre cuando cambia la lista de archivos
|
||||
useEffect(() => {
|
||||
if (onFilesChangeRef.current) onFilesChangeRef.current(files)
|
||||
}, [files])
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
// Scroll automático hacia abajo solo cuando se pasa de 0 a 1 o más archivos
|
||||
useEffect(() => {
|
||||
if (
|
||||
autoScrollToDropzone &&
|
||||
prevFilesLengthRef.current === 0 &&
|
||||
files.length > 0
|
||||
) {
|
||||
// Usar un pequeño timeout para asegurar que el renderizado se complete
|
||||
const timer = setTimeout(() => {
|
||||
bottomRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
})
|
||||
}, 100)
|
||||
|
||||
// Actualizar la referencia
|
||||
prevFilesLengthRef.current = files.length
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
|
||||
// Mantener sincronizada la referencia en otros casos
|
||||
prevFilesLengthRef.current = files.length
|
||||
}, [files.length, autoScrollToDropzone])
|
||||
|
||||
// Determinar el icono a mostrar según la extensión del archivo
|
||||
const getFileIcon = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'pdf':
|
||||
@@ -117,13 +179,19 @@ export function FileDropzone({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Elemento invisible para referencia de scroll */}
|
||||
<div ref={bottomRef} />
|
||||
|
||||
{/* Área principal de dropzone */}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'border-border hover:border-primary/50 cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all duration-300',
|
||||
isDragging && 'active',
|
||||
'cursor-pointer rounded-xl border-2 border-dashed p-7 text-center transition-all duration-300',
|
||||
// Siempre usar borde por defecto a menos que se esté arrastrando
|
||||
'border-border hover:border-primary/50',
|
||||
isDragging && 'ring-primary ring-2 ring-offset-2',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
@@ -133,6 +201,7 @@ export function FileDropzone({
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
disabled={files.length >= maxFiles}
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
@@ -152,9 +221,9 @@ export function FileDropzone({
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-foreground text-sm font-medium">{title}</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{/* <p className="text-muted-foreground mt-1 text-xs">
|
||||
{description}
|
||||
</p>
|
||||
</p> */}
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Formatos:{' '}
|
||||
{acceptedTypes
|
||||
@@ -162,44 +231,63 @@ export function FileDropzone({
|
||||
.toUpperCase()
|
||||
.replace(/,/g, ', ')}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex items-center justify-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-primary text-xl font-bold',
|
||||
files.length >= maxFiles ? 'text-destructive' : '',
|
||||
)}
|
||||
>
|
||||
{files.length}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium transition-colors',
|
||||
files.length >= maxFiles
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground/80',
|
||||
)}
|
||||
>
|
||||
/ {maxFiles} archivos (máximo)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Uploaded files list */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="bg-accent/50 border-border fade-in flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
{getFileIcon(file.type)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">{file.size}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||||
onClick={() => removeFile(file.id)}
|
||||
{/* Lista de archivos subidos (Orden inverso: más recientes primero) */}
|
||||
<div className="h-56 overflow-y-auto">
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{[...files].reverse().map((uploadedFile) => (
|
||||
<div
|
||||
key={uploadedFile.id}
|
||||
className="bg-accent/50 border-border fade-in flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length >= maxFiles && (
|
||||
<p className="text-warning text-center text-xs">
|
||||
Máximo de {maxFiles} archivos alcanzado
|
||||
</p>
|
||||
)}
|
||||
{getFileIcon(uploadedFile.file.type)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{uploadedFile.file.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatFileSize(uploadedFile.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||||
onClick={() => removeFile(uploadedFile.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { FileDropzone } from './FileDropZone'
|
||||
import ReferenciasParaIA from './ReferenciasParaIA'
|
||||
|
||||
import type { UploadedFile } from './FileDropZone'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
@@ -22,15 +21,13 @@ import {
|
||||
export function PasoDetallesPanel({
|
||||
wizard,
|
||||
onChange,
|
||||
onGenerarIA,
|
||||
isLoading,
|
||||
}: {
|
||||
wizard: NewPlanWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||
onGenerarIA: () => void
|
||||
isLoading: boolean
|
||||
}) {
|
||||
if (wizard.modoCreacion === 'MANUAL') {
|
||||
if (wizard.tipoOrigen === 'MANUAL') {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -43,22 +40,23 @@ export function PasoDetallesPanel({
|
||||
)
|
||||
}
|
||||
|
||||
if (wizard.modoCreacion === 'IA') {
|
||||
if (wizard.tipoOrigen === 'IA') {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="desc">Descripción del enfoque</Label>
|
||||
<Label htmlFor="desc">Descripción del enfoque académico</Label>
|
||||
<textarea
|
||||
id="desc"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
placeholder="Describe el enfoque del programa…"
|
||||
value={wizard.iaConfig?.descripcionEnfoque || ''}
|
||||
placeholder="Define el perfil de egreso, visión pedagógica y sector profesional. Ej.: Programa semestral orientado a la Industria 4.0, con enfoque en competencias directivas y emprendimiento tecnológico..."
|
||||
maxLength={7000}
|
||||
value={wizard.iaConfig?.descripcionEnfoqueAcademico || ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
descripcionEnfoque: e.target.value,
|
||||
descripcionEnfoqueAcademico: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -66,18 +64,24 @@ export function PasoDetallesPanel({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="notas">Notas adicionales</Label>
|
||||
<Label htmlFor="notas">
|
||||
Instrucciones adicionales para la IA
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
(Opcional)
|
||||
</span>
|
||||
</Label>
|
||||
<textarea
|
||||
id="notas"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
placeholder="Lineamientos institucionales, restricciones, etc."
|
||||
value={wizard.iaConfig?.notasAdicionales || ''}
|
||||
placeholder="Opcional: Estándares, estructura y limitaciones. Ej.: Estructura de 9 ciclos, carga pesada en ciencias básicas, sigue normativa CACEI, incluye 15% de materias optativas..."
|
||||
maxLength={7000}
|
||||
value={wizard.iaConfig?.instruccionesAdicionalesIA || ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
notasAdicionales: e.target.value,
|
||||
instruccionesAdicionalesIA: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -86,8 +90,9 @@ export function PasoDetallesPanel({
|
||||
<ReferenciasParaIA
|
||||
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
|
||||
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
|
||||
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
|
||||
onToggleArchivo={(id, checked) =>
|
||||
onChange((w) => {
|
||||
onChange((w): NewPlanWizardState => {
|
||||
const prev = w.iaConfig?.archivosReferencia || []
|
||||
const next = checked
|
||||
? [...prev, id]
|
||||
@@ -102,7 +107,7 @@ export function PasoDetallesPanel({
|
||||
})
|
||||
}
|
||||
onToggleRepositorio={(id, checked) =>
|
||||
onChange((w) => {
|
||||
onChange((w): NewPlanWizardState => {
|
||||
const prev = w.iaConfig?.repositoriosReferencia || []
|
||||
const next = checked
|
||||
? [...prev, id]
|
||||
@@ -116,56 +121,23 @@ export function PasoDetallesPanel({
|
||||
}
|
||||
})
|
||||
}
|
||||
onFilesChange={(files) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
archivosAdjuntos: files,
|
||||
},
|
||||
}))
|
||||
onFilesChange={(files: Array<UploadedFile>) =>
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
archivosAdjuntos: files,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Opcional: se pueden adjuntar recursos IA más adelante.
|
||||
</div>
|
||||
<Button onClick={onGenerarIA} disabled={isLoading}>
|
||||
{isLoading ? 'Generando…' : 'Generar borrador con IA'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{wizard.resumen.previewPlan && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview IA</CardTitle>
|
||||
<CardDescription>
|
||||
Asignaturas aprox.:{' '}
|
||||
{wizard.resumen.previewPlan.numAsignaturasAprox}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-sm">
|
||||
{wizard.resumen.previewPlan.secciones?.map((s) => (
|
||||
<li key={s.id}>
|
||||
<span className="text-foreground font-medium">
|
||||
{s.titulo}:
|
||||
</span>{' '}
|
||||
{s.resumen}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'INTERNO'
|
||||
) {
|
||||
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
@@ -269,10 +241,7 @@ export function PasoDetallesPanel({
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'TRADICIONAL'
|
||||
) {
|
||||
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -5,6 +5,8 @@ import BarraBusqueda from '../../BarraBusqueda'
|
||||
|
||||
import { FileDropzone } from './FileDropZone'
|
||||
|
||||
import type { UploadedFile } from './FileDropZone'
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
@@ -15,21 +17,22 @@ import {
|
||||
TabsContents,
|
||||
} from '@/components/ui/motion-tabs'
|
||||
import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ReferenciasParaIA = ({
|
||||
selectedArchivoIds = [],
|
||||
selectedRepositorioIds = [],
|
||||
uploadedFiles = [],
|
||||
onToggleArchivo,
|
||||
onToggleRepositorio,
|
||||
onFilesChange,
|
||||
}: {
|
||||
selectedArchivoIds?: Array<string>
|
||||
selectedRepositorioIds?: Array<string>
|
||||
uploadedFiles?: Array<UploadedFile>
|
||||
onToggleArchivo?: (id: string, checked: boolean) => void
|
||||
onToggleRepositorio?: (id: string, checked: boolean) => void
|
||||
onFilesChange?: (
|
||||
files: Array<{ id: string; name: string; size: string; type: string }>,
|
||||
) => void
|
||||
onFilesChange?: (files: Array<UploadedFile>) => void
|
||||
}) => {
|
||||
const [busquedaArchivos, setBusquedaArchivos] = useState('')
|
||||
const [busquedaRepositorios, setBusquedaRepositorios] = useState('')
|
||||
@@ -74,7 +77,7 @@ const ReferenciasParaIA = ({
|
||||
placeholder="Buscar archivo existente..."
|
||||
className="m-1 mb-1.5"
|
||||
/>
|
||||
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
|
||||
<div className="flex h-96 flex-col gap-0.5 overflow-y-auto">
|
||||
{archivosFiltrados.map((archivo) => (
|
||||
<Label
|
||||
key={archivo.id}
|
||||
@@ -85,7 +88,10 @@ const ReferenciasParaIA = ({
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleArchivo?.(archivo.id, !!checked)
|
||||
}
|
||||
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className={cn(
|
||||
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
selectedArchivoIds.includes(archivo.id) ? '' : 'invisible',
|
||||
)}
|
||||
/>
|
||||
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
@@ -121,7 +127,7 @@ const ReferenciasParaIA = ({
|
||||
placeholder="Buscar repositorio..."
|
||||
className="m-1 mb-1.5"
|
||||
/>
|
||||
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
|
||||
<div className="flex h-96 flex-col gap-0.5 overflow-y-auto">
|
||||
{repositoriosFiltrados.map((repositorio) => (
|
||||
<Label
|
||||
key={repositorio.id}
|
||||
@@ -132,7 +138,12 @@ const ReferenciasParaIA = ({
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleRepositorio?.(repositorio.id, !!checked)
|
||||
}
|
||||
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className={cn(
|
||||
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
selectedRepositorioIds.includes(repositorio.id)
|
||||
? ''
|
||||
: 'invisible',
|
||||
)}
|
||||
/>
|
||||
|
||||
<FolderOpen className="text-muted-foreground h-4 w-4" />
|
||||
@@ -161,11 +172,13 @@ const ReferenciasParaIA = ({
|
||||
icon: Upload,
|
||||
|
||||
content: (
|
||||
<div>
|
||||
<div className="p-1">
|
||||
<FileDropzone
|
||||
persistentFiles={uploadedFiles}
|
||||
onFilesChange={onFilesChange}
|
||||
title="Sube archivos de referencia"
|
||||
description="Documentos que serán usados como contexto para la generación"
|
||||
autoScrollToDropzone={true}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@@ -174,7 +187,12 @@ const ReferenciasParaIA = ({
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<Label>Referencias para la IA</Label>
|
||||
<Label>
|
||||
Referencias para la IA{' '}
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
(Opcional)
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<Tabs defaultValue="archivos-existentes" className="gap-4">
|
||||
<TabsList className="w-full">
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import * as Icons from 'lucide-react'
|
||||
|
||||
import type {
|
||||
NewPlanWizardState,
|
||||
ModoCreacion,
|
||||
SubModoClonado,
|
||||
} from '@/features/planes/nuevo/types'
|
||||
import type { TipoOrigen } from '@/data/types/domain'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -21,8 +18,7 @@ export function PasoModoCardGroup({
|
||||
wizard: NewPlanWizardState
|
||||
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||
}) {
|
||||
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
|
||||
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
|
||||
const isSelected = (m: TipoOrigen) => wizard.tipoOrigen === m
|
||||
const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => {
|
||||
const key = e.key
|
||||
if (
|
||||
@@ -41,19 +37,21 @@ export function PasoModoCardGroup({
|
||||
<Card
|
||||
className={isSelected('MANUAL') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
modoCreacion: 'MANUAL',
|
||||
subModoClonado: undefined,
|
||||
}))
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'MANUAL',
|
||||
}),
|
||||
)
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
modoCreacion: 'MANUAL',
|
||||
subModoClonado: undefined,
|
||||
})),
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'MANUAL',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
@@ -70,19 +68,21 @@ export function PasoModoCardGroup({
|
||||
<Card
|
||||
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
modoCreacion: 'IA',
|
||||
subModoClonado: undefined,
|
||||
}))
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'IA',
|
||||
}),
|
||||
)
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
modoCreacion: 'IA',
|
||||
subModoClonado: undefined,
|
||||
})),
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'IA',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
@@ -99,11 +99,13 @@ export function PasoModoCardGroup({
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
|
||||
className={isSelected('OTRO') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() =>
|
||||
onChange((w): NewPlanWizardState => ({ ...w, tipoOrigen: 'OTRO' }))
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({ ...w, modoCreacion: 'CLONADO' })),
|
||||
onChange((w): NewPlanWizardState => ({ ...w, tipoOrigen: 'OTRO' })),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
@@ -115,22 +117,34 @@ export function PasoModoCardGroup({
|
||||
</CardTitle>
|
||||
<CardDescription>Desde un plan existente o archivos.</CardDescription>
|
||||
</CardHeader>
|
||||
{wizard.modoCreacion === 'CLONADO' && (
|
||||
{(wizard.tipoOrigen === 'OTRO' ||
|
||||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
||||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL') && (
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' }))
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'CLONADO_INTERNO',
|
||||
}),
|
||||
)
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' })),
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'CLONADO_INTERNO',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
|
||||
isSubSelected('INTERNO')
|
||||
isSelected('CLONADO_INTERNO')
|
||||
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
||||
: 'border-border text-muted-foreground'
|
||||
} `}
|
||||
@@ -144,15 +158,25 @@ export function PasoModoCardGroup({
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' }))
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'CLONADO_TRADICIONAL',
|
||||
}),
|
||||
)
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' })),
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
...w,
|
||||
tipoOrigen: 'CLONADO_TRADICIONAL',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
|
||||
isSubSelected('TRADICIONAL')
|
||||
isSelected('CLONADO_TRADICIONAL')
|
||||
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
||||
: 'border-border text-muted-foreground'
|
||||
} `}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UploadedFile } from './PasoDetallesPanel/FileDropZone'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
import {
|
||||
@@ -8,12 +9,11 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
PLANTILLAS_ANEXO_1,
|
||||
PLANTILLAS_ANEXO_2,
|
||||
PLANES_EXISTENTES,
|
||||
ARCHIVOS,
|
||||
REPOSITORIOS,
|
||||
} from '@/features/planes/nuevo/catalogs'
|
||||
import { formatFileSize } from '@/features/planes/utils/format-file-size'
|
||||
|
||||
export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
return (
|
||||
@@ -32,12 +32,6 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
const repositoriosRef =
|
||||
wizard.iaConfig?.repositoriosReferencia ?? []
|
||||
const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? []
|
||||
const plantillaPlan = PLANTILLAS_ANEXO_1.find(
|
||||
(x) => x.id === wizard.datosBasicos.plantillaPlanId,
|
||||
)
|
||||
const plantillaMapa = PLANTILLAS_ANEXO_2.find(
|
||||
(x) => x.id === wizard.datosBasicos.plantillaMapaId,
|
||||
)
|
||||
const contenido = (
|
||||
<>
|
||||
<div>
|
||||
@@ -51,8 +45,8 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
Facultad/Carrera:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{wizard.datosBasicos.facultadId || '—'} /{' '}
|
||||
{wizard.datosBasicos.carreraId || '—'}
|
||||
{wizard.datosBasicos.facultad.nombre || '—'} /{' '}
|
||||
{wizard.datosBasicos.carrera.nombre || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -68,100 +62,67 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
{wizard.datosBasicos.tipoCiclo})
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">
|
||||
Plantilla plan:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{(plantillaPlan?.name ||
|
||||
wizard.datosBasicos.plantillaPlanId ||
|
||||
'—') +
|
||||
' · ' +
|
||||
(wizard.datosBasicos.plantillaPlanVersion || '—')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Mapa curricular:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{(plantillaMapa?.name ||
|
||||
wizard.datosBasicos.plantillaMapaId ||
|
||||
'—') +
|
||||
' · ' +
|
||||
(wizard.datosBasicos.plantillaMapaVersion || '—')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">Modo: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.modoCreacion === 'MANUAL' && 'Manual'}
|
||||
{wizard.modoCreacion === 'IA' && 'Generado con IA'}
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'INTERNO' &&
|
||||
{wizard.tipoOrigen === 'MANUAL' && 'Manual'}
|
||||
{wizard.tipoOrigen === 'IA' && 'Generado con IA'}
|
||||
{wizard.tipoOrigen === 'CLONADO_INTERNO' &&
|
||||
'Clonado desde plan del sistema'}
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'TRADICIONAL' &&
|
||||
{wizard.tipoOrigen === 'CLONADO_TRADICIONAL' &&
|
||||
'Importado desde documentos tradicionales'}
|
||||
</span>
|
||||
</div>
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'INTERNO' && (
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">
|
||||
Plan origen:{' '}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{(() => {
|
||||
const p = PLANES_EXISTENTES.find(
|
||||
(x) => x.id === wizard.clonInterno?.planOrigenId,
|
||||
)
|
||||
return (
|
||||
p?.nombre || wizard.clonInterno?.planOrigenId || '—'
|
||||
)
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{wizard.modoCreacion === 'CLONADO' &&
|
||||
wizard.subModoClonado === 'TRADICIONAL' && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Documentos adjuntos</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
<li>
|
||||
<span className="text-foreground">
|
||||
Word del plan:
|
||||
</span>{' '}
|
||||
{wizard.clonTradicional?.archivoWordPlanId?.name ||
|
||||
'—'}
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground">
|
||||
Mapa curricular:
|
||||
</span>{' '}
|
||||
{wizard.clonTradicional?.archivoMapaExcelId?.name ||
|
||||
'—'}
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground">Asignaturas:</span>{' '}
|
||||
{wizard.clonTradicional?.archivoAsignaturasExcelId
|
||||
?.name || '—'}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{wizard.modoCreacion === 'IA' && (
|
||||
{wizard.tipoOrigen === 'CLONADO_INTERNO' && (
|
||||
<div className="mt-2">
|
||||
<span className="text-muted-foreground">Plan origen: </span>
|
||||
<span className="font-medium">
|
||||
{(() => {
|
||||
const p = PLANES_EXISTENTES.find(
|
||||
(x) => x.id === wizard.clonInterno?.planOrigenId,
|
||||
)
|
||||
return (
|
||||
p?.nombre || wizard.clonInterno?.planOrigenId || '—'
|
||||
)
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{wizard.tipoOrigen === 'CLONADO_TRADICIONAL' && (
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Documentos adjuntos</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
<li>
|
||||
<span className="text-foreground">Word del plan:</span>{' '}
|
||||
{wizard.clonTradicional?.archivoWordPlanId?.name || '—'}
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground">
|
||||
Mapa curricular:
|
||||
</span>{' '}
|
||||
{wizard.clonTradicional?.archivoMapaExcelId?.name ||
|
||||
'—'}
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground">Asignaturas:</span>{' '}
|
||||
{wizard.clonTradicional?.archivoAsignaturasExcelId
|
||||
?.name || '—'}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{wizard.tipoOrigen === 'IA' && (
|
||||
<div className="bg-muted/50 mt-2 rounded-md p-3">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Enfoque: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.iaConfig?.descripcionEnfoque || '—'}
|
||||
{wizard.iaConfig?.descripcionEnfoqueAcademico || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Notas: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.iaConfig?.notasAdicionales || '—'}
|
||||
{wizard.iaConfig?.instruccionesAdicionalesIA || '—'}
|
||||
</span>
|
||||
</div>
|
||||
{archivosRef.length > 0 && (
|
||||
@@ -206,10 +167,12 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Adjuntos</div>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||
{adjuntos.map((f) => (
|
||||
{adjuntos.map((f: UploadedFile) => (
|
||||
<li key={f.id}>
|
||||
<span className="text-foreground">{f.name}</span>{' '}
|
||||
<span>· {f.size}</span>
|
||||
<span className="text-foreground">
|
||||
{f.file.name}
|
||||
</span>{' '}
|
||||
<span>· {formatFileSize(f.file.size)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,47 +1,313 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { AIGeneratePlanInput } from '@/data'
|
||||
import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
// import type { Database } from '@/types/supabase'
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { plans_get_maybe } from '@/data/api/plans.api'
|
||||
import {
|
||||
useCreatePlanManual,
|
||||
useDeletePlanEstudio,
|
||||
useGeneratePlanAI,
|
||||
} from '@/data/hooks/usePlans'
|
||||
import { supabaseBrowser } from '@/data/supabase/client'
|
||||
|
||||
export function WizardControls({
|
||||
errorMessage,
|
||||
onPrev,
|
||||
onNext,
|
||||
onCreate,
|
||||
disablePrev,
|
||||
disableNext,
|
||||
disableCreate,
|
||||
isLastStep,
|
||||
wizard,
|
||||
setWizard,
|
||||
}: {
|
||||
errorMessage?: string | null
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
onCreate: () => void
|
||||
disablePrev: boolean
|
||||
disableNext: boolean
|
||||
disableCreate: boolean
|
||||
isLastStep: boolean
|
||||
wizard: NewPlanWizardState
|
||||
setWizard: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const generatePlanAI = useGeneratePlanAI()
|
||||
const createPlanManual = useCreatePlanManual()
|
||||
const deletePlan = useDeletePlanEstudio()
|
||||
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
||||
const cancelledRef = useRef(false)
|
||||
const realtimeChannelRef = useRef<RealtimeChannel | null>(null)
|
||||
const watchPlanIdRef = useRef<string | null>(null)
|
||||
const watchTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
cancelledRef.current = false
|
||||
return () => {
|
||||
cancelledRef.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopPlanWatch = useCallback(() => {
|
||||
if (watchTimeoutRef.current) {
|
||||
window.clearTimeout(watchTimeoutRef.current)
|
||||
watchTimeoutRef.current = null
|
||||
}
|
||||
|
||||
watchPlanIdRef.current = null
|
||||
|
||||
const ch = realtimeChannelRef.current
|
||||
if (ch) {
|
||||
realtimeChannelRef.current = null
|
||||
try {
|
||||
supabaseBrowser().removeChannel(ch)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPlanWatch()
|
||||
}
|
||||
}, [stopPlanWatch])
|
||||
|
||||
const checkPlanStateAndAct = useCallback(
|
||||
async (planId: string) => {
|
||||
if (cancelledRef.current) return
|
||||
if (watchPlanIdRef.current !== planId) return
|
||||
|
||||
const plan = await plans_get_maybe(planId as any)
|
||||
if (!plan) return
|
||||
|
||||
const clave = String(plan.estados_plan?.clave ?? '').toUpperCase()
|
||||
|
||||
if (clave.startsWith('GENERANDO')) return
|
||||
|
||||
if (clave.startsWith('BORRADOR')) {
|
||||
stopPlanWatch()
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({ ...w, isLoading: false }))
|
||||
navigate({
|
||||
to: `/planes/${plan.id}`,
|
||||
state: { showConfetti: true },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (clave.startsWith('FALLID')) {
|
||||
stopPlanWatch()
|
||||
setIsSpinningIA(false)
|
||||
|
||||
deletePlan
|
||||
.mutateAsync(plan.id)
|
||||
.catch(() => {
|
||||
// Si falla el borrado, igual mostramos el error.
|
||||
})
|
||||
.finally(() => {
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage: 'La generación del plan falló',
|
||||
}))
|
||||
})
|
||||
}
|
||||
},
|
||||
[deletePlan, navigate, setWizard, stopPlanWatch],
|
||||
)
|
||||
|
||||
const beginPlanWatch = useCallback(
|
||||
(planId: string) => {
|
||||
stopPlanWatch()
|
||||
watchPlanIdRef.current = planId
|
||||
|
||||
watchTimeoutRef.current = window.setTimeout(
|
||||
() => {
|
||||
if (cancelledRef.current) return
|
||||
if (watchPlanIdRef.current !== planId) return
|
||||
|
||||
stopPlanWatch()
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage:
|
||||
'La generación está tardando demasiado. Intenta de nuevo en unos minutos.',
|
||||
}))
|
||||
},
|
||||
6 * 60 * 1000,
|
||||
)
|
||||
|
||||
const supabase = supabaseBrowser()
|
||||
const channel = supabase.channel(`planes-status-${planId}`)
|
||||
realtimeChannelRef.current = channel
|
||||
|
||||
channel.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'planes_estudio',
|
||||
filter: `id=eq.${planId}`,
|
||||
},
|
||||
() => {
|
||||
void checkPlanStateAndAct(planId)
|
||||
},
|
||||
)
|
||||
|
||||
channel.subscribe((status) => {
|
||||
const st = status as
|
||||
| 'SUBSCRIBED'
|
||||
| 'TIMED_OUT'
|
||||
| 'CLOSED'
|
||||
| 'CHANNEL_ERROR'
|
||||
if (cancelledRef.current) return
|
||||
if (st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
|
||||
stopPlanWatch()
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage:
|
||||
'No se pudo suscribir al estado del plan. Intenta de nuevo.',
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback inmediato por si el plan ya cambió antes de suscribir.
|
||||
void checkPlanStateAndAct(planId)
|
||||
},
|
||||
[checkPlanStateAndAct, setWizard, stopPlanWatch],
|
||||
)
|
||||
|
||||
const handleCreate = async () => {
|
||||
// Start loading
|
||||
setWizard(
|
||||
(w: NewPlanWizardState): NewPlanWizardState => ({
|
||||
...w,
|
||||
isLoading: true,
|
||||
errorMessage: null,
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
if (wizard.tipoOrigen === 'IA') {
|
||||
const tipoCicloSafe = (wizard.datosBasicos.tipoCiclo ||
|
||||
'Semestre') as any
|
||||
const numCiclosSafe =
|
||||
typeof wizard.datosBasicos.numCiclos === 'number'
|
||||
? wizard.datosBasicos.numCiclos
|
||||
: 1
|
||||
|
||||
const aiInput: AIGeneratePlanInput = {
|
||||
datosBasicos: {
|
||||
nombrePlan: wizard.datosBasicos.nombrePlan,
|
||||
carreraId: wizard.datosBasicos.carrera.id,
|
||||
facultadId: wizard.datosBasicos.facultad.id,
|
||||
nivel: wizard.datosBasicos.nivel as string,
|
||||
tipoCiclo: tipoCicloSafe,
|
||||
numCiclos: numCiclosSafe,
|
||||
estructuraPlanId: wizard.datosBasicos.estructuraPlanId as string,
|
||||
},
|
||||
iaConfig: {
|
||||
descripcionEnfoqueAcademico:
|
||||
wizard.iaConfig?.descripcionEnfoqueAcademico || '',
|
||||
instruccionesAdicionalesIA:
|
||||
wizard.iaConfig?.instruccionesAdicionalesIA || '',
|
||||
archivosReferencia: wizard.iaConfig?.archivosReferencia || [],
|
||||
repositoriosIds: wizard.iaConfig?.repositoriosReferencia || [],
|
||||
archivosAdjuntos: wizard.iaConfig?.archivosAdjuntos || [],
|
||||
},
|
||||
}
|
||||
|
||||
console.log(`${new Date().toISOString()} - Enviando a generar plan IA`)
|
||||
|
||||
setIsSpinningIA(true)
|
||||
const resp: any = await generatePlanAI.mutateAsync(aiInput as any)
|
||||
const planId = resp?.plan?.id ?? resp?.id
|
||||
console.log(`${new Date().toISOString()} - Plan IA generado`, resp)
|
||||
|
||||
if (!planId) {
|
||||
throw new Error('No se pudo obtener el id del plan generado por IA')
|
||||
}
|
||||
|
||||
// Inicia realtime; los efectos navegan o marcan error.
|
||||
beginPlanWatch(String(planId))
|
||||
return
|
||||
}
|
||||
|
||||
if (wizard.tipoOrigen === 'MANUAL') {
|
||||
// Crear plan vacío manualmente usando el hook
|
||||
const plan = await createPlanManual.mutateAsync({
|
||||
carreraId: wizard.datosBasicos.carrera.id,
|
||||
estructuraId: wizard.datosBasicos.estructuraPlanId as string,
|
||||
nombre: wizard.datosBasicos.nombrePlan,
|
||||
nivel: wizard.datosBasicos.nivel as NivelPlanEstudio,
|
||||
tipoCiclo: wizard.datosBasicos.tipoCiclo as TipoCiclo,
|
||||
numCiclos: (wizard.datosBasicos.numCiclos as number) || 1,
|
||||
datos: {},
|
||||
})
|
||||
|
||||
// Navegar al nuevo plan
|
||||
navigate({
|
||||
to: `/planes/${plan.id}`,
|
||||
state: { showConfetti: true },
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
setIsSpinningIA(false)
|
||||
stopPlanWatch()
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage: err?.message ?? 'Error generando el plan',
|
||||
}))
|
||||
} finally {
|
||||
// Si entramos en watch realtime, el loading se corta desde checkPlanStateAndAct.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex grow items-center justify-between">
|
||||
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
|
||||
Anterior
|
||||
</Button>
|
||||
<div className="mx-2 flex-1">
|
||||
{errorMessage && (
|
||||
<span className="text-destructive text-sm font-medium">
|
||||
{errorMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
|
||||
Anterior
|
||||
</Button>
|
||||
{isLastStep ? (
|
||||
<Button onClick={onCreate} disabled={disableCreate}>
|
||||
Crear plan
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onNext} disabled={disableNext}>
|
||||
Siguiente
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="mx-2 flex w-5 items-center justify-center">
|
||||
<Loader2
|
||||
className={
|
||||
wizard.tipoOrigen === 'IA' && isSpinningIA
|
||||
? 'text-muted-foreground h-6 w-6 animate-spin'
|
||||
: 'h-6 w-6 opacity-0'
|
||||
}
|
||||
aria-hidden={!(wizard.tipoOrigen === 'IA' && isSpinningIA)}
|
||||
/>
|
||||
</div>
|
||||
{isLastStep ? (
|
||||
<Button onClick={handleCreate} disabled={disableCreate}>
|
||||
Crear plan
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onNext} disabled={disableNext}>
|
||||
Siguiente
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
44
src/components/ui/NotFoundPage.tsx
Normal file
44
src/components/ui/NotFoundPage.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Link, useRouter } from '@tanstack/react-router'
|
||||
import { FileQuestion, Home, ArrowLeft } from 'lucide-react'
|
||||
|
||||
import { Button } from './button'
|
||||
|
||||
interface NotFoundPageProps {
|
||||
title?: string
|
||||
message?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function NotFoundPage({
|
||||
title = 'Página no encontrada',
|
||||
message = 'Lo sentimos, no pudimos encontrar lo que buscabas. Es posible que la página haya sido movida o eliminada.',
|
||||
children,
|
||||
}: NotFoundPageProps) {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center p-4 text-center">
|
||||
<div className="bg-muted mb-6 rounded-full p-6">
|
||||
<FileQuestion className="text-muted-foreground h-12 w-12" />
|
||||
</div>
|
||||
|
||||
<h1 className="mb-2 text-3xl font-bold tracking-tight">{title}</h1>
|
||||
<p className="text-muted-foreground mb-8 max-w-125">{message}</p>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button variant="outline" onClick={() => router.history.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Regresar
|
||||
</Button>
|
||||
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
Ir al inicio
|
||||
</Link>
|
||||
</Button>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
src/components/ui/accordion.tsx
Normal file
64
src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
250
src/components/ui/context-menu.tsx
Normal file
250
src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
133
src/components/ui/drawer.tsx
Normal file
133
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
48
src/components/ui/lateral-confetti.tsx
Normal file
48
src/components/ui/lateral-confetti.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// src/components/ui/lateral-confetti.tsx
|
||||
|
||||
import confetti from 'canvas-confetti'
|
||||
|
||||
export function lateralConfetti() {
|
||||
// 1. Reset para limpiar cualquier configuración vieja pegada en memoria
|
||||
confetti.reset()
|
||||
|
||||
const duration = 1500
|
||||
const end = Date.now() + duration
|
||||
|
||||
// 2. Colores vibrantes (cálidos primero)
|
||||
const vibrantColors = [
|
||||
'#FF0000', // Rojo puro
|
||||
'#fcff42', // Amarillo
|
||||
'#88ff5a', // Verde
|
||||
'#26ccff', // Azul
|
||||
'#a25afd', // Morado
|
||||
]
|
||||
|
||||
;(function frame() {
|
||||
const commonSettings = {
|
||||
particleCount: 5,
|
||||
spread: 55,
|
||||
// origin: { x: 0.5 }, // No necesario si definimos origin abajo, pero útil en otros contextos
|
||||
colors: vibrantColors,
|
||||
zIndex: 99999,
|
||||
}
|
||||
|
||||
// Cañón izquierdo
|
||||
confetti({
|
||||
...commonSettings,
|
||||
angle: 60,
|
||||
origin: { x: 0, y: 0.6 },
|
||||
})
|
||||
|
||||
// Cañón derecho
|
||||
confetti({
|
||||
...commonSettings,
|
||||
angle: 120,
|
||||
origin: { x: 1, y: 0.6 },
|
||||
})
|
||||
|
||||
if (Date.now() < end) {
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
})()
|
||||
}
|
||||
43
src/components/ui/radio-group.tsx
Normal file
43
src/components/ui/radio-group.tsx
Normal file
@@ -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 }
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -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 }
|
||||
|
||||
41
src/components/wizard/StepWithTooltip.tsx
Normal file
41
src/components/wizard/StepWithTooltip.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
export function StepWithTooltip({
|
||||
title,
|
||||
desc,
|
||||
}: {
|
||||
title: string
|
||||
desc: string
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="cursor-help decoration-dotted underline-offset-4 hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsOpen((prev) => !prev)
|
||||
}}
|
||||
onMouseEnter={() => setIsOpen(true)}
|
||||
onMouseLeave={() => setIsOpen(false)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-50 text-xs">
|
||||
<p>{desc}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
52
src/components/wizard/WizardLayout.tsx
Normal file
52
src/components/wizard/WizardLayout.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as Icons from 'lucide-react'
|
||||
|
||||
import { CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
|
||||
export function WizardLayout({
|
||||
title,
|
||||
onClose,
|
||||
headerSlot,
|
||||
footerSlot,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
onClose: () => void
|
||||
headerSlot?: React.ReactNode
|
||||
footerSlot?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div className="z-10 flex-none border-b bg-white">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-4">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none"
|
||||
>
|
||||
<Icons.X className="h-4 w-4" />
|
||||
<span className="sr-only">Cerrar</span>
|
||||
</button>
|
||||
</CardHeader>
|
||||
|
||||
{headerSlot ? <div className="px-6 pb-6">{headerSlot}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footerSlot ? (
|
||||
<div className="flex-none border-t bg-white p-6">{footerSlot}</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
75
src/components/wizard/WizardResponsiveHeader.tsx
Normal file
75
src/components/wizard/WizardResponsiveHeader.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { CircularProgress } from '@/components/CircularProgress'
|
||||
import { StepWithTooltip } from '@/components/wizard/StepWithTooltip'
|
||||
|
||||
export function WizardResponsiveHeader({
|
||||
wizard,
|
||||
methods,
|
||||
titleOverrides,
|
||||
hiddenStepIds,
|
||||
}: {
|
||||
wizard: any
|
||||
methods: any
|
||||
titleOverrides?: Record<string, string>
|
||||
hiddenStepIds?: Array<string>
|
||||
}) {
|
||||
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
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="block sm:hidden">
|
||||
<div className="flex items-center gap-5">
|
||||
<CircularProgress current={currentIndex} total={totalSteps} />
|
||||
<div className="flex flex-col justify-center">
|
||||
<h2 className="text-lg font-bold text-slate-900">
|
||||
<StepWithTooltip
|
||||
title={resolveTitle(methods.current)}
|
||||
desc={methods.current.description}
|
||||
/>
|
||||
</h2>
|
||||
{hasNextStep && nextStep ? (
|
||||
<p className="text-sm text-slate-400">
|
||||
Siguiente: {resolveTitle(nextStep)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm font-medium text-green-500">
|
||||
¡Último paso!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block">
|
||||
<wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
|
||||
{visibleSteps.map((step: any, visibleIdx: number) => (
|
||||
<wizard.Stepper.Step
|
||||
key={step.id}
|
||||
of={step.id}
|
||||
icon={visibleIdx + 1}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
<wizard.Stepper.Title>
|
||||
<StepWithTooltip
|
||||
title={resolveTitle(step)}
|
||||
desc={step.description}
|
||||
/>
|
||||
</wizard.Stepper.Title>
|
||||
</wizard.Stepper.Step>
|
||||
))}
|
||||
</wizard.Stepper.Navigation>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,45 +1,56 @@
|
||||
import type { PostgrestError, AuthError, SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "../types/database";
|
||||
import type { Database } from '../types/database'
|
||||
import type {
|
||||
PostgrestError,
|
||||
AuthError,
|
||||
SupabaseClient,
|
||||
} from '@supabase/supabase-js'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code?: string,
|
||||
public readonly details?: unknown,
|
||||
public readonly hint?: string
|
||||
public readonly hint?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
export function throwIfError(error: PostgrestError | AuthError | null): void {
|
||||
if (!error) return;
|
||||
|
||||
const anyErr = error as any;
|
||||
if (!error) return
|
||||
const anyErr = error as any
|
||||
throw new ApiError(
|
||||
anyErr.message ?? "Error inesperado",
|
||||
anyErr.message ?? 'Error inesperado',
|
||||
anyErr.code,
|
||||
anyErr.details,
|
||||
anyErr.hint
|
||||
);
|
||||
anyErr.hint,
|
||||
)
|
||||
}
|
||||
|
||||
export function requireData<T>(data: T | null | undefined, message = "Respuesta vacía"): T {
|
||||
if (data === null || data === undefined) throw new ApiError(message);
|
||||
return data;
|
||||
export function requireData<T>(
|
||||
data: T | null | undefined,
|
||||
message = 'Respuesta vacía',
|
||||
): T {
|
||||
if (data === null || data === undefined) throw new ApiError(message)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUserIdOrThrow(supabase: SupabaseClient<Database>): Promise<string> {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
throwIfError(error);
|
||||
if (!data?.user?.id) throw new ApiError("No hay sesión activa (auth).");
|
||||
return data.user.id;
|
||||
export async function getUserIdOrThrow(
|
||||
supabase: SupabaseClient<Database>,
|
||||
): Promise<string> {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
throwIfError(error)
|
||||
if (!data?.user?.id) throw new ApiError('No hay sesión activa (auth).')
|
||||
return data.user.id
|
||||
}
|
||||
|
||||
export function buildRange(limit?: number, offset?: number): { from?: number; to?: number } {
|
||||
if (!limit) return {};
|
||||
const from = Math.max(0, offset ?? 0);
|
||||
const to = from + Math.max(1, limit) - 1;
|
||||
return { from, to };
|
||||
export function buildRange(
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
): { from?: number; to?: number } {
|
||||
if (!limit) return {}
|
||||
const from = Math.max(0, offset ?? 0)
|
||||
const to = from + Math.max(1, limit) - 1
|
||||
return { from, to }
|
||||
}
|
||||
|
||||
@@ -1,81 +1,377 @@
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import type { InteraccionIA, UUID } from "../types/domain";
|
||||
import { supabaseBrowser } from '../supabase/client'
|
||||
import { invokeEdge } from '../supabase/invokeEdge'
|
||||
|
||||
import type { InteraccionIA, UUID } from '../types/domain'
|
||||
|
||||
const EDGE = {
|
||||
ai_plan_improve: "ai_plan_improve",
|
||||
ai_plan_chat: "ai_plan_chat",
|
||||
ai_subject_improve: "ai_subject_improve",
|
||||
ai_subject_chat: "ai_subject_chat",
|
||||
ai_plan_improve: 'ai_plan_improve',
|
||||
ai_plan_chat: 'ai_plan_chat',
|
||||
ai_subject_improve: 'ai_subject_improve',
|
||||
ai_subject_chat: 'ai_subject_chat',
|
||||
|
||||
library_search: "library_search",
|
||||
} as const;
|
||||
library_search: 'library_search',
|
||||
} as const
|
||||
|
||||
export async function ai_plan_improve(payload: {
|
||||
planId: UUID;
|
||||
sectionKey: string; // ej: "perfil_de_egreso" o tu key interna
|
||||
prompt: string;
|
||||
context?: Record<string, any>;
|
||||
planId: UUID
|
||||
sectionKey: string // ej: "perfil_de_egreso" o tu key interna
|
||||
prompt: string
|
||||
context?: Record<string, any>
|
||||
fuentes?: {
|
||||
archivosIds?: UUID[];
|
||||
vectorStoresIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
conversacionId?: string;
|
||||
};
|
||||
archivosIds?: Array<UUID>
|
||||
vectorStoresIds?: Array<UUID>
|
||||
usarMCP?: boolean
|
||||
conversacionId?: string
|
||||
}
|
||||
}): Promise<{ interaccion: InteraccionIA; propuesta: any }> {
|
||||
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(EDGE.ai_plan_improve, payload);
|
||||
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(
|
||||
EDGE.ai_plan_improve,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export async function ai_plan_chat(payload: {
|
||||
planId: UUID;
|
||||
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
|
||||
planId: UUID
|
||||
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
|
||||
fuentes?: {
|
||||
archivosIds?: UUID[];
|
||||
vectorStoresIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
conversacionId?: string;
|
||||
};
|
||||
archivosIds?: Array<UUID>
|
||||
vectorStoresIds?: Array<UUID>
|
||||
usarMCP?: boolean
|
||||
conversacionId?: string
|
||||
}
|
||||
}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> {
|
||||
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(EDGE.ai_plan_chat, payload);
|
||||
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(
|
||||
EDGE.ai_plan_chat,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export async function ai_subject_improve(payload: {
|
||||
subjectId: UUID;
|
||||
sectionKey: string;
|
||||
prompt: string;
|
||||
context?: Record<string, any>;
|
||||
subjectId: UUID
|
||||
sectionKey: string
|
||||
prompt: string
|
||||
context?: Record<string, any>
|
||||
fuentes?: {
|
||||
archivosIds?: UUID[];
|
||||
vectorStoresIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
conversacionId?: string;
|
||||
};
|
||||
archivosIds?: Array<UUID>
|
||||
vectorStoresIds?: Array<UUID>
|
||||
usarMCP?: boolean
|
||||
conversacionId?: string
|
||||
}
|
||||
}): Promise<{ interaccion: InteraccionIA; propuesta: any }> {
|
||||
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(EDGE.ai_subject_improve, payload);
|
||||
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(
|
||||
EDGE.ai_subject_improve,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export async function ai_subject_chat(payload: {
|
||||
subjectId: UUID;
|
||||
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
|
||||
subjectId: UUID
|
||||
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
|
||||
fuentes?: {
|
||||
archivosIds?: UUID[];
|
||||
vectorStoresIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
conversacionId?: string;
|
||||
};
|
||||
archivosIds?: Array<UUID>
|
||||
vectorStoresIds?: Array<UUID>
|
||||
usarMCP?: boolean
|
||||
conversacionId?: string
|
||||
}
|
||||
}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> {
|
||||
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(EDGE.ai_subject_chat, payload);
|
||||
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(
|
||||
EDGE.ai_subject_chat,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
/** Biblioteca (Edge; adapta a tu API real) */
|
||||
export type LibraryItem = {
|
||||
id: string;
|
||||
titulo: string;
|
||||
autor?: string;
|
||||
isbn?: string;
|
||||
citaSugerida?: string;
|
||||
disponibilidad?: string;
|
||||
};
|
||||
|
||||
export async function library_search(payload: { query: string; limit?: number }): Promise<LibraryItem[]> {
|
||||
return invokeEdge<LibraryItem[]>(EDGE.library_search, payload);
|
||||
id: string
|
||||
titulo: string
|
||||
autor?: string
|
||||
isbn?: string
|
||||
citaSugerida?: string
|
||||
disponibilidad?: string
|
||||
}
|
||||
|
||||
export async function library_search(payload: {
|
||||
query: string
|
||||
limit?: number
|
||||
}): Promise<Array<LibraryItem>> {
|
||||
return invokeEdge<Array<LibraryItem>>(EDGE.library_search, payload)
|
||||
}
|
||||
|
||||
export async function create_conversation(planId: string) {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
'create-chat-conversation/plan/conversations',
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
plan_estudio_id: planId, // O el nombre que confirmamos que funciona
|
||||
instanciador: 'alex',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export async function get_chat_history(conversacionId: string) {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
`create-chat-conversation/conversations/${conversacionId}/messages`,
|
||||
{ method: 'GET' },
|
||||
)
|
||||
if (error) throw error
|
||||
return data // Retorna Array de mensajes
|
||||
}
|
||||
|
||||
export async function update_conversation_status(
|
||||
conversacionId: string,
|
||||
nuevoEstado: 'ARCHIVADA' | 'ACTIVA',
|
||||
) {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('conversaciones_plan') // Asegúrate que el nombre de la tabla sea exacto
|
||||
.update({ estado: nuevoEstado })
|
||||
.eq('id', conversacionId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
// Modificamos la función de chat para que use la ruta de mensajes
|
||||
export async function ai_plan_chat_v2(payload: {
|
||||
conversacionId: string
|
||||
content: string
|
||||
campos?: Array<string>
|
||||
}): Promise<{ reply: string; meta?: any }> {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
`create-chat-conversation/conversations/plan/${payload.conversacionId}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
content: payload.content,
|
||||
campos: payload.campos || [],
|
||||
},
|
||||
},
|
||||
)
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getConversationByPlan(planId: string) {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('conversaciones_plan')
|
||||
.select('*')
|
||||
.eq('plan_estudio_id', planId)
|
||||
.order('creado_en', { ascending: false })
|
||||
if (error) throw error
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
return data ?? []
|
||||
}
|
||||
export async function 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,
|
||||
nuevoTitulo: string,
|
||||
) {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('conversaciones_plan')
|
||||
.update({ nombre: nuevoTitulo }) // Asegúrate que la columna se llame 'title' o 'nombre'
|
||||
.eq('id', conversacionId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export async function update_recommendation_applied_status(
|
||||
mensajeId: string, // Ahora es más eficiente usar el ID del mensaje directamente
|
||||
campoAfectado: string,
|
||||
) {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
// 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 (!msgData?.propuesta)
|
||||
throw new Error('No se encontró la propuesta en el mensaje')
|
||||
|
||||
const propuestaActual = msgData.propuesta as any
|
||||
|
||||
// 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 (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
|
||||
}
|
||||
|
||||
52
src/data/api/document.api.ts
Normal file
52
src/data/api/document.api.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// document.api.ts
|
||||
|
||||
const DOCUMENT_PDF_URL =
|
||||
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
|
||||
|
||||
const DOCUMENT_PDF_ASIGNATURA_URL =
|
||||
'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d'
|
||||
|
||||
interface GeneratePdfParams {
|
||||
plan_estudio_id: string
|
||||
}
|
||||
interface GeneratePdfParamsAsignatura {
|
||||
asignatura_id: string
|
||||
}
|
||||
|
||||
export async function fetchPlanPdf({
|
||||
plan_estudio_id,
|
||||
}: GeneratePdfParams): Promise<Blob> {
|
||||
const response = await fetch(DOCUMENT_PDF_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
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()
|
||||
}
|
||||
|
||||
export async function fetchAsignaturaPdf({
|
||||
asignatura_id,
|
||||
}: GeneratePdfParamsAsignatura): Promise<Blob> {
|
||||
const response = await fetch(DOCUMENT_PDF_ASIGNATURA_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ asignatura_id }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Error al generar el PDF')
|
||||
}
|
||||
|
||||
// n8n devuelve el archivo → lo tratamos como blob
|
||||
return await response.blob()
|
||||
}
|
||||
@@ -1,32 +1,32 @@
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import type { UUID } from "../types/domain";
|
||||
import { invokeEdge } from '../supabase/invokeEdge'
|
||||
import type { UUID } from '../types/domain'
|
||||
|
||||
/**
|
||||
* Metadata “canónica” para UI (archivo OpenAI + espejo en Supabase)
|
||||
* Se apoya en tu tabla `archivos`.
|
||||
*/
|
||||
export type AppFile = {
|
||||
id: UUID; // id interno (tabla archivos)
|
||||
openai_file_id: string; // id OpenAI
|
||||
nombre: string;
|
||||
mime_type: string | null;
|
||||
bytes: number | null;
|
||||
id: UUID // id interno (tabla archivos)
|
||||
openai_file_id: string // id OpenAI
|
||||
nombre: string
|
||||
mime_type: string | null
|
||||
bytes: number | null
|
||||
|
||||
// espejo Supabase para preview/descarga
|
||||
ruta_storage: string | null; // "bucket/path"
|
||||
signed_url?: string | null;
|
||||
ruta_storage: string | null // "bucket/path"
|
||||
signed_url?: string | null
|
||||
|
||||
// auditoría/evidencia
|
||||
temporal: boolean;
|
||||
notas?: string | null;
|
||||
temporal: boolean
|
||||
notas?: string | null
|
||||
|
||||
subido_en: string;
|
||||
};
|
||||
subido_en: string
|
||||
}
|
||||
|
||||
const EDGE = {
|
||||
upload: "openai_files_upload",
|
||||
remove: "openai_files_delete",
|
||||
} as const;
|
||||
upload: 'openai_files_upload',
|
||||
remove: 'openai_files_delete',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Sube archivo a OpenAI y (opcional) crea espejo en Storage
|
||||
@@ -37,28 +37,28 @@ export async function openai_files_upload(payload: {
|
||||
* Si tu Edge soporta multipart: manda File/Blob directo.
|
||||
* Si no, manda base64/bytes (según tu implementación).
|
||||
*/
|
||||
file: File;
|
||||
file: File
|
||||
|
||||
/** “temporal” = evidencia usada para generar plan/materia */
|
||||
temporal?: boolean;
|
||||
/** “temporal” = evidencia usada para generar plan/asignatura */
|
||||
temporal?: boolean
|
||||
|
||||
/** contexto para auditoría */
|
||||
contexto?: {
|
||||
planId?: UUID;
|
||||
asignaturaId?: UUID;
|
||||
motivo?: "WIZARD_PLAN" | "WIZARD_MATERIA" | "ADHOC";
|
||||
};
|
||||
planId?: UUID
|
||||
asignaturaId?: UUID
|
||||
motivo?: 'WIZARD_PLAN' | 'WIZARD_MATERIA' | 'ADHOC'
|
||||
}
|
||||
|
||||
/** si quieres forzar espejo para preview siempre */
|
||||
mirrorToSupabase?: boolean;
|
||||
mirrorToSupabase?: boolean
|
||||
}): Promise<AppFile> {
|
||||
return invokeEdge<AppFile>(EDGE.upload, payload);
|
||||
return invokeEdge<AppFile>(EDGE.upload, payload)
|
||||
}
|
||||
|
||||
export async function openai_files_delete(payload: {
|
||||
openaiFileId: string;
|
||||
openaiFileId: string
|
||||
/** si quieres borrar también espejo y registro */
|
||||
hardDelete?: boolean;
|
||||
hardDelete?: boolean
|
||||
}): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.remove, payload);
|
||||
return invokeEdge<{ ok: true }>(EDGE.remove, payload)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import { supabaseBrowser } from '../supabase/client'
|
||||
import { invokeEdge } from '../supabase/invokeEdge'
|
||||
|
||||
import { buildRange, requireData, throwIfError } from "./_helpers";
|
||||
import { buildRange, requireData, throwIfError } from './_helpers'
|
||||
|
||||
import type { Database } from '../../types/supabase'
|
||||
import type {
|
||||
Asignatura,
|
||||
CambioPlan,
|
||||
@@ -13,89 +14,121 @@ import type {
|
||||
PlanEstudio,
|
||||
TipoCiclo,
|
||||
UUID,
|
||||
} from "../types/domain";
|
||||
} from '../types/domain'
|
||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
||||
|
||||
const EDGE = {
|
||||
plans_create_manual: "plans_create_manual",
|
||||
ai_generate_plan: "ai_generate_plan",
|
||||
plans_persist_from_ai: "plans_persist_from_ai",
|
||||
plans_clone_from_existing: "plans_clone_from_existing",
|
||||
plans_create_manual: 'plans_create_manual',
|
||||
ai_generate_plan: 'ai-generate-plan',
|
||||
plans_persist_from_ai: 'plans_persist_from_ai',
|
||||
plans_clone_from_existing: 'plans_clone_from_existing',
|
||||
|
||||
plans_import_from_files: "plans_import_from_files",
|
||||
plans_import_from_files: 'plans_import_from_files',
|
||||
|
||||
plans_update_fields: "plans_update_fields",
|
||||
plans_update_map: "plans_update_map",
|
||||
plans_transition_state: "plans_transition_state",
|
||||
// plans_update_fields: 'plans_update_fields',
|
||||
plans_update_map: 'plans_update_map',
|
||||
plans_transition_state: 'plans_transition_state',
|
||||
|
||||
plans_generate_document: "plans_generate_document",
|
||||
plans_get_document: "plans_get_document",
|
||||
} as const;
|
||||
plans_generate_document: 'plans_generate_document',
|
||||
plans_get_document: 'plans_get_document',
|
||||
} as const
|
||||
|
||||
export type PlanListFilters = {
|
||||
search?: string;
|
||||
carreraId?: UUID;
|
||||
facultadId?: UUID; // filtra por carreras.facultad_id
|
||||
estadoId?: UUID;
|
||||
activo?: boolean;
|
||||
search?: string
|
||||
carreraId?: UUID
|
||||
facultadId?: UUID // filtra por carreras.facultad_id
|
||||
estadoId?: UUID
|
||||
activo?: boolean
|
||||
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
// Helper para limpiar texto (lo movemos fuera para reutilizar o lo dejas en un utils)
|
||||
const cleanText = (text: string) => {
|
||||
return text
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export async function plans_list(
|
||||
filters: PlanListFilters = {},
|
||||
): Promise<Paged<PlanEstudio>> {
|
||||
const supabase = supabaseBrowser();
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
// 1. Construimos la query base
|
||||
// NOTA IMPORTANTE: Para filtrar planes basados en facultad (que está en carreras),
|
||||
// necesitamos hacer un INNER JOIN. En Supabase se usa "!inner".
|
||||
// Si filters.facultadId existe, forzamos el inner join, si no, lo dejamos normal.
|
||||
|
||||
const carreraModifier =
|
||||
filters.facultadId && filters.facultadId !== 'todas' ? '!inner' : ''
|
||||
|
||||
// 1. Construimos la query.
|
||||
// TypeScript validará que "planes_estudio" existe en Database
|
||||
let q = supabase
|
||||
.from("planes_estudio")
|
||||
.from('planes_estudio')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
carreras (
|
||||
carreras${carreraModifier} (
|
||||
*,
|
||||
facultades (*)
|
||||
),
|
||||
estructuras_plan (*),
|
||||
estados_plan (*)
|
||||
`,
|
||||
{ count: "exact" },
|
||||
{ count: 'exact' },
|
||||
)
|
||||
.order("actualizado_en", { ascending: false });
|
||||
.order('creado_en', { ascending: false })
|
||||
|
||||
// 2. Aplicamos filtros dinámicos
|
||||
if (filters.search?.trim()) {
|
||||
q = q.ilike("nombre", `%${filters.search.trim()}%`);
|
||||
}
|
||||
if (filters.carreraId) q = q.eq("carrera_id", filters.carreraId);
|
||||
if (filters.estadoId) q = q.eq("estado_actual_id", filters.estadoId);
|
||||
if (typeof filters.activo === "boolean") q = q.eq("activo", filters.activo);
|
||||
|
||||
// filtro por FK “hacia arriba” (PostgREST soporta filtros en recursos embebidos)
|
||||
if (filters.facultadId) q = q.eq("carreras.facultad_id", filters.facultadId);
|
||||
// SOLUCIÓN SEARCH: Limpiamos el input y buscamos en la columna generada
|
||||
if (filters.search?.trim()) {
|
||||
const cleanTerm = cleanText(filters.search.trim())
|
||||
// Usamos la columna nueva creada en el Paso 1
|
||||
q = q.ilike('nombre_search', `%${cleanTerm}%`)
|
||||
}
|
||||
|
||||
if (filters.carreraId && filters.carreraId !== 'todas') {
|
||||
q = q.eq('carrera_id', filters.carreraId)
|
||||
}
|
||||
|
||||
if (filters.estadoId && filters.estadoId !== 'todos') {
|
||||
q = q.eq('estado_actual_id', filters.estadoId)
|
||||
}
|
||||
|
||||
if (typeof filters.activo === 'boolean') {
|
||||
q = q.eq('activo', filters.activo)
|
||||
}
|
||||
|
||||
// Filtro por facultad (gracias al !inner arriba, esto filtrará los planes)
|
||||
if (filters.facultadId && filters.facultadId !== 'todas') {
|
||||
q = q.eq('carreras.facultad_id', filters.facultadId)
|
||||
}
|
||||
|
||||
// 3. Paginación
|
||||
const { from, to } = buildRange(filters.limit, filters.offset);
|
||||
if (from !== undefined && to !== undefined) q = q.range(from, to);
|
||||
const { from, to } = buildRange(filters.limit, filters.offset)
|
||||
if (from !== undefined && to !== undefined) q = q.range(from, to)
|
||||
|
||||
const { data, error, count } = await q;
|
||||
throwIfError(error);
|
||||
const { data, error, count } = await q
|
||||
throwIfError(error)
|
||||
|
||||
return {
|
||||
// 1. Si data es null, usa [].
|
||||
// 2. Luego dile a TS que el resultado es tu Array tipado.
|
||||
data: (data ?? []) as unknown as Array<PlanEstudio>,
|
||||
count: count ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
||||
const supabase = supabaseBrowser();
|
||||
console.log('plans_get')
|
||||
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("planes_estudio")
|
||||
.from('planes_estudio')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
@@ -104,198 +137,340 @@ export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
||||
estados_plan (*)
|
||||
`,
|
||||
)
|
||||
.eq("id", planId)
|
||||
.single();
|
||||
.eq('id', planId)
|
||||
.single()
|
||||
|
||||
throwIfError(error);
|
||||
return requireData(data, "Plan no encontrado.");
|
||||
throwIfError(error)
|
||||
return requireData(data, 'Plan no encontrado.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante de `plans_get` que NO lanza si no existe (devuelve null).
|
||||
* Útil para flujos de polling donde el plan puede tardar en aparecer.
|
||||
*/
|
||||
export async function plans_get_maybe(
|
||||
planId: UUID,
|
||||
): Promise<PlanEstudio | null> {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('planes_estudio')
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
carreras (*, facultades(*)),
|
||||
estructuras_plan (*),
|
||||
estados_plan (*)
|
||||
`,
|
||||
)
|
||||
.eq('id', planId)
|
||||
.maybeSingle()
|
||||
|
||||
throwIfError(error)
|
||||
return (data ?? null) as unknown as PlanEstudio | null
|
||||
}
|
||||
|
||||
export async function plans_delete(planId: UUID): Promise<{ id: UUID }> {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('planes_estudio')
|
||||
.delete()
|
||||
.eq('id', planId)
|
||||
.select('id')
|
||||
.maybeSingle()
|
||||
|
||||
throwIfError(error)
|
||||
|
||||
// Si por alguna razón no retorna fila (RLS / triggers), devolvemos el id solicitado.
|
||||
return { id: ((data as any)?.id ?? planId) as UUID }
|
||||
}
|
||||
|
||||
export async function plan_lineas_list(
|
||||
planId: UUID,
|
||||
): Promise<Array<LineaPlan>> {
|
||||
const supabase = supabaseBrowser();
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from("lineas_plan")
|
||||
.select("id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en")
|
||||
.eq("plan_estudio_id", planId)
|
||||
.order("orden", { ascending: true });
|
||||
.from('lineas_plan')
|
||||
.select('id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en')
|
||||
.eq('plan_estudio_id', planId)
|
||||
.order('orden', { ascending: true })
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
throwIfError(error)
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export async function plan_asignaturas_list(
|
||||
planId: UUID,
|
||||
): Promise<Array<Asignatura>> {
|
||||
const supabase = supabaseBrowser();
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.from('asignaturas')
|
||||
.select(
|
||||
"id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,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,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,prerrequisito_asignatura_id',
|
||||
)
|
||||
.eq("plan_estudio_id", planId)
|
||||
.order("numero_ciclo", { ascending: true, nullsFirst: false })
|
||||
.order("orden_celda", { ascending: true, nullsFirst: false })
|
||||
.order("nombre", { ascending: true });
|
||||
.eq('plan_estudio_id', planId)
|
||||
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
||||
.order('orden_celda', { ascending: true, nullsFirst: false })
|
||||
.order('nombre', { ascending: true })
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
throwIfError(error)
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export async function plans_history(planId: UUID): Promise<Array<CambioPlan>> {
|
||||
const supabase = supabaseBrowser();
|
||||
const { data, error } = await supabase
|
||||
.from("cambios_plan")
|
||||
.select(
|
||||
"id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,interaccion_ia_id",
|
||||
)
|
||||
.eq("plan_estudio_id", planId)
|
||||
.order("cambiado_en", { ascending: false });
|
||||
export async function plans_history(
|
||||
planId: UUID,
|
||||
page: number = 0,
|
||||
pageSize: number = 4,
|
||||
): Promise<{ data: Array<CambioPlan>; count: number }> {
|
||||
// Cambiamos el retorno
|
||||
const supabase = supabaseBrowser()
|
||||
const from = page * pageSize
|
||||
const to = from + pageSize - 1
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
const { data, error, count } = await supabase
|
||||
.from('cambios_plan')
|
||||
.select(
|
||||
'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,response_id',
|
||||
{ count: 'exact' }, // <--- Pedimos el conteo exacto
|
||||
)
|
||||
.eq('plan_estudio_id', planId)
|
||||
.order('cambiado_en', { ascending: false })
|
||||
.range(from, to)
|
||||
|
||||
throwIfError(error)
|
||||
return {
|
||||
data: data ?? [],
|
||||
count: count ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Wizard: crear plan manual (Edge Function) */
|
||||
export type PlansCreateManualInput = {
|
||||
carreraId: UUID;
|
||||
estructuraId: UUID;
|
||||
nombre: string;
|
||||
nivel: NivelPlanEstudio;
|
||||
tipoCiclo: TipoCiclo;
|
||||
numCiclos: number;
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>;
|
||||
};
|
||||
carreraId: UUID
|
||||
estructuraId: UUID
|
||||
nombre: string
|
||||
nivel: NivelPlanEstudio
|
||||
tipoCiclo: TipoCiclo
|
||||
numCiclos: number
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>
|
||||
}
|
||||
|
||||
export async function plans_create_manual(
|
||||
input: PlansCreateManualInput,
|
||||
): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_create_manual, input);
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
// 1. Obtener estado 'BORRADOR'
|
||||
const { data: estado, error: estadoError } = await supabase
|
||||
.from('estados_plan')
|
||||
.select('id,clave,orden')
|
||||
.ilike('clave', 'BORRADOR%')
|
||||
.order('orden', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (estadoError) {
|
||||
throw new Error(estadoError.message)
|
||||
}
|
||||
|
||||
// 2. Preparar insert
|
||||
const planInsert: Database['public']['Tables']['planes_estudio']['Insert'] = {
|
||||
activo: true,
|
||||
actualizado_en: new Date().toISOString(),
|
||||
carrera_id: input.carreraId,
|
||||
creado_en: new Date().toISOString(),
|
||||
datos: input.datos || {},
|
||||
estado_actual_id: estado?.id || null,
|
||||
estructura_id: input.estructuraId,
|
||||
nivel: input.nivel,
|
||||
nombre: input.nombre,
|
||||
numero_ciclos: input.numCiclos,
|
||||
tipo_ciclo: input.tipoCiclo,
|
||||
tipo_origen: 'MANUAL',
|
||||
}
|
||||
|
||||
// 3. Insertar
|
||||
const { data: nuevoPlan, error: planError } = await supabase
|
||||
.from('planes_estudio')
|
||||
.insert([planInsert])
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
carreras (*, facultades(*)),
|
||||
estructuras_plan (*),
|
||||
estados_plan (*)
|
||||
`,
|
||||
)
|
||||
.single()
|
||||
|
||||
if (planError) {
|
||||
throw new Error(planError.message)
|
||||
}
|
||||
|
||||
return nuevoPlan as unknown as PlanEstudio
|
||||
}
|
||||
|
||||
/** Wizard: IA genera preview JSON (Edge Function) */
|
||||
export type AIGeneratePlanInput = {
|
||||
datosBasicos: {
|
||||
nombrePlan: string;
|
||||
carreraId: UUID;
|
||||
facultadId?: UUID;
|
||||
nivel: string;
|
||||
tipoCiclo: TipoCiclo;
|
||||
numCiclos: number;
|
||||
};
|
||||
nombrePlan: string
|
||||
carreraId: UUID
|
||||
facultadId?: UUID
|
||||
nivel: string
|
||||
tipoCiclo: TipoCiclo
|
||||
numCiclos: number
|
||||
estructuraPlanId: UUID
|
||||
}
|
||||
iaConfig: {
|
||||
descripcionEnfoque: string;
|
||||
poblacionObjetivo?: string;
|
||||
notasAdicionales?: string;
|
||||
archivosReferencia?: Array<UUID>;
|
||||
repositoriosIds?: Array<UUID>;
|
||||
usarMCP?: boolean;
|
||||
};
|
||||
};
|
||||
descripcionEnfoqueAcademico: string
|
||||
instruccionesAdicionalesIA?: string
|
||||
archivosReferencia?: Array<UUID>
|
||||
repositoriosIds?: Array<UUID>
|
||||
archivosAdjuntos: Array<UploadedFile>
|
||||
usarMCP?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export async function ai_generate_plan(
|
||||
input: AIGeneratePlanInput,
|
||||
): Promise<any> {
|
||||
return invokeEdge<any>(EDGE.ai_generate_plan, input);
|
||||
console.log('input ai generate', input)
|
||||
|
||||
const edgeFunctionBody = new FormData()
|
||||
edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos))
|
||||
edgeFunctionBody.append(
|
||||
'iaConfig',
|
||||
JSON.stringify({
|
||||
...input.iaConfig,
|
||||
archivosAdjuntos: undefined, // los manejamos aparte
|
||||
}),
|
||||
)
|
||||
input.iaConfig.archivosAdjuntos.forEach((file) => {
|
||||
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
|
||||
})
|
||||
|
||||
return invokeEdge<any>(
|
||||
EDGE.ai_generate_plan,
|
||||
edgeFunctionBody,
|
||||
undefined,
|
||||
supabaseBrowser(),
|
||||
)
|
||||
}
|
||||
|
||||
export async function plans_persist_from_ai(
|
||||
payload: { jsonPlan: any },
|
||||
): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_persist_from_ai, payload);
|
||||
export async function plans_persist_from_ai(payload: {
|
||||
jsonPlan: any
|
||||
}): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_persist_from_ai, payload)
|
||||
}
|
||||
|
||||
export async function plans_clone_from_existing(payload: {
|
||||
planOrigenId: UUID;
|
||||
overrides:
|
||||
& Partial<
|
||||
Pick<PlanEstudio, "nombre" | "nivel" | "tipo_ciclo" | "numero_ciclos">
|
||||
>
|
||||
& {
|
||||
carrera_id?: UUID;
|
||||
estructura_id?: UUID;
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>;
|
||||
};
|
||||
planOrigenId: UUID
|
||||
overrides: Partial<
|
||||
Pick<PlanEstudio, 'nombre' | 'nivel' | 'tipo_ciclo' | 'numero_ciclos'>
|
||||
> & {
|
||||
carrera_id?: UUID
|
||||
estructura_id?: UUID
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>
|
||||
}
|
||||
}): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_clone_from_existing, payload);
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_clone_from_existing, payload)
|
||||
}
|
||||
|
||||
export async function plans_import_from_files(payload: {
|
||||
datosBasicos: {
|
||||
nombrePlan: string;
|
||||
carreraId: UUID;
|
||||
estructuraId: UUID;
|
||||
nivel: string;
|
||||
tipoCiclo: TipoCiclo;
|
||||
numCiclos: number;
|
||||
};
|
||||
archivoWordPlanId: UUID;
|
||||
archivoMapaExcelId?: UUID | null;
|
||||
archivoMateriasExcelId?: UUID | null;
|
||||
nombrePlan: string
|
||||
carreraId: UUID
|
||||
estructuraId: UUID
|
||||
nivel: string
|
||||
tipoCiclo: TipoCiclo
|
||||
numCiclos: number
|
||||
}
|
||||
archivoWordPlanId: UUID
|
||||
archivoMapaExcelId?: UUID | null
|
||||
archivoAsignaturasExcelId?: UUID | null
|
||||
}): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload);
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload)
|
||||
}
|
||||
|
||||
/** Update de tarjetas/fields del plan (Edge Function: merge server-side) */
|
||||
export type PlansUpdateFieldsPatch = {
|
||||
nombre?: string;
|
||||
nivel?: NivelPlanEstudio;
|
||||
tipo_ciclo?: TipoCiclo;
|
||||
numero_ciclos?: number;
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>;
|
||||
};
|
||||
nombre?: string
|
||||
nivel?: NivelPlanEstudio
|
||||
tipo_ciclo?: TipoCiclo
|
||||
numero_ciclos?: number
|
||||
datos?: Partial<PlanDatosSep> & Record<string, any>
|
||||
}
|
||||
|
||||
export async function plans_update_fields(
|
||||
planId: UUID,
|
||||
patch: PlansUpdateFieldsPatch,
|
||||
): Promise<PlanEstudio> {
|
||||
return invokeEdge<PlanEstudio>(EDGE.plans_update_fields, { planId, patch });
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('planes_estudio')
|
||||
.update(patch)
|
||||
.eq('id', planId)
|
||||
.select(
|
||||
`
|
||||
*,
|
||||
carreras (*, facultades(*)),
|
||||
estructuras_plan (*),
|
||||
estados_plan (*)
|
||||
`,
|
||||
)
|
||||
.single()
|
||||
|
||||
throwIfError(error)
|
||||
return requireData(data, 'No se pudo actualizar el plan.')
|
||||
// Alternativa Edge Function:
|
||||
// return invokeEdge<PlanEstudio>(EDGE.plans_update_fields, { planId, patch })
|
||||
}
|
||||
|
||||
/** Operaciones del mapa curricular (mover/reordenar) */
|
||||
export type PlanMapOperation =
|
||||
| {
|
||||
op: "MOVE_ASIGNATURA";
|
||||
asignaturaId: UUID;
|
||||
numero_ciclo: number | null;
|
||||
linea_plan_id: UUID | null;
|
||||
orden_celda?: number | null;
|
||||
}
|
||||
op: 'MOVE_ASIGNATURA'
|
||||
asignaturaId: UUID
|
||||
numero_ciclo: number | null
|
||||
linea_plan_id: UUID | null
|
||||
orden_celda?: number | null
|
||||
}
|
||||
| {
|
||||
op: "REORDER_CELDA";
|
||||
linea_plan_id: UUID;
|
||||
numero_ciclo: number;
|
||||
asignaturaIdsOrdenados: Array<UUID>;
|
||||
};
|
||||
op: 'REORDER_CELDA'
|
||||
linea_plan_id: UUID
|
||||
numero_ciclo: number
|
||||
asignaturaIdsOrdenados: Array<UUID>
|
||||
}
|
||||
|
||||
export async function plans_update_map(
|
||||
planId: UUID,
|
||||
ops: Array<PlanMapOperation>,
|
||||
): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops });
|
||||
return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops })
|
||||
}
|
||||
|
||||
export async function plans_transition_state(payload: {
|
||||
planId: UUID;
|
||||
haciaEstadoId: UUID;
|
||||
comentario?: string;
|
||||
planId: UUID
|
||||
haciaEstadoId: UUID
|
||||
comentario?: string
|
||||
}): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.plans_transition_state, payload);
|
||||
return invokeEdge<{ ok: true }>(EDGE.plans_transition_state, payload)
|
||||
}
|
||||
|
||||
/** Documento (Edge Function: genera y devuelve URL firmada o metadata) */
|
||||
export type DocumentoResult = {
|
||||
archivoId: UUID;
|
||||
signedUrl: string;
|
||||
mimeType?: string;
|
||||
nombre?: string;
|
||||
};
|
||||
archivoId: UUID
|
||||
signedUrl: string
|
||||
mimeType?: string
|
||||
nombre?: string
|
||||
}
|
||||
|
||||
export async function plans_generate_document(
|
||||
planId: UUID,
|
||||
): Promise<DocumentoResult> {
|
||||
return invokeEdge<DocumentoResult>(EDGE.plans_generate_document, { planId });
|
||||
return invokeEdge<DocumentoResult>(EDGE.plans_generate_document, { planId })
|
||||
}
|
||||
|
||||
export async function plans_get_document(
|
||||
@@ -303,5 +478,26 @@ export async function plans_get_document(
|
||||
): Promise<DocumentoResult | null> {
|
||||
return invokeEdge<DocumentoResult | null>(EDGE.plans_get_document, {
|
||||
planId,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCatalogos() {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const [facultadesRes, carrerasRes, estadosRes, estructurasPlanRes] =
|
||||
await Promise.all([
|
||||
supabase.from('facultades').select('*').order('nombre'),
|
||||
supabase.from('carreras').select('*').order('nombre'),
|
||||
supabase.from('estados_plan').select('*').order('orden'),
|
||||
supabase.from('estructuras_plan').select('*').order('creado_en', {
|
||||
ascending: true,
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
facultades: facultadesRes.data ?? [],
|
||||
carreras: carrerasRes.data ?? [],
|
||||
estados: estadosRes.data ?? [],
|
||||
estructurasPlan: estructurasPlanRes.data ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,181 +1,441 @@
|
||||
import { supabaseBrowser } from "../supabase/client";
|
||||
import { invokeEdge } from "../supabase/invokeEdge";
|
||||
import { throwIfError, requireData } from "./_helpers";
|
||||
import { supabaseBrowser } from '../supabase/client'
|
||||
import { invokeEdge } from '../supabase/invokeEdge'
|
||||
|
||||
import { throwIfError, requireData } from './_helpers'
|
||||
|
||||
import type { DocumentoResult } from './plans.api'
|
||||
import type {
|
||||
Asignatura,
|
||||
BibliografiaAsignatura,
|
||||
CarreraRow,
|
||||
CambioAsignatura,
|
||||
EstructuraAsignatura,
|
||||
FacultadRow,
|
||||
PlanEstudioRow,
|
||||
TipoAsignatura,
|
||||
UUID,
|
||||
} from "../types/domain";
|
||||
import type { DocumentoResult } from "./plans.api";
|
||||
} from '../types/domain'
|
||||
import type {
|
||||
AsignaturaSugerida,
|
||||
DataAsignaturaSugerida,
|
||||
} from '@/features/asignaturas/nueva/types'
|
||||
import type { Database, Tables, TablesInsert } from '@/types/supabase'
|
||||
|
||||
const EDGE = {
|
||||
subjects_create_manual: "subjects_create_manual",
|
||||
ai_generate_subject: "ai_generate_subject",
|
||||
subjects_persist_from_ai: "subjects_persist_from_ai",
|
||||
subjects_clone_from_existing: "subjects_clone_from_existing",
|
||||
subjects_import_from_file: "subjects_import_from_file",
|
||||
generate_subject_suggestions: 'generate-subject-suggestions',
|
||||
subjects_create_manual: 'subjects_create_manual',
|
||||
ai_generate_subject: 'ai-generate-subject',
|
||||
subjects_persist_from_ai: 'subjects_persist_from_ai',
|
||||
subjects_clone_from_existing: 'subjects_clone_from_existing',
|
||||
subjects_import_from_file: 'subjects_import_from_file',
|
||||
|
||||
subjects_update_fields: "subjects_update_fields",
|
||||
subjects_update_contenido: "subjects_update_contenido",
|
||||
subjects_update_bibliografia: "subjects_update_bibliografia",
|
||||
// Bibliografía
|
||||
buscar_bibliografia: 'buscar-bibliografia',
|
||||
|
||||
subjects_generate_document: "subjects_generate_document",
|
||||
subjects_get_document: "subjects_get_document",
|
||||
} as const;
|
||||
subjects_update_fields: 'subjects_update_fields',
|
||||
subjects_update_bibliografia: 'subjects_update_bibliografia',
|
||||
|
||||
export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
|
||||
const supabase = supabaseBrowser();
|
||||
subjects_generate_document: 'subjects_generate_document',
|
||||
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
|
||||
| {
|
||||
nombre: string
|
||||
horasEstimadas?: number
|
||||
descripcion?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Estructura persistida en `asignaturas.contenido_tematico`.
|
||||
* La BDD guarda un arreglo de unidades, cada una con temas (strings u objetos).
|
||||
*/
|
||||
export type ContenidoApi = {
|
||||
unidad: number
|
||||
titulo: string
|
||||
temas: Array<ContenidoTemaApi>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type FacultadInSubject = Pick<
|
||||
FacultadRow,
|
||||
'id' | 'nombre' | 'nombre_corto' | 'color' | 'icono'
|
||||
>
|
||||
|
||||
export type CarreraInSubject = Pick<
|
||||
CarreraRow,
|
||||
'id' | 'facultad_id' | 'nombre' | 'nombre_corto' | 'clave_sep' | 'activa'
|
||||
> & {
|
||||
facultades: FacultadInSubject | null
|
||||
}
|
||||
|
||||
export type PlanEstudioInSubject = Pick<
|
||||
PlanEstudioRow,
|
||||
| 'id'
|
||||
| 'carrera_id'
|
||||
| 'estructura_id'
|
||||
| 'nombre'
|
||||
| 'nivel'
|
||||
| 'tipo_ciclo'
|
||||
| 'numero_ciclos'
|
||||
| 'datos'
|
||||
| 'estado_actual_id'
|
||||
| 'activo'
|
||||
| 'tipo_origen'
|
||||
| 'meta_origen'
|
||||
| 'creado_por'
|
||||
| 'actualizado_por'
|
||||
| 'creado_en'
|
||||
| 'actualizado_en'
|
||||
> & {
|
||||
carreras: CarreraInSubject | null
|
||||
}
|
||||
|
||||
export type EstructuraAsignaturaInSubject = Pick<
|
||||
EstructuraAsignatura,
|
||||
'id' | 'nombre' | 'definicion'
|
||||
>
|
||||
|
||||
/**
|
||||
* Tipo real que devuelve `subjects_get` (asignatura + relaciones seleccionadas).
|
||||
* Nota: `asignaturas_update` (update directo) NO devuelve estas relaciones.
|
||||
*/
|
||||
export type AsignaturaDetail = Omit<Asignatura, 'contenido_tematico'> & {
|
||||
contenido_tematico: Array<ContenidoApi> | null
|
||||
planes_estudio: PlanEstudioInSubject | null
|
||||
estructuras_asignatura: EstructuraAsignaturaInSubject | null
|
||||
}
|
||||
|
||||
export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.from('asignaturas')
|
||||
.select(
|
||||
`
|
||||
id,plan_estudio_id,estructura_id,facultad_propietaria_id,codigo,nombre,tipo,creditos,horas_semana,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,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,
|
||||
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)
|
||||
.single();
|
||||
.eq('id', subjectId)
|
||||
.single()
|
||||
|
||||
throwIfError(error);
|
||||
return requireData(data, "Materia no encontrada.");
|
||||
throwIfError(error)
|
||||
return requireData(
|
||||
data,
|
||||
'Asignatura no encontrada.',
|
||||
) as unknown as AsignaturaDetail
|
||||
}
|
||||
|
||||
export async function subjects_history(subjectId: UUID): Promise<CambioAsignatura[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
export async function subjects_history(
|
||||
subjectId: UUID,
|
||||
): Promise<Array<CambioAsignatura>> {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from("cambios_asignatura")
|
||||
.from('cambios_asignatura')
|
||||
.select(
|
||||
"id,asignatura_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,fuente,interaccion_ia_id"
|
||||
'id,asignatura_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,fuente,interaccion_ia_id',
|
||||
)
|
||||
.eq("asignatura_id", subjectId)
|
||||
.order("cambiado_en", { ascending: false });
|
||||
.eq('asignatura_id', subjectId)
|
||||
.order('cambiado_en', { ascending: false })
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
throwIfError(error)
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
export async function subjects_bibliografia_list(subjectId: UUID): Promise<BibliografiaAsignatura[]> {
|
||||
const supabase = supabaseBrowser();
|
||||
export async function subjects_bibliografia_list(
|
||||
subjectId: UUID,
|
||||
): Promise<Array<BibliografiaAsignatura>> {
|
||||
const supabase = supabaseBrowser()
|
||||
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")
|
||||
.eq("asignatura_id", subjectId)
|
||||
.order("tipo", { ascending: true })
|
||||
.order("creado_en", { ascending: true });
|
||||
.from('bibliografia_asignatura')
|
||||
.select(
|
||||
'id,asignatura_id,tipo,cita,referencia_biblioteca,referencia_en_linea,creado_por,creado_en,actualizado_en',
|
||||
)
|
||||
.eq('asignatura_id', subjectId)
|
||||
.order('tipo', { ascending: true })
|
||||
.order('creado_en', { ascending: true })
|
||||
|
||||
throwIfError(error);
|
||||
return data ?? [];
|
||||
throwIfError(error)
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
/** Wizard: crear materia manual (Edge Function) */
|
||||
export type SubjectsCreateManualInput = {
|
||||
planId: UUID;
|
||||
datosBasicos: {
|
||||
nombre: string;
|
||||
clave?: string;
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horasSemana?: number;
|
||||
estructuraId: UUID;
|
||||
};
|
||||
};
|
||||
export async function subjects_create_manual(
|
||||
payload: TablesInsert<'asignaturas'>,
|
||||
): Promise<Asignatura> {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from('asignaturas')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
export async function subjects_create_manual(payload: SubjectsCreateManualInput): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_create_manual, payload);
|
||||
throwIfError(error)
|
||||
return requireData(data, 'No se pudo crear la asignatura.')
|
||||
}
|
||||
|
||||
export async function ai_generate_subject(payload: {
|
||||
planId: UUID;
|
||||
datosBasicos: {
|
||||
nombre: string;
|
||||
clave?: string;
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horasSemana?: number;
|
||||
estructuraId: UUID;
|
||||
};
|
||||
iaConfig: {
|
||||
descripcionEnfoque: string;
|
||||
notasAdicionales?: string;
|
||||
archivosExistentesIds?: UUID[];
|
||||
repositoriosIds?: UUID[];
|
||||
archivosAdhocIds?: UUID[];
|
||||
usarMCP?: boolean;
|
||||
};
|
||||
}): Promise<any> {
|
||||
return invokeEdge<any>(EDGE.ai_generate_subject, payload);
|
||||
/**
|
||||
* Nuevo payload unificado (JSON) para la Edge `ai_generate_subject`.
|
||||
* - Siempre incluye `datosUpdate.plan_estudio_id`.
|
||||
* - `datosUpdate.id` es opcional (si no existe, la Edge puede crear).
|
||||
* En el frontend, insertamos primero y usamos `id` para actualizar.
|
||||
*/
|
||||
export type AISubjectUnifiedInput = {
|
||||
datosUpdate: Partial<{
|
||||
id: string
|
||||
plan_estudio_id: string
|
||||
estructura_id: string
|
||||
nombre: string
|
||||
codigo: string | null
|
||||
tipo: string | null
|
||||
creditos: number
|
||||
horas_academicas: number | null
|
||||
horas_independientes: number | null
|
||||
numero_ciclo: number | null
|
||||
linea_plan_id: string | null
|
||||
orden_celda: number | null
|
||||
}> & {
|
||||
plan_estudio_id: string
|
||||
}
|
||||
iaConfig?: {
|
||||
descripcionEnfoqueAcademico?: string
|
||||
instruccionesAdicionalesIA?: string
|
||||
archivosAdjuntos?: Array<string>
|
||||
}
|
||||
}
|
||||
|
||||
export async function subjects_persist_from_ai(payload: { planId: UUID; jsonMateria: any }): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_persist_from_ai, payload);
|
||||
export async function subjects_get_maybe(
|
||||
subjectId: UUID,
|
||||
): Promise<Asignatura | null> {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('asignaturas')
|
||||
.select('id,plan_estudio_id,estado')
|
||||
.eq('id', subjectId)
|
||||
.maybeSingle()
|
||||
|
||||
throwIfError(error)
|
||||
return (data ?? null) as unknown as Asignatura | null
|
||||
}
|
||||
|
||||
export type GenerateSubjectSuggestionsInput = {
|
||||
plan_estudio_id: UUID
|
||||
enfoque?: string
|
||||
cantidad_de_sugerencias: number
|
||||
sugerencias_conservadas: Array<{ nombre: string; descripcion: string }>
|
||||
}
|
||||
|
||||
export async function generate_subject_suggestions(
|
||||
input: GenerateSubjectSuggestionsInput,
|
||||
): Promise<Array<AsignaturaSugerida>> {
|
||||
const raw = await invokeEdge<Array<DataAsignaturaSugerida>>(
|
||||
EDGE.generate_subject_suggestions,
|
||||
input,
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
|
||||
return raw.map(
|
||||
(s): AsignaturaSugerida => ({
|
||||
id: crypto.randomUUID(),
|
||||
selected: false,
|
||||
source: 'IA',
|
||||
nombre: s.nombre,
|
||||
codigo: s.codigo,
|
||||
tipo: s.tipo ?? null,
|
||||
creditos: s.creditos ?? null,
|
||||
horasAcademicas: s.horasAcademicas ?? null,
|
||||
horasIndependientes: s.horasIndependientes ?? null,
|
||||
descripcion: s.descripcion,
|
||||
linea_plan_id: null,
|
||||
numero_ciclo: null,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function ai_generate_subject(
|
||||
input: AISubjectUnifiedInput,
|
||||
): Promise<any> {
|
||||
return invokeEdge<any>(EDGE.ai_generate_subject, input, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function subjects_persist_from_ai(payload: {
|
||||
planId: UUID
|
||||
jsonAsignatura: any
|
||||
}): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_persist_from_ai, payload)
|
||||
}
|
||||
|
||||
export async function subjects_clone_from_existing(payload: {
|
||||
materiaOrigenId: UUID;
|
||||
planDestinoId: UUID;
|
||||
asignaturaOrigenId: UUID
|
||||
planDestinoId: UUID
|
||||
overrides?: Partial<{
|
||||
nombre: string;
|
||||
codigo: string;
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horas_semana: number;
|
||||
}>;
|
||||
nombre: string
|
||||
codigo: string
|
||||
tipo: TipoAsignatura
|
||||
creditos: number
|
||||
horas_semana: number
|
||||
}>
|
||||
}): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_clone_from_existing, payload);
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_clone_from_existing, payload)
|
||||
}
|
||||
|
||||
export async function subjects_import_from_file(payload: {
|
||||
planId: UUID;
|
||||
archivoWordMateriaId: UUID;
|
||||
archivosAdicionalesIds?: UUID[];
|
||||
planId: UUID
|
||||
archivoWordAsignaturaId: UUID
|
||||
archivosAdicionalesIds?: Array<UUID>
|
||||
}): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_import_from_file, payload);
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_import_from_file, payload)
|
||||
}
|
||||
|
||||
/** Guardado de tarjetas/fields (Edge: merge server-side en asignaturas.datos y columnas) */
|
||||
export type SubjectsUpdateFieldsPatch = Partial<{
|
||||
codigo: string | null;
|
||||
nombre: string;
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horas_semana: number | null;
|
||||
numero_ciclo: number | null;
|
||||
linea_plan_id: UUID | null;
|
||||
codigo: string | null
|
||||
nombre: string
|
||||
tipo: TipoAsignatura
|
||||
creditos: number
|
||||
horas_semana: number | null
|
||||
numero_ciclo: number | null
|
||||
linea_plan_id: UUID | null
|
||||
|
||||
datos: Record<string, any>;
|
||||
}>;
|
||||
datos: Record<string, any>
|
||||
}>
|
||||
|
||||
export async function subjects_update_fields(subjectId: UUID, patch: SubjectsUpdateFieldsPatch): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_update_fields, { subjectId, patch });
|
||||
export async function subjects_update_fields(
|
||||
subjectId: UUID,
|
||||
patch: SubjectsUpdateFieldsPatch,
|
||||
): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_update_fields, {
|
||||
subjectId,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
|
||||
export async function subjects_update_contenido(subjectId: UUID, unidades: any[]): Promise<Asignatura> {
|
||||
return invokeEdge<Asignatura>(EDGE.subjects_update_contenido, { subjectId, unidades });
|
||||
export async function subjects_update_contenido(
|
||||
subjectId: UUID,
|
||||
unidades: Array<ContenidoApi>,
|
||||
): Promise<Asignatura> {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
type AsignaturaUpdate = Database['public']['Tables']['asignaturas']['Update']
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('asignaturas')
|
||||
.update({
|
||||
contenido_tematico:
|
||||
unidades as unknown as AsignaturaUpdate['contenido_tematico'],
|
||||
})
|
||||
.eq('id', subjectId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
throwIfError(error)
|
||||
return requireData(data, 'No se pudo actualizar la asignatura.')
|
||||
}
|
||||
|
||||
export type BibliografiaUpsertInput = Array<{
|
||||
id?: UUID;
|
||||
tipo: "BASICA" | "COMPLEMENTARIA";
|
||||
cita: string;
|
||||
tipo_fuente?: "MANUAL" | "BIBLIOTECA";
|
||||
biblioteca_item_id?: string | null;
|
||||
}>;
|
||||
id?: UUID
|
||||
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
||||
cita: string
|
||||
tipo_fuente?: 'MANUAL' | 'BIBLIOTECA'
|
||||
biblioteca_item_id?: string | null
|
||||
}>
|
||||
|
||||
export async function subjects_update_bibliografia(
|
||||
subjectId: UUID,
|
||||
entries: BibliografiaUpsertInput
|
||||
entries: BibliografiaUpsertInput,
|
||||
): Promise<{ ok: true }> {
|
||||
return invokeEdge<{ ok: true }>(EDGE.subjects_update_bibliografia, { subjectId, entries });
|
||||
return invokeEdge<{ ok: true }>(EDGE.subjects_update_bibliografia, {
|
||||
subjectId,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
/** Documento SEP materia */
|
||||
/** Documento SEP asignatura */
|
||||
/* export type DocumentoResult = {
|
||||
archivoId: UUID;
|
||||
signedUrl: string;
|
||||
@@ -183,10 +443,145 @@ export async function subjects_update_bibliografia(
|
||||
nombre?: string;
|
||||
}; */
|
||||
|
||||
export async function subjects_generate_document(subjectId: UUID): Promise<DocumentoResult> {
|
||||
return invokeEdge<DocumentoResult>(EDGE.subjects_generate_document, { subjectId });
|
||||
export async function subjects_generate_document(
|
||||
subjectId: UUID,
|
||||
): Promise<DocumentoResult> {
|
||||
return invokeEdge<DocumentoResult>(EDGE.subjects_generate_document, {
|
||||
subjectId,
|
||||
})
|
||||
}
|
||||
|
||||
export async function subjects_get_document(subjectId: UUID): Promise<DocumentoResult | null> {
|
||||
return invokeEdge<DocumentoResult | null>(EDGE.subjects_get_document, { subjectId });
|
||||
export async function subjects_get_document(
|
||||
subjectId: UUID,
|
||||
): Promise<DocumentoResult | null> {
|
||||
return invokeEdge<DocumentoResult | null>(EDGE.subjects_get_document, {
|
||||
subjectId,
|
||||
})
|
||||
}
|
||||
|
||||
export async function subjects_get_structure_catalog(): Promise<
|
||||
Array<Database['public']['Tables']['estructuras_asignatura']['Row']>
|
||||
> {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('estructuras_asignatura')
|
||||
.select('*')
|
||||
.order('nombre', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function asignaturas_update(
|
||||
asignaturaId: UUID,
|
||||
patch: Partial<Asignatura>, // O tu tipo específico para el Patch de materias
|
||||
): Promise<Asignatura> {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('asignaturas')
|
||||
.update(patch)
|
||||
.eq('id', asignaturaId)
|
||||
.select() // Trae la materia actualizada
|
||||
.single()
|
||||
|
||||
throwIfError(error)
|
||||
return requireData(data, 'No se pudo actualizar la asignatura.')
|
||||
}
|
||||
|
||||
// Insertar una nueva línea
|
||||
export async function lineas_insert(linea: {
|
||||
nombre: string
|
||||
plan_estudio_id: string
|
||||
orden: number
|
||||
area?: string
|
||||
}) {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from('lineas_plan') // Asegúrate que el nombre de la tabla sea correcto
|
||||
.insert([linea])
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
// Actualizar una línea existente
|
||||
export async function lineas_update(
|
||||
lineaId: string,
|
||||
patch: { nombre?: string; orden?: number; area?: string },
|
||||
) {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from('lineas_plan')
|
||||
.update(patch)
|
||||
.eq('id', lineaId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export async function lineas_delete(lineaId: string) {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
// Nota: Si configuraste "ON DELETE SET NULL" en tu base de datos,
|
||||
// las asignaturas se desvincularán solas. Si no, Supabase podría dar error.
|
||||
const { error } = await supabase
|
||||
.from('lineas_plan')
|
||||
.delete()
|
||||
.eq('id', lineaId)
|
||||
|
||||
if (error) throw error
|
||||
return lineaId
|
||||
}
|
||||
|
||||
export async function bibliografia_insert(
|
||||
entry: TablesInsert<'bibliografia_asignatura'>,
|
||||
): Promise<Tables<'bibliografia_asignatura'>> {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from('bibliografia_asignatura')
|
||||
.insert([entry])
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data as Tables<'bibliografia_asignatura'>
|
||||
}
|
||||
|
||||
export async function bibliografia_update(
|
||||
id: string,
|
||||
updates: {
|
||||
cita?: string
|
||||
tipo?: 'BASICA' | 'COMPLEMENTARIA'
|
||||
},
|
||||
) {
|
||||
const supabase = supabaseBrowser()
|
||||
const { data, error } = await supabase
|
||||
.from('bibliografia_asignatura')
|
||||
.update(updates) // Ahora 'updates' es compatible con lo que espera Supabase
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export async function bibliografia_delete(id: string) {
|
||||
const supabase = supabaseBrowser()
|
||||
const { error } = await supabase
|
||||
.from('bibliografia_asignatura')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -1,29 +1,337 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import {
|
||||
ai_plan_chat,
|
||||
ai_plan_chat_v2,
|
||||
ai_plan_improve,
|
||||
ai_subject_chat,
|
||||
ai_subject_improve,
|
||||
create_conversation,
|
||||
get_chat_history,
|
||||
getConversationByPlan,
|
||||
library_search,
|
||||
} from "../api/ai.api";
|
||||
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'
|
||||
|
||||
import type { UUID } from 'node:crypto'
|
||||
|
||||
export function useAIPlanImprove() {
|
||||
return useMutation({ mutationFn: ai_plan_improve });
|
||||
return useMutation({ mutationFn: ai_plan_improve })
|
||||
}
|
||||
|
||||
export function useAIPlanChat() {
|
||||
return useMutation({ mutationFn: ai_plan_chat });
|
||||
return useMutation({
|
||||
mutationFn: async (payload: {
|
||||
planId: UUID
|
||||
content: string
|
||||
campos?: Array<string>
|
||||
conversacionId?: string
|
||||
}) => {
|
||||
let currentId = payload.conversacionId
|
||||
|
||||
// 1. Si no hay ID, creamos la conversación
|
||||
if (!currentId) {
|
||||
const response = await create_conversation(payload.planId)
|
||||
|
||||
// CAMBIO AQUÍ: Accedemos a la estructura correcta según tu consola
|
||||
currentId = response.conversation_plan.id
|
||||
}
|
||||
|
||||
// 2. Ahora enviamos el mensaje con el ID garantizado
|
||||
const result = await ai_plan_chat_v2({
|
||||
conversacionId: currentId!,
|
||||
content: payload.content,
|
||||
campos: payload.campos,
|
||||
})
|
||||
|
||||
// Retornamos el resultado del chat y el ID para el estado del componente
|
||||
return { ...result, conversacionId: currentId }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useChatHistory(conversacionId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['chat-history', conversacionId],
|
||||
queryFn: async () => {
|
||||
return get_chat_history(conversacionId!)
|
||||
},
|
||||
enabled: Boolean(conversacionId),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateConversationStatus() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
estado,
|
||||
}: {
|
||||
id: string
|
||||
estado: 'ARCHIVADA' | 'ACTIVA'
|
||||
}) => update_conversation_status(id, estado),
|
||||
onSuccess: () => {
|
||||
// Esto refresca las listas automáticamente
|
||||
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useConversationByPlan(planId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['conversation-by-plan', planId],
|
||||
queryFn: () => getConversationByPlan(planId!),
|
||||
enabled: !!planId, // solo ejecuta si existe planId
|
||||
})
|
||||
}
|
||||
|
||||
export function 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()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
conversacionId,
|
||||
campoAfectado,
|
||||
}: {
|
||||
conversacionId: string
|
||||
campoAfectado: string
|
||||
}) => update_recommendation_applied_status(conversacionId, campoAfectado),
|
||||
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidamos la query para que useConversationByPlan refresque el JSON
|
||||
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
|
||||
console.log(
|
||||
`Recomendación ${variables.campoAfectado} marcada como aplicada.`,
|
||||
)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error al actualizar el estado de la recomendación:', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAISubjectImprove() {
|
||||
return useMutation({ mutationFn: ai_subject_improve });
|
||||
}
|
||||
|
||||
export function useAISubjectChat() {
|
||||
return useMutation({ mutationFn: ai_subject_chat });
|
||||
return useMutation({ mutationFn: ai_subject_improve })
|
||||
}
|
||||
|
||||
export function useLibrarySearch() {
|
||||
return useMutation({ mutationFn: library_search });
|
||||
return useMutation({ mutationFn: library_search })
|
||||
}
|
||||
|
||||
export function useUpdateConversationTitle() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, nombre }: { id: string; nombre: string }) =>
|
||||
update_conversation_title(id, nombre),
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidamos para que la lista de chats se refresque
|
||||
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@ import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
} from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import {
|
||||
ai_generate_plan,
|
||||
getCatalogos,
|
||||
plan_asignaturas_list,
|
||||
plan_lineas_list,
|
||||
plans_clone_from_existing,
|
||||
plans_create_manual,
|
||||
plans_delete,
|
||||
plans_generate_document,
|
||||
plans_get,
|
||||
plans_get_document,
|
||||
@@ -21,16 +24,18 @@ import {
|
||||
plans_transition_state,
|
||||
plans_update_fields,
|
||||
plans_update_map,
|
||||
} from "../api/plans.api";
|
||||
import { qk } from "../query/keys";
|
||||
} from '../api/plans.api'
|
||||
import { lineas_delete } from '../api/subjects.api'
|
||||
import { qk } from '../query/keys'
|
||||
import { supabaseBrowser } from '../supabase/client'
|
||||
|
||||
import type {
|
||||
PlanListFilters,
|
||||
PlanMapOperation,
|
||||
PlansCreateManualInput,
|
||||
PlansUpdateFieldsPatch,
|
||||
} from "../api/plans.api";
|
||||
import type { UUID } from "../types/domain";
|
||||
} from '../api/plans.api'
|
||||
import type { UUID } from '../types/domain'
|
||||
|
||||
export function usePlanes(filters: PlanListFilters) {
|
||||
// 🧠 Tip: memoiza "filters" (useMemo) para que queryKey sea estable.
|
||||
@@ -46,124 +51,223 @@ export function usePlanes(filters: PlanListFilters) {
|
||||
|
||||
// Opcional: Tiempo que la data se considera fresca
|
||||
staleTime: 1000 * 60 * 5, // 5 minutos
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlan(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.plan(planId) : ["planes", "detail", null],
|
||||
queryFn: () => plans_get(planId as UUID),
|
||||
queryKey: planId ? qk.plan(planId) : ['planes', 'detail', null],
|
||||
queryFn: () => {
|
||||
console.log('usePlan')
|
||||
return plans_get(planId as UUID)
|
||||
},
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlanLineas(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.planLineas(planId) : ["planes", "lineas", null],
|
||||
queryKey: planId ? qk.planLineas(planId) : ['planes', 'lineas', null],
|
||||
queryFn: () => plan_lineas_list(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlanAsignaturas(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
const qc = useQueryClient()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: planId
|
||||
? qk.planAsignaturas(planId)
|
||||
: ["planes", "asignaturas", null],
|
||||
: ['planes', 'asignaturas', null],
|
||||
queryFn: () => plan_asignaturas_list(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!planId) return
|
||||
|
||||
const supabase = supabaseBrowser()
|
||||
const channel = supabase.channel(`plan-asignaturas-${planId}`)
|
||||
|
||||
channel.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'asignaturas',
|
||||
filter: `plan_estudio_id=eq.${planId}`,
|
||||
},
|
||||
(payload: {
|
||||
eventType?: 'INSERT' | 'UPDATE' | 'DELETE'
|
||||
new?: any
|
||||
old?: any
|
||||
}) => {
|
||||
const eventType = payload.eventType
|
||||
|
||||
if (eventType === 'DELETE') {
|
||||
const oldRow: any = payload.old
|
||||
const deletedId = oldRow?.id
|
||||
if (!deletedId) return
|
||||
|
||||
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
|
||||
if (!Array.isArray(prev)) return prev
|
||||
return prev.filter((a: any) => String(a?.id) !== String(deletedId))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const newRow: any = payload.new
|
||||
if (!newRow?.id) return
|
||||
|
||||
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
|
||||
if (!Array.isArray(prev)) return prev
|
||||
|
||||
const idx = prev.findIndex(
|
||||
(a: any) => String(a?.id) === String(newRow.id),
|
||||
)
|
||||
if (idx === -1) return [...prev, newRow]
|
||||
|
||||
const next = [...prev]
|
||||
next[idx] = { ...prev[idx], ...newRow }
|
||||
return next
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
channel.subscribe()
|
||||
|
||||
return () => {
|
||||
try {
|
||||
supabase.removeChannel(channel)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}, [planId, qc])
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
export function usePlanHistorial(planId: UUID | null | undefined) {
|
||||
export function usePlanHistorial(
|
||||
planId: UUID | null | undefined,
|
||||
page: number,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.planHistorial(planId) : ["planes", "historial", null],
|
||||
queryFn: () => plans_history(planId as UUID),
|
||||
queryKey: planId
|
||||
? [...qk.planHistorial(planId), page]
|
||||
: ['planes', 'historial', null, page],
|
||||
queryFn: () => plans_history(planId as UUID, page),
|
||||
enabled: Boolean(planId),
|
||||
});
|
||||
placeholderData: (previousData) => previousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePlanDocumento(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: planId ? qk.planDocumento(planId) : ["planes", "documento", null],
|
||||
queryKey: planId ? qk.planDocumento(planId) : ['planes', 'documento', null],
|
||||
queryFn: () => plans_get_document(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useCatalogosPlanes() {
|
||||
return useQuery({
|
||||
queryKey: qk.estructurasPlan(),
|
||||
queryFn: getCatalogos,
|
||||
staleTime: 1000 * 60 * 60, // 1 hora de caché (estos datos casi no cambian)
|
||||
})
|
||||
}
|
||||
|
||||
/* ------------------ Mutations ------------------ */
|
||||
|
||||
export function useCreatePlanManual() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: PlansCreateManualInput) => plans_create_manual(input),
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
||||
qc.setQueryData(qk.plan(plan.id), plan)
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useGeneratePlanAI() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ai_generate_plan,
|
||||
});
|
||||
onSuccess: (data) => {
|
||||
// Asumiendo que la Edge Function devuelve { ok: true, plan: { id: ... } }
|
||||
console.log('success de ai_generate_plan')
|
||||
|
||||
const newPlan = data.plan
|
||||
|
||||
if (newPlan) {
|
||||
// 1. Invalidar la lista para que aparezca el nuevo plan
|
||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
||||
|
||||
// 2. (Opcional) Pre-cargar el dato individual para que la navegación sea instantánea
|
||||
// qc.setQueryData(["planes", "detail", newPlan.id], newPlan);
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Funcion obsoleta porque ahora el plan se persiste directamente en useGeneratePlanAI
|
||||
export function usePersistPlanFromAI() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: { jsonPlan: any }) => plans_persist_from_ai(payload),
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
||||
qc.setQueryData(qk.plan(plan.id), plan)
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useClonePlan() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: plans_clone_from_existing,
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
||||
qc.setQueryData(qk.plan(plan.id), plan)
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useImportPlanFromFiles() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: plans_import_from_files,
|
||||
onSuccess: (plan) => {
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.setQueryData(qk.plan(plan.id), plan);
|
||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
||||
qc.setQueryData(qk.plan(plan.id), plan)
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdatePlanFields() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { planId: UUID; patch: PlansUpdateFieldsPatch }) =>
|
||||
plans_update_fields(vars.planId, vars.patch),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(qk.plan(updated.id), updated);
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(updated.id) });
|
||||
qc.setQueryData(qk.plan(updated.id), updated)
|
||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(updated.id) })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdatePlanMapa() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { planId: UUID; ops: Array<PlanMapOperation> }) =>
|
||||
@@ -171,61 +275,90 @@ export function useUpdatePlanMapa() {
|
||||
|
||||
// ✅ Optimista (rápida) para el caso MOVE_ASIGNATURA
|
||||
onMutate: async (vars) => {
|
||||
await qc.cancelQueries({ queryKey: qk.planAsignaturas(vars.planId) });
|
||||
const prev = qc.getQueryData<any>(qk.planAsignaturas(vars.planId));
|
||||
await qc.cancelQueries({ queryKey: qk.planAsignaturas(vars.planId) })
|
||||
const prev = qc.getQueryData<any>(qk.planAsignaturas(vars.planId))
|
||||
|
||||
// solo optimizamos MOVEs simples
|
||||
const moves = vars.ops.filter((x) => x.op === "MOVE_ASIGNATURA");
|
||||
const moves = vars.ops.filter((x) => x.op === 'MOVE_ASIGNATURA')
|
||||
|
||||
if (prev && Array.isArray(prev) && moves.length) {
|
||||
const next = prev.map((a: any) => {
|
||||
const m = moves.find((x) => x.asignaturaId === a.id);
|
||||
if (!m) return a;
|
||||
const m = moves.find((x) => x.asignaturaId === a.id)
|
||||
if (!m) return a
|
||||
return {
|
||||
...a,
|
||||
numero_ciclo: m.numero_ciclo,
|
||||
linea_plan_id: m.linea_plan_id,
|
||||
orden_celda: m.orden_celda ?? a.orden_celda,
|
||||
};
|
||||
});
|
||||
qc.setQueryData(qk.planAsignaturas(vars.planId), next);
|
||||
}
|
||||
})
|
||||
qc.setQueryData(qk.planAsignaturas(vars.planId), next)
|
||||
}
|
||||
|
||||
return { prev };
|
||||
return { prev }
|
||||
},
|
||||
|
||||
onError: (_err, vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(qk.planAsignaturas(vars.planId), ctx.prev);
|
||||
if (ctx?.prev) qc.setQueryData(qk.planAsignaturas(vars.planId), ctx.prev)
|
||||
},
|
||||
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(vars.planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(vars.planId) })
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useTransitionPlanEstado() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: plans_transition_state,
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: qk.plan(vars.planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) });
|
||||
qc.invalidateQueries({ queryKey: ["planes", "list"] });
|
||||
qc.invalidateQueries({ queryKey: qk.plan(vars.planId) })
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) })
|
||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeletePlanEstudio() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (planId: UUID) => plans_delete(planId),
|
||||
onSuccess: (_ok, planId) => {
|
||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
||||
qc.removeQueries({ queryKey: qk.plan(planId) })
|
||||
qc.removeQueries({ queryKey: qk.planMaybe(planId) })
|
||||
qc.removeQueries({ queryKey: qk.planAsignaturas(planId) })
|
||||
qc.removeQueries({ queryKey: qk.planLineas(planId) })
|
||||
qc.removeQueries({ queryKey: qk.planHistorial(planId) })
|
||||
qc.removeQueries({ queryKey: qk.planDocumento(planId) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useGeneratePlanDocumento() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (planId: UUID) => plans_generate_document(planId),
|
||||
onSuccess: (_doc, planId) => {
|
||||
qc.invalidateQueries({ queryKey: qk.planDocumento(planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(planId) });
|
||||
qc.invalidateQueries({ queryKey: qk.planDocumento(planId) })
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(planId) })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteLinea() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: lineas_delete,
|
||||
onSuccess: (_idEliminado) => {
|
||||
// Invalidamos para que las materias y líneas se refresquen
|
||||
qc.invalidateQueries({ queryKey: ['plan_lineas'] })
|
||||
qc.invalidateQueries({ queryKey: ['plan_asignaturas'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,166 +1,319 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../query/keys";
|
||||
import type { UUID } from "../types/domain";
|
||||
import type {
|
||||
BibliografiaUpsertInput,
|
||||
SubjectsCreateManualInput,
|
||||
SubjectsUpdateFieldsPatch,
|
||||
} from "../api/subjects.api";
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
ai_generate_subject,
|
||||
asignaturas_update,
|
||||
bibliografia_delete,
|
||||
bibliografia_insert,
|
||||
bibliografia_update,
|
||||
lineas_insert,
|
||||
lineas_update,
|
||||
subjects_bibliografia_list,
|
||||
subjects_clone_from_existing,
|
||||
subjects_create_manual,
|
||||
subjects_generate_document,
|
||||
subjects_get,
|
||||
subjects_get_document,
|
||||
subjects_get_structure_catalog,
|
||||
subjects_history,
|
||||
subjects_import_from_file,
|
||||
subjects_persist_from_ai,
|
||||
subjects_update_bibliografia,
|
||||
subjects_update_contenido,
|
||||
subjects_update_fields,
|
||||
} from "../api/subjects.api";
|
||||
} from '../api/subjects.api'
|
||||
import { qk } from '../query/keys'
|
||||
|
||||
import type {
|
||||
BibliografiaUpsertInput,
|
||||
ContenidoApi,
|
||||
SubjectsUpdateFieldsPatch,
|
||||
} from '../api/subjects.api'
|
||||
import type { UUID } from '../types/domain'
|
||||
import type { TablesInsert } from '@/types/supabase'
|
||||
|
||||
export function useSubject(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignatura(subjectId) : ["asignaturas", "detail", null],
|
||||
queryKey: subjectId
|
||||
? qk.asignatura(subjectId)
|
||||
: ['asignaturas', 'detail', null],
|
||||
queryFn: () => subjects_get(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useSubjectBibliografia(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignaturaBibliografia(subjectId) : ["asignaturas", "bibliografia", null],
|
||||
queryKey: subjectId
|
||||
? qk.asignaturaBibliografia(subjectId)
|
||||
: ['asignaturas', 'bibliografia', null],
|
||||
queryFn: () => subjects_bibliografia_list(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useSubjectHistorial(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignaturaHistorial(subjectId) : ["asignaturas", "historial", null],
|
||||
queryKey: subjectId
|
||||
? qk.asignaturaHistorial(subjectId)
|
||||
: ['asignaturas', 'historial', null],
|
||||
queryFn: () => subjects_history(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useSubjectDocumento(subjectId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: subjectId ? qk.asignaturaDocumento(subjectId) : ["asignaturas", "documento", null],
|
||||
queryKey: subjectId
|
||||
? qk.asignaturaDocumento(subjectId)
|
||||
: ['asignaturas', 'documento', null],
|
||||
queryFn: () => subjects_get_document(subjectId as UUID),
|
||||
enabled: Boolean(subjectId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useSubjectEstructuras() {
|
||||
return useQuery({
|
||||
queryKey: qk.estructurasAsignatura(),
|
||||
queryFn: () => subjects_get_structure_catalog(),
|
||||
})
|
||||
}
|
||||
|
||||
/* ------------------ Mutations ------------------ */
|
||||
|
||||
export function useCreateSubjectManual() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: SubjectsCreateManualInput) => subjects_create_manual(payload),
|
||||
mutationFn: (payload: TablesInsert<'asignaturas'>) =>
|
||||
subjects_create_manual(payload),
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject)
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planHistorial(subject.plan_estudio_id),
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useGenerateSubjectAI() {
|
||||
return useMutation({ mutationFn: ai_generate_subject });
|
||||
return useMutation({
|
||||
mutationFn: ai_generate_subject,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePersistSubjectFromAI() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (payload: { planId: UUID; jsonMateria: any }) => subjects_persist_from_ai(payload),
|
||||
mutationFn: (payload: { planId: UUID; jsonAsignatura: any }) =>
|
||||
subjects_persist_from_ai(payload),
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject)
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planHistorial(subject.plan_estudio_id),
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useCloneSubject() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: subjects_clone_from_existing,
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject)
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planHistorial(subject.plan_estudio_id),
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useImportSubjectFromFile() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: subjects_import_from_file,
|
||||
onSuccess: (subject) => {
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(subject.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.planHistorial(subject.plan_estudio_id) });
|
||||
qc.setQueryData(qk.asignatura(subject.id), subject)
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planHistorial(subject.plan_estudio_id),
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateSubjectFields() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) =>
|
||||
subjects_update_fields(vars.subjectId, vars.patch),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(qk.asignatura(updated.id), updated);
|
||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(updated.plan_estudio_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) });
|
||||
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
|
||||
prev ? { ...(prev as any), ...(updated as any) } : updated,
|
||||
)
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateSubjectContenido() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { subjectId: UUID; unidades: any[] }) =>
|
||||
mutationFn: (vars: { subjectId: UUID; unidades: Array<ContenidoApi> }) =>
|
||||
subjects_update_contenido(vars.subjectId, vars.unidades),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(qk.asignatura(updated.id), updated);
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) });
|
||||
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
|
||||
prev ? { ...(prev as any), ...(updated as any) } : updated,
|
||||
)
|
||||
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planHistorial(updated.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateSubjectBibliografia() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: { subjectId: UUID; entries: BibliografiaUpsertInput }) =>
|
||||
subjects_update_bibliografia(vars.subjectId, vars.entries),
|
||||
onSuccess: (_ok, vars) => {
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaBibliografia(vars.subjectId) });
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(vars.subjectId) });
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.asignaturaBibliografia(vars.subjectId),
|
||||
})
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(vars.subjectId) })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useGenerateSubjectDocumento() {
|
||||
const qc = useQueryClient();
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (subjectId: UUID) => subjects_generate_document(subjectId),
|
||||
onSuccess: (_doc, subjectId) => {
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaDocumento(subjectId) });
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(subjectId) });
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaDocumento(subjectId) })
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(subjectId) })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateAsignatura() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: {
|
||||
asignaturaId: UUID
|
||||
patch: Partial<SubjectsUpdateFieldsPatch>
|
||||
}) => asignaturas_update(vars.asignaturaId, vars.patch),
|
||||
|
||||
onSuccess: (updated) => {
|
||||
// ✅ Mantener consistencia con las query keys centralizadas (qk)
|
||||
// 1) Actualiza el detalle (esto evita volver a entrar con caché vieja)
|
||||
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
|
||||
prev ? { ...(prev as any), ...(updated as any) } : updated,
|
||||
)
|
||||
|
||||
// 2) Refresca vistas derivadas del plan
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
|
||||
})
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.planHistorial(updated.plan_estudio_id),
|
||||
})
|
||||
|
||||
// 3) Refresca historial de la asignatura si existe
|
||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateLinea() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: lineas_insert,
|
||||
onSuccess: (nuevaLinea) => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: ['plan_lineas', nuevaLinea.plan_estudio_id],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateLinea() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (vars: { lineaId: string; patch: any }) =>
|
||||
lineas_update(vars.lineaId, vars.patch),
|
||||
onSuccess: (updated) => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: ['plan_lineas', updated.plan_estudio_id],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateBibliografia() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: bibliografia_insert,
|
||||
onSuccess: (data) => {
|
||||
// USAR LA MISMA LLAVE QUE EL HOOK DE LECTURA
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: qk.asignaturaBibliografia(data.asignatura_id),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateBibliografia(asignaturaId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, updates }: { id: string; updates: any }) =>
|
||||
bibliografia_update(id, updates),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: qk.asignaturaBibliografia(asignaturaId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteBibliografia(asignaturaId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => bibliografia_delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: qk.asignaturaBibliografia(asignaturaId),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
311
src/data/mockAsignaturaData.ts
Normal file
311
src/data/mockAsignaturaData.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import type {
|
||||
Asignatura,
|
||||
AsignaturaStructure,
|
||||
UnidadTematica,
|
||||
BibliografiaEntry,
|
||||
CambioAsignatura,
|
||||
DocumentoAsignatura,
|
||||
} from '@/types/asignatura'
|
||||
|
||||
export const mockAsignatura: Asignatura = {
|
||||
id: '1',
|
||||
nombre: 'Inteligencia Artificial Aplicada',
|
||||
clave: 'IAA-401',
|
||||
creditos: 8,
|
||||
lineaCurricular: 'Sistemas Inteligentes',
|
||||
ciclo: '7° Semestre',
|
||||
planId: 'plan-1',
|
||||
planNombre: 'Licenciatura en Ingeniería en Sistemas Computacionales 2024',
|
||||
carrera: 'Ingeniería en Sistemas Computacionales',
|
||||
facultad: 'Facultad de Ingeniería',
|
||||
estructuraId: 'estructura-1',
|
||||
}
|
||||
|
||||
export const mockEstructura: AsignaturaStructure = {
|
||||
id: 'estructura-1',
|
||||
nombre: 'Plantilla SEP Licenciatura',
|
||||
campos: [
|
||||
{
|
||||
id: 'objetivo_general',
|
||||
nombre: 'Objetivo General',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: true,
|
||||
descripcion: 'Describe el propósito principal de la asignatura',
|
||||
placeholder: 'Al finalizar el curso, el estudiante será capaz de...',
|
||||
},
|
||||
{
|
||||
id: 'competencias',
|
||||
nombre: 'Competencias a Desarrollar',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: true,
|
||||
descripcion: 'Competencias profesionales que se desarrollarán',
|
||||
},
|
||||
{
|
||||
id: 'justificacion',
|
||||
nombre: 'Justificación',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: true,
|
||||
descripcion: 'Relevancia de la asignatura en el plan de estudios',
|
||||
},
|
||||
{
|
||||
id: 'requisitos',
|
||||
nombre: 'Requisitos / Seriación',
|
||||
tipo: 'texto',
|
||||
obligatorio: false,
|
||||
descripcion: 'Asignaturas previas requeridas',
|
||||
},
|
||||
{
|
||||
id: 'estrategias_didacticas',
|
||||
nombre: 'Estrategias Didácticas',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: true,
|
||||
descripcion: 'Métodos de enseñanza-aprendizaje',
|
||||
},
|
||||
{
|
||||
id: 'evaluacion',
|
||||
nombre: 'Sistema de Evaluación',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: true,
|
||||
descripcion: 'Criterios y porcentajes de evaluación',
|
||||
},
|
||||
{
|
||||
id: 'perfil_docente',
|
||||
nombre: 'Perfil del Docente',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: false,
|
||||
descripcion: 'Características requeridas del profesor',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const mockDatosGenerales: Record<string, any> = {
|
||||
objetivo_general:
|
||||
'Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos del mundo real, aplicando principios éticos y metodologías actuales en el campo.',
|
||||
competencias:
|
||||
'• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes y texto\n• Evaluar y optimizar modelos de IA considerando métricas de rendimiento\n• Aplicar principios éticos en el desarrollo de sistemas inteligentes',
|
||||
justificacion:
|
||||
'La inteligencia artificial es una de las tecnologías más disruptivas del siglo XXI. Su integración en diversos sectores demanda profesionales con sólidas bases teóricas y prácticas. Esta asignatura proporciona las competencias necesarias para que el egresado pueda innovar y contribuir al desarrollo tecnológico del país.',
|
||||
requisitos:
|
||||
'Programación Avanzada (PAV-301), Matemáticas Discretas (MAT-201)',
|
||||
estrategias_didacticas:
|
||||
'• Aprendizaje basado en proyectos\n• Talleres prácticos con datasets reales\n• Exposiciones y discusiones grupales\n• Análisis de casos de estudio\n• Desarrollo de prototipo integrador',
|
||||
evaluacion:
|
||||
'• Exámenes parciales: 30%\n• Proyecto integrador: 35%\n• Prácticas de laboratorio: 20%\n• Participación y tareas: 15%',
|
||||
perfil_docente:
|
||||
'Profesional con maestría o doctorado en áreas afines a la inteligencia artificial, con experiencia mínima de 3 años en docencia y desarrollo de proyectos de IA.',
|
||||
}
|
||||
|
||||
export const mockContenidoTematico: Array<UnidadTematica> = [
|
||||
{
|
||||
id: 'unidad-1',
|
||||
nombre: 'Fundamentos de Inteligencia Artificial',
|
||||
numero: 1,
|
||||
temas: [
|
||||
{
|
||||
id: 'tema-1-1',
|
||||
nombre: 'Historia y evolución de la IA',
|
||||
descripcion: 'Desde los orígenes hasta la actualidad',
|
||||
horasEstimadas: 2,
|
||||
},
|
||||
{
|
||||
id: 'tema-1-2',
|
||||
nombre: 'Tipos de IA y aplicaciones',
|
||||
descripcion: 'IA débil, fuerte y superinteligencia',
|
||||
horasEstimadas: 3,
|
||||
},
|
||||
{
|
||||
id: 'tema-1-3',
|
||||
nombre: 'Ética en IA',
|
||||
descripcion: 'Consideraciones éticas y responsabilidad',
|
||||
horasEstimadas: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'unidad-2',
|
||||
nombre: 'Machine Learning',
|
||||
numero: 2,
|
||||
temas: [
|
||||
{
|
||||
id: 'tema-2-1',
|
||||
nombre: 'Aprendizaje supervisado',
|
||||
descripcion: 'Regresión y clasificación',
|
||||
horasEstimadas: 6,
|
||||
},
|
||||
{
|
||||
id: 'tema-2-2',
|
||||
nombre: 'Aprendizaje no supervisado',
|
||||
descripcion: 'Clustering y reducción de dimensionalidad',
|
||||
horasEstimadas: 5,
|
||||
},
|
||||
{
|
||||
id: 'tema-2-3',
|
||||
nombre: 'Evaluación de modelos',
|
||||
descripcion: 'Métricas y validación cruzada',
|
||||
horasEstimadas: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'unidad-3',
|
||||
nombre: 'Deep Learning',
|
||||
numero: 3,
|
||||
temas: [
|
||||
{
|
||||
id: 'tema-3-1',
|
||||
nombre: 'Redes neuronales artificiales',
|
||||
descripcion: 'Perceptrón y backpropagation',
|
||||
horasEstimadas: 5,
|
||||
},
|
||||
{
|
||||
id: 'tema-3-2',
|
||||
nombre: 'Redes convolucionales (CNN)',
|
||||
descripcion: 'Procesamiento de imágenes',
|
||||
horasEstimadas: 6,
|
||||
},
|
||||
{
|
||||
id: 'tema-3-3',
|
||||
nombre: 'Redes recurrentes (RNN)',
|
||||
descripcion: 'Procesamiento de secuencias',
|
||||
horasEstimadas: 5,
|
||||
},
|
||||
{
|
||||
id: 'tema-3-4',
|
||||
nombre: 'Transformers y atención',
|
||||
descripcion: 'Arquitecturas modernas',
|
||||
horasEstimadas: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'unidad-4',
|
||||
nombre: 'Aplicaciones Prácticas',
|
||||
numero: 4,
|
||||
temas: [
|
||||
{
|
||||
id: 'tema-4-1',
|
||||
nombre: 'Procesamiento de lenguaje natural',
|
||||
descripcion: 'NLP y chatbots',
|
||||
horasEstimadas: 6,
|
||||
},
|
||||
{
|
||||
id: 'tema-4-2',
|
||||
nombre: 'Visión por computadora',
|
||||
descripcion: 'Detección y reconocimiento',
|
||||
horasEstimadas: 5,
|
||||
},
|
||||
{
|
||||
id: 'tema-4-3',
|
||||
nombre: 'Sistemas de recomendación',
|
||||
descripcion: 'Filtrado colaborativo y contenido',
|
||||
horasEstimadas: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const mockBibliografia: Array<BibliografiaEntry> = [
|
||||
{
|
||||
id: 'bib-1',
|
||||
tipo: 'BASICA',
|
||||
cita: 'Russell, S., & Norvig, P. (2021). Artificial Intelligence: A Modern Approach (4th ed.). Pearson.',
|
||||
fuenteBibliotecaId: 'lib-1',
|
||||
fuenteBiblioteca: {
|
||||
id: 'lib-1',
|
||||
titulo: 'Artificial Intelligence: A Modern Approach',
|
||||
autor: 'Stuart Russell, Peter Norvig',
|
||||
editorial: 'Pearson',
|
||||
anio: 2021,
|
||||
isbn: '978-0134610993',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bib-2',
|
||||
tipo: 'BASICA',
|
||||
cita: "Géron, A. (2022). Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow (3rd ed.). O'Reilly Media.",
|
||||
fuenteBibliotecaId: 'lib-2',
|
||||
fuenteBiblioteca: {
|
||||
id: 'lib-2',
|
||||
titulo:
|
||||
'Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow',
|
||||
autor: 'Aurélien Géron',
|
||||
editorial: "O'Reilly Media",
|
||||
anio: 2022,
|
||||
isbn: '978-1098125974',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bib-3',
|
||||
tipo: 'COMPLEMENTARIA',
|
||||
cita: 'Goodfellow, I., Bengio, Y., & Courville, A. (2016). Deep Learning. MIT Press.',
|
||||
},
|
||||
{
|
||||
id: 'bib-4',
|
||||
tipo: 'COMPLEMENTARIA',
|
||||
cita: 'Chollet, F. (2021). Deep Learning with Python (2nd ed.). Manning Publications.',
|
||||
fuenteBibliotecaId: 'lib-4',
|
||||
fuenteBiblioteca: {
|
||||
id: 'lib-4',
|
||||
titulo: 'Deep Learning with Python',
|
||||
autor: 'François Chollet',
|
||||
editorial: 'Manning Publications',
|
||||
anio: 2021,
|
||||
isbn: '978-1617296864',
|
||||
tipo: 'libro',
|
||||
disponible: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const mockHistorial: Array<CambioAsignatura> = [
|
||||
{
|
||||
id: 'cambio-1',
|
||||
tipo: 'datos',
|
||||
descripcion: 'Actualización del objetivo general',
|
||||
usuario: 'Dr. Carlos Méndez',
|
||||
fecha: new Date('2024-12-10T14:30:00'),
|
||||
detalles: { campo: 'objetivo_general' },
|
||||
},
|
||||
{
|
||||
id: 'cambio-2',
|
||||
tipo: 'contenido',
|
||||
descripcion: 'Agregada Unidad 4: Aplicaciones Prácticas',
|
||||
usuario: 'Dr. Carlos Méndez',
|
||||
fecha: new Date('2024-12-09T10:15:00'),
|
||||
detalles: { unidad: 'Unidad 4' },
|
||||
},
|
||||
{
|
||||
id: 'cambio-3',
|
||||
tipo: 'ia',
|
||||
descripcion: 'IA mejoró las competencias a desarrollar',
|
||||
usuario: 'Dra. María López',
|
||||
fecha: new Date('2024-12-08T16:45:00'),
|
||||
detalles: { campo: 'competencias', accion: 'mejora' },
|
||||
},
|
||||
{
|
||||
id: 'cambio-4',
|
||||
tipo: 'bibliografia',
|
||||
descripcion: 'Añadida referencia: Deep Learning with Python',
|
||||
usuario: 'Biblioteca Central',
|
||||
fecha: new Date('2024-12-07T09:00:00'),
|
||||
},
|
||||
{
|
||||
id: 'cambio-5',
|
||||
tipo: 'documento',
|
||||
descripcion: 'Documento SEP regenerado (versión 3)',
|
||||
usuario: 'Sistema',
|
||||
fecha: new Date('2024-12-06T11:30:00'),
|
||||
},
|
||||
]
|
||||
|
||||
export const mockDocumentoSep: DocumentoAsignatura = {
|
||||
id: 'doc-1',
|
||||
asignaturaId: '1',
|
||||
version: 3,
|
||||
fechaGeneracion: new Date('2024-12-06T11:30:00'),
|
||||
estado: 'listo',
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
import type {
|
||||
Materia,
|
||||
MateriaStructure,
|
||||
UnidadTematica,
|
||||
BibliografiaEntry,
|
||||
CambioMateria,
|
||||
DocumentoMateria,
|
||||
LibraryResource
|
||||
} from '@/types/materia';
|
||||
|
||||
export const mockMateria: Materia = {
|
||||
id: '1',
|
||||
nombre: 'Inteligencia Artificial Aplicada',
|
||||
clave: 'IAA-401',
|
||||
creditos: 8,
|
||||
lineaCurricular: 'Sistemas Inteligentes',
|
||||
ciclo: '7° Semestre',
|
||||
planId: 'plan-1',
|
||||
planNombre: 'Licenciatura en Ingeniería en Sistemas Computacionales 2024',
|
||||
carrera: 'Ingeniería en Sistemas Computacionales',
|
||||
facultad: 'Facultad de Ingeniería',
|
||||
estructuraId: 'estructura-1',
|
||||
};
|
||||
|
||||
export const mockEstructura: MateriaStructure = {
|
||||
id: 'estructura-1',
|
||||
nombre: 'Plantilla SEP Licenciatura',
|
||||
campos: [
|
||||
{
|
||||
id: 'objetivo_general',
|
||||
nombre: 'Objetivo General',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: true,
|
||||
descripcion: 'Describe el propósito principal de la materia',
|
||||
placeholder: 'Al finalizar el curso, el estudiante será capaz de...',
|
||||
},
|
||||
{
|
||||
id: 'competencias',
|
||||
nombre: 'Competencias a Desarrollar',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: true,
|
||||
descripcion: 'Competencias profesionales que se desarrollarán',
|
||||
},
|
||||
{
|
||||
id: 'justificacion',
|
||||
nombre: 'Justificación',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: true,
|
||||
descripcion: 'Relevancia de la materia en el plan de estudios',
|
||||
},
|
||||
{
|
||||
id: 'requisitos',
|
||||
nombre: 'Requisitos / Seriación',
|
||||
tipo: 'texto',
|
||||
obligatorio: false,
|
||||
descripcion: 'Materias previas requeridas',
|
||||
},
|
||||
{
|
||||
id: 'estrategias_didacticas',
|
||||
nombre: 'Estrategias Didácticas',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: true,
|
||||
descripcion: 'Métodos de enseñanza-aprendizaje',
|
||||
},
|
||||
{
|
||||
id: 'evaluacion',
|
||||
nombre: 'Sistema de Evaluación',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: true,
|
||||
descripcion: 'Criterios y porcentajes de evaluación',
|
||||
},
|
||||
{
|
||||
id: 'perfil_docente',
|
||||
nombre: 'Perfil del Docente',
|
||||
tipo: 'texto_largo',
|
||||
obligatorio: false,
|
||||
descripcion: 'Características requeridas del profesor',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockDatosGenerales: Record<string, any> = {
|
||||
objetivo_general: 'Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos del mundo real, aplicando principios éticos y metodologías actuales en el campo.',
|
||||
competencias: '• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes y texto\n• Evaluar y optimizar modelos de IA considerando métricas de rendimiento\n• Aplicar principios éticos en el desarrollo de sistemas inteligentes',
|
||||
justificacion: 'La inteligencia artificial es una de las tecnologías más disruptivas del siglo XXI. Su integración en diversos sectores demanda profesionales con sólidas bases teóricas y prácticas. Esta materia proporciona las competencias necesarias para que el egresado pueda innovar y contribuir al desarrollo tecnológico del país.',
|
||||
requisitos: 'Programación Avanzada (PAV-301), Matemáticas Discretas (MAT-201)',
|
||||
estrategias_didacticas: '• Aprendizaje basado en proyectos\n• Talleres prácticos con datasets reales\n• Exposiciones y discusiones grupales\n• Análisis de casos de estudio\n• Desarrollo de prototipo integrador',
|
||||
evaluacion: '• Exámenes parciales: 30%\n• Proyecto integrador: 35%\n• Prácticas de laboratorio: 20%\n• Participación y tareas: 15%',
|
||||
perfil_docente: 'Profesional con maestría o doctorado en áreas afines a la inteligencia artificial, con experiencia mínima de 3 años en docencia y desarrollo de proyectos de IA.',
|
||||
};
|
||||
|
||||
export const mockContenidoTematico: UnidadTematica[] = [
|
||||
{
|
||||
id: 'unidad-1',
|
||||
nombre: 'Fundamentos de Inteligencia Artificial',
|
||||
numero: 1,
|
||||
temas: [
|
||||
{ id: 'tema-1-1', nombre: 'Historia y evolución de la IA', descripcion: 'Desde los orígenes hasta la actualidad', horasEstimadas: 2 },
|
||||
{ id: 'tema-1-2', nombre: 'Tipos de IA y aplicaciones', descripcion: 'IA débil, fuerte y superinteligencia', horasEstimadas: 3 },
|
||||
{ id: 'tema-1-3', nombre: 'Ética en IA', descripcion: 'Consideraciones éticas y responsabilidad', horasEstimadas: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'unidad-2',
|
||||
nombre: 'Machine Learning',
|
||||
numero: 2,
|
||||
temas: [
|
||||
{ id: 'tema-2-1', nombre: 'Aprendizaje supervisado', descripcion: 'Regresión y clasificación', horasEstimadas: 6 },
|
||||
{ id: 'tema-2-2', nombre: 'Aprendizaje no supervisado', descripcion: 'Clustering y reducción de dimensionalidad', horasEstimadas: 5 },
|
||||
{ id: 'tema-2-3', nombre: 'Evaluación de modelos', descripcion: 'Métricas y validación cruzada', horasEstimadas: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'unidad-3',
|
||||
nombre: 'Deep Learning',
|
||||
numero: 3,
|
||||
temas: [
|
||||
{ id: 'tema-3-1', nombre: 'Redes neuronales artificiales', descripcion: 'Perceptrón y backpropagation', horasEstimadas: 5 },
|
||||
{ id: 'tema-3-2', nombre: 'Redes convolucionales (CNN)', descripcion: 'Procesamiento de imágenes', horasEstimadas: 6 },
|
||||
{ id: 'tema-3-3', nombre: 'Redes recurrentes (RNN)', descripcion: 'Procesamiento de secuencias', horasEstimadas: 5 },
|
||||
{ id: 'tema-3-4', nombre: 'Transformers y atención', descripcion: 'Arquitecturas modernas', horasEstimadas: 6 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'unidad-4',
|
||||
nombre: 'Aplicaciones Prácticas',
|
||||
numero: 4,
|
||||
temas: [
|
||||
{ id: 'tema-4-1', nombre: 'Procesamiento de lenguaje natural', descripcion: 'NLP y chatbots', horasEstimadas: 6 },
|
||||
{ id: 'tema-4-2', nombre: 'Visión por computadora', descripcion: 'Detección y reconocimiento', horasEstimadas: 5 },
|
||||
{ id: 'tema-4-3', nombre: 'Sistemas de recomendación', descripcion: 'Filtrado colaborativo y contenido', horasEstimadas: 4 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockBibliografia: BibliografiaEntry[] = [
|
||||
{
|
||||
id: 'bib-1',
|
||||
tipo: 'BASICA',
|
||||
cita: 'Russell, S., & Norvig, P. (2021). Artificial Intelligence: A Modern Approach (4th ed.). Pearson.',
|
||||
fuenteBibliotecaId: 'lib-1',
|
||||
fuenteBiblioteca: {
|
||||
id: 'lib-1',
|
||||
titulo: 'Artificial Intelligence: A Modern Approach',
|
||||
autor: 'Stuart Russell, Peter Norvig',
|
||||
editorial: 'Pearson',
|
||||
anio: 2021,
|
||||
isbn: '978-0134610993',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bib-2',
|
||||
tipo: 'BASICA',
|
||||
cita: 'Géron, A. (2022). Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow (3rd ed.). O\'Reilly Media.',
|
||||
fuenteBibliotecaId: 'lib-2',
|
||||
fuenteBiblioteca: {
|
||||
id: 'lib-2',
|
||||
titulo: 'Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow',
|
||||
autor: 'Aurélien Géron',
|
||||
editorial: 'O\'Reilly Media',
|
||||
anio: 2022,
|
||||
isbn: '978-1098125974',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bib-3',
|
||||
tipo: 'COMPLEMENTARIA',
|
||||
cita: 'Goodfellow, I., Bengio, Y., & Courville, A. (2016). Deep Learning. MIT Press.',
|
||||
},
|
||||
{
|
||||
id: 'bib-4',
|
||||
tipo: 'COMPLEMENTARIA',
|
||||
cita: 'Chollet, F. (2021). Deep Learning with Python (2nd ed.). Manning Publications.',
|
||||
fuenteBibliotecaId: 'lib-4',
|
||||
fuenteBiblioteca: {
|
||||
id: 'lib-4',
|
||||
titulo: 'Deep Learning with Python',
|
||||
autor: 'François Chollet',
|
||||
editorial: 'Manning Publications',
|
||||
anio: 2021,
|
||||
isbn: '978-1617296864',
|
||||
tipo: 'libro',
|
||||
disponible: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const mockHistorial: CambioMateria[] = [
|
||||
{
|
||||
id: 'cambio-1',
|
||||
tipo: 'datos',
|
||||
descripcion: 'Actualización del objetivo general',
|
||||
usuario: 'Dr. Carlos Méndez',
|
||||
fecha: new Date('2024-12-10T14:30:00'),
|
||||
detalles: { campo: 'objetivo_general' },
|
||||
},
|
||||
{
|
||||
id: 'cambio-2',
|
||||
tipo: 'contenido',
|
||||
descripcion: 'Agregada Unidad 4: Aplicaciones Prácticas',
|
||||
usuario: 'Dr. Carlos Méndez',
|
||||
fecha: new Date('2024-12-09T10:15:00'),
|
||||
detalles: { unidad: 'Unidad 4' },
|
||||
},
|
||||
{
|
||||
id: 'cambio-3',
|
||||
tipo: 'ia',
|
||||
descripcion: 'IA mejoró las competencias a desarrollar',
|
||||
usuario: 'Dra. María López',
|
||||
fecha: new Date('2024-12-08T16:45:00'),
|
||||
detalles: { campo: 'competencias', accion: 'mejora' },
|
||||
},
|
||||
{
|
||||
id: 'cambio-4',
|
||||
tipo: 'bibliografia',
|
||||
descripcion: 'Añadida referencia: Deep Learning with Python',
|
||||
usuario: 'Biblioteca Central',
|
||||
fecha: new Date('2024-12-07T09:00:00'),
|
||||
},
|
||||
{
|
||||
id: 'cambio-5',
|
||||
tipo: 'documento',
|
||||
descripcion: 'Documento SEP regenerado (versión 3)',
|
||||
usuario: 'Sistema',
|
||||
fecha: new Date('2024-12-06T11:30:00'),
|
||||
},
|
||||
];
|
||||
|
||||
export const mockDocumentoSep: DocumentoMateria = {
|
||||
id: 'doc-1',
|
||||
materiaId: '1',
|
||||
version: 3,
|
||||
fechaGeneracion: new Date('2024-12-06T11:30:00'),
|
||||
estado: 'listo',
|
||||
};
|
||||
|
||||
export const mockLibraryResources: LibraryResource[] = [
|
||||
{
|
||||
id: 'lib-1',
|
||||
titulo: 'Artificial Intelligence: A Modern Approach',
|
||||
autor: 'Stuart Russell, Peter Norvig',
|
||||
editorial: 'Pearson',
|
||||
anio: 2021,
|
||||
isbn: '978-0134610993',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
{
|
||||
id: 'lib-2',
|
||||
titulo: 'Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow',
|
||||
autor: 'Aurélien Géron',
|
||||
editorial: 'O\'Reilly Media',
|
||||
anio: 2022,
|
||||
isbn: '978-1098125974',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
{
|
||||
id: 'lib-3',
|
||||
titulo: 'Pattern Recognition and Machine Learning',
|
||||
autor: 'Christopher Bishop',
|
||||
editorial: 'Springer',
|
||||
anio: 2006,
|
||||
isbn: '978-0387310732',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
{
|
||||
id: 'lib-4',
|
||||
titulo: 'Deep Learning with Python',
|
||||
autor: 'François Chollet',
|
||||
editorial: 'Manning Publications',
|
||||
anio: 2021,
|
||||
isbn: '978-1617296864',
|
||||
tipo: 'libro',
|
||||
disponible: false,
|
||||
},
|
||||
{
|
||||
id: 'lib-5',
|
||||
titulo: 'Neural Networks and Deep Learning: A Textbook',
|
||||
autor: 'Charu C. Aggarwal',
|
||||
editorial: 'Springer',
|
||||
anio: 2023,
|
||||
isbn: '978-3031296413',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
{
|
||||
id: 'lib-6',
|
||||
titulo: 'Machine Learning: A Probabilistic Perspective',
|
||||
autor: 'Kevin Murphy',
|
||||
editorial: 'MIT Press',
|
||||
anio: 2012,
|
||||
isbn: '978-0262018029',
|
||||
tipo: 'libro',
|
||||
disponible: true,
|
||||
},
|
||||
];
|
||||
@@ -1,31 +1,37 @@
|
||||
export const qk = {
|
||||
auth: ["auth"] as const,
|
||||
session: () => ["auth", "session"] as const,
|
||||
meProfile: () => ["auth", "meProfile"] as const,
|
||||
auth: ['auth'] as const,
|
||||
session: () => ['auth', 'session'] as const,
|
||||
meProfile: () => ['auth', 'meProfile'] as const,
|
||||
|
||||
facultades: () => ["meta", "facultades"] as const,
|
||||
facultades: () => ['meta', 'facultades'] as const,
|
||||
carreras: (facultadId?: string | null) =>
|
||||
["meta", "carreras", { facultadId: facultadId ?? null }] as const,
|
||||
['meta', 'carreras', { facultadId: facultadId ?? null }] as const,
|
||||
estructurasPlan: (nivel?: string | null) =>
|
||||
["meta", "estructurasPlan", { nivel: nivel ?? null }] as const,
|
||||
estructurasAsignatura: () => ["meta", "estructurasAsignatura"] as const,
|
||||
estadosPlan: () => ["meta", "estadosPlan"] as const,
|
||||
['meta', 'estructurasPlan', { nivel: nivel ?? null }] as const,
|
||||
estructurasAsignatura: () => ['meta', 'estructurasAsignatura'] as const,
|
||||
estadosPlan: () => ['meta', 'estadosPlan'] as const,
|
||||
|
||||
planesList: (filters: unknown) => ["planes", "list", filters] as const,
|
||||
plan: (planId: string) => ["planes", "detail", planId] as const,
|
||||
planLineas: (planId: string) => ["planes", planId, "lineas"] as const,
|
||||
planAsignaturas: (planId: string) => ["planes", planId, "asignaturas"] as const,
|
||||
planHistorial: (planId: string) => ["planes", planId, "historial"] as const,
|
||||
planDocumento: (planId: string) => ["planes", planId, "documento"] as const,
|
||||
planesList: (filters: unknown) => ['planes', 'list', filters] as const,
|
||||
plan: (planId: string) => ['planes', 'detail', planId] as const,
|
||||
planMaybe: (planId: string) => ['planes', 'detail-maybe', planId] as const,
|
||||
planLineas: (planId: string) => ['planes', planId, 'lineas'] as const,
|
||||
planAsignaturas: (planId: string) =>
|
||||
['planes', planId, 'asignaturas'] as const,
|
||||
planHistorial: (planId: string) => ['planes', planId, 'historial'] as const,
|
||||
planDocumento: (planId: string) => ['planes', planId, 'documento'] as const,
|
||||
|
||||
asignatura: (asignaturaId: string) => ["asignaturas", "detail", asignaturaId] as const,
|
||||
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
|
||||
asignatura: (asignaturaId: string) =>
|
||||
['asignaturas', 'detail', asignaturaId] as const,
|
||||
asignaturaMaybe: (asignaturaId: string) =>
|
||||
['asignaturas', 'detail-maybe', asignaturaId] as const,
|
||||
asignaturaBibliografia: (asignaturaId: string) =>
|
||||
["asignaturas", asignaturaId, "bibliografia"] as const,
|
||||
['asignaturas', asignaturaId, 'bibliografia'] as const,
|
||||
asignaturaHistorial: (asignaturaId: string) =>
|
||||
["asignaturas", asignaturaId, "historial"] as const,
|
||||
['asignaturas', asignaturaId, 'historial'] as const,
|
||||
asignaturaDocumento: (asignaturaId: string) =>
|
||||
["asignaturas", asignaturaId, "documento"] as const,
|
||||
['asignaturas', asignaturaId, 'documento'] as const,
|
||||
|
||||
tareas: () => ["tareas", "mias"] as const,
|
||||
notificaciones: () => ["notificaciones", "mias"] as const,
|
||||
};
|
||||
tareas: () => ['tareas', 'mias'] as const,
|
||||
notificaciones: () => ['notificaciones', 'mias'] as const,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createClient } from "@supabase/supabase-js";
|
||||
import { getEnv } from "./env";
|
||||
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "src/types/supabase.js";
|
||||
import type { Database } from "src/types/supabase";
|
||||
|
||||
let _client: SupabaseClient<Database> | null = null;
|
||||
|
||||
|
||||
@@ -1,47 +1,108 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "../types/database";
|
||||
import { supabaseBrowser } from "./client";
|
||||
import {
|
||||
FunctionsFetchError,
|
||||
FunctionsHttpError,
|
||||
FunctionsRelayError,
|
||||
} from '@supabase/supabase-js'
|
||||
|
||||
import { supabaseBrowser } from './client'
|
||||
|
||||
import type { Database } from '@/types/supabase'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
export type EdgeInvokeOptions = {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export class EdgeFunctionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly functionName: string,
|
||||
public readonly status?: number,
|
||||
public readonly details?: unknown
|
||||
public readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "EdgeFunctionError";
|
||||
super(message)
|
||||
this.name = 'EdgeFunctionError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function invokeEdge<TOut>(
|
||||
functionName: string,
|
||||
body?: unknown,
|
||||
body?:
|
||||
| string
|
||||
| File
|
||||
| Blob
|
||||
| ArrayBuffer
|
||||
| FormData
|
||||
| ReadableStream<Uint8Array<ArrayBufferLike>>
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
opts: EdgeInvokeOptions = {},
|
||||
client?: SupabaseClient<Database>
|
||||
client?: SupabaseClient<Database>,
|
||||
): Promise<TOut> {
|
||||
const supabase = client ?? supabaseBrowser();
|
||||
const supabase = client ?? supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase.functions.invoke(functionName, {
|
||||
body,
|
||||
method: opts.method ?? "POST",
|
||||
method: opts.method ?? 'POST',
|
||||
headers: opts.headers,
|
||||
});
|
||||
})
|
||||
|
||||
if (error) {
|
||||
const anyErr = error as any;
|
||||
throw new EdgeFunctionError(
|
||||
anyErr.message ?? "Error en Edge Function",
|
||||
functionName,
|
||||
anyErr.status,
|
||||
anyErr
|
||||
);
|
||||
// Valores por defecto (por si falla el parseo o es otro tipo de error)
|
||||
let message = error.message // El genérico "returned a non-2xx status code"
|
||||
let status = undefined
|
||||
let details: unknown = error
|
||||
|
||||
// 2. Verificamos si es un error HTTP (4xx o 5xx) que trae cuerpo JSON
|
||||
if (error instanceof FunctionsHttpError) {
|
||||
try {
|
||||
// Obtenemos el status real (ej. 404, 400)
|
||||
status = error.context.status
|
||||
|
||||
// ¡LA CLAVE! Leemos el JSON que tu Edge Function envió
|
||||
const errorBody = await error.context.json()
|
||||
details = errorBody
|
||||
|
||||
// Intentamos extraer el mensaje humano según tu estructura { error: { message: "..." } }
|
||||
// o la estructura simple { error: "..." }
|
||||
if (errorBody && typeof errorBody === 'object') {
|
||||
// Caso 1: Estructura anidada (la que definimos hace poco: { error: { message: "..." } })
|
||||
if (
|
||||
'error' in errorBody &&
|
||||
typeof errorBody.error === 'object' &&
|
||||
errorBody.error !== null &&
|
||||
'message' in errorBody.error
|
||||
) {
|
||||
message = (errorBody.error as { message: string }).message
|
||||
}
|
||||
// Caso 2: Estructura simple ({ error: "Mensaje de error" })
|
||||
else if (
|
||||
'error' in errorBody &&
|
||||
typeof errorBody.error === 'string'
|
||||
) {
|
||||
message = errorBody.error
|
||||
}
|
||||
// Caso 3: Propiedad message directa ({ message: "..." })
|
||||
else if (
|
||||
'message' in errorBody &&
|
||||
typeof errorBody.message === 'string'
|
||||
) {
|
||||
message = errorBody.message
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('No se pudo parsear el error JSON de la Edge Function', e)
|
||||
}
|
||||
} else if (error instanceof FunctionsRelayError) {
|
||||
message = `Error de Relay Supabase: ${error.message}`
|
||||
} else if (error instanceof FunctionsFetchError) {
|
||||
message = `Error de conexión (Fetch): ${error.message}`
|
||||
}
|
||||
|
||||
// 3. Lanzamos tu error personalizado con los datos reales extraídos
|
||||
throw new EdgeFunctionError(message, functionName, status, details)
|
||||
}
|
||||
|
||||
return data as TOut;
|
||||
return data as TOut
|
||||
}
|
||||
|
||||
@@ -51,6 +51,17 @@ export type PlanDatosSep = {
|
||||
propuesta_de_evaluacion_periodica_del_plan_de_estudios?: string | null;
|
||||
};
|
||||
|
||||
export type PlanEstudioWithRel =
|
||||
& Tables<"planes_estudio">
|
||||
& {
|
||||
carreras:
|
||||
| Tables<"carreras"> & {
|
||||
facultades: Tables<"facultades"> | null;
|
||||
}
|
||||
| null;
|
||||
estados_plan: Tables<"estados_plan"> | null;
|
||||
};
|
||||
|
||||
export type Paged<T> = { data: Array<T>; count: number | null };
|
||||
|
||||
export type FacultadRow = Tables<"facultades">;
|
||||
|
||||
151
src/features/asignaturas/nueva/AIProgressLoader.tsx
Normal file
151
src/features/asignaturas/nueva/AIProgressLoader.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
|
||||
// --- DEFINICIÓN DE MENSAJES ---
|
||||
const MENSAJES_CORTOS = [
|
||||
// Hasta 5 sugerencias (6 mensajes)
|
||||
'Analizando el plan de estudios...',
|
||||
'Identificando áreas de oportunidad...',
|
||||
'Consultando bases de datos académicas...',
|
||||
'Redactando competencias específicas...',
|
||||
'Calculando créditos y horas...',
|
||||
'Afinando los últimos detalles...',
|
||||
]
|
||||
|
||||
const MENSAJES_MEDIOS = [
|
||||
// Hasta 10 sugerencias (10 mensajes)
|
||||
'Conectando con el motor de IA...',
|
||||
'Analizando estructura curricular...',
|
||||
'Buscando asignaturas compatibles...',
|
||||
'Verificando prerrequisitos...',
|
||||
'Generando descripciones detalladas...',
|
||||
'Balanceando cargas académicas...',
|
||||
'Asignando horas independientes...',
|
||||
'Validando coherencia temática...',
|
||||
'Formateando resultados...',
|
||||
'Finalizando generación...',
|
||||
]
|
||||
|
||||
const MENSAJES_LARGOS = [
|
||||
// Más de 10 sugerencias (14 mensajes)
|
||||
'Iniciando procesamiento masivo...',
|
||||
'Escaneando retícula completa...',
|
||||
'Detectando líneas de investigación...',
|
||||
'Generando primer bloque de asignaturas...',
|
||||
'Evaluando pertinencia académica...',
|
||||
'Optimizando créditos por ciclo...',
|
||||
'Redactando objetivos de aprendizaje...',
|
||||
'Generando segundo bloque...',
|
||||
'Revisando duplicidad de contenidos...',
|
||||
'Ajustando tiempos teóricos y prácticos...',
|
||||
'Verificando normatividad...',
|
||||
'Compilando sugerencias...',
|
||||
'Aplicando formato final...',
|
||||
'Casi listo, gracias por tu paciencia...',
|
||||
]
|
||||
|
||||
interface AIProgressLoaderProps {
|
||||
isLoading: boolean
|
||||
cantidadDeSugerencias: number
|
||||
}
|
||||
|
||||
export const AIProgressLoader: React.FC<AIProgressLoaderProps> = ({
|
||||
isLoading,
|
||||
cantidadDeSugerencias,
|
||||
}) => {
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [currentMessageIndex, setCurrentMessageIndex] = useState(0)
|
||||
|
||||
// 1. Seleccionar el grupo de mensajes según la cantidad
|
||||
const messages = useMemo(() => {
|
||||
if (cantidadDeSugerencias <= 5) return MENSAJES_CORTOS
|
||||
if (cantidadDeSugerencias <= 10) return MENSAJES_MEDIOS
|
||||
return MENSAJES_LARGOS
|
||||
}, [cantidadDeSugerencias])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setProgress(0)
|
||||
setCurrentMessageIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
// --- CÁLCULO DEL TIEMPO TOTAL ---
|
||||
// y = 4.07x + 10.93 (en segundos)
|
||||
const estimatedSeconds = 4.07 * cantidadDeSugerencias + 10.93
|
||||
const durationMs = estimatedSeconds * 1000
|
||||
|
||||
// Intervalo de actualización de la barra (cada 50ms para suavidad)
|
||||
const updateInterval = 50
|
||||
const totalSteps = durationMs / updateInterval
|
||||
const incrementPerStep = 99 / totalSteps // Llegamos al 99% para esperar la respuesta real
|
||||
|
||||
// --- TIMER 1: BARRA DE PROGRESO ---
|
||||
const progressTimer = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
const next = prev + incrementPerStep
|
||||
return next >= 99 ? 99 : next // Topar en 99%
|
||||
})
|
||||
}, updateInterval)
|
||||
|
||||
// --- TIMER 2: MENSAJES (CADA 5 SEGUNDOS) ---
|
||||
const messagesTimer = setInterval(() => {
|
||||
setCurrentMessageIndex((prev) => {
|
||||
// Si ya es el último mensaje, no avanzar más (no ciclar)
|
||||
if (prev >= messages.length - 1) return prev
|
||||
return prev + 1
|
||||
})
|
||||
}, 5000)
|
||||
|
||||
// Cleanup al desmontar o cuando isLoading cambie
|
||||
return () => {
|
||||
clearInterval(progressTimer)
|
||||
clearInterval(messagesTimer)
|
||||
}
|
||||
}, [isLoading, cantidadDeSugerencias, messages])
|
||||
|
||||
if (!isLoading) return null
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in zoom-in m-2 mx-auto w-full max-w-md duration-300">
|
||||
{/* Contenedor de la barra */}
|
||||
<div className="relative pt-1">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="inline-block rounded-full bg-blue-200 px-2 py-1 text-xs font-semibold text-blue-600 uppercase">
|
||||
Generando IA
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="inline-block text-xs font-semibold text-blue-600">
|
||||
{Math.floor(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barra de fondo */}
|
||||
<div className="mb-4 flex h-2 overflow-hidden rounded bg-blue-100 text-xs">
|
||||
{/* Barra de progreso dinámica */}
|
||||
<div
|
||||
style={{ width: `${progress}%` }}
|
||||
className="flex flex-col justify-center bg-blue-500 text-center whitespace-nowrap text-white shadow-none transition-all duration-75 ease-linear"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Mensajes cambiantes */}
|
||||
<div className="h-6 text-center">
|
||||
{' '}
|
||||
{/* Altura fija para evitar saltos */}
|
||||
<p className="text-sm text-slate-500 italic transition-opacity duration-500">
|
||||
{messages[currentMessageIndex]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Nota de tiempo estimado (Opcional, transparencia operacional) */}
|
||||
<p className="mt-2 text-center text-[10px] text-slate-400">
|
||||
Tiempo estimado: ~{Math.ceil(4.07 * cantidadDeSugerencias + 10.93)}{' '}
|
||||
segs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,26 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import * as Icons from 'lucide-react'
|
||||
|
||||
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
|
||||
|
||||
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm'
|
||||
import { PasoConfiguracionPanel } from '@/components/asignaturas/wizard/PasoConfiguracionPanel'
|
||||
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
|
||||
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
|
||||
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
|
||||
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
|
||||
import { VistaSinPermisos } from '@/components/asignaturas/wizard/VistaSinPermisos'
|
||||
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
|
||||
import { WizardHeader } from '@/components/asignaturas/wizard/WizardHeader'
|
||||
import { defineStepper } from '@/components/stepper'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { WizardLayout } from '@/components/wizard/WizardLayout'
|
||||
import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader'
|
||||
|
||||
const auth_get_current_user_role = (): string => 'JEFE_CARRERA'
|
||||
|
||||
const Wizard = defineStepper(
|
||||
{
|
||||
@@ -24,8 +34,8 @@ const Wizard = defineStepper(
|
||||
description: 'Nombre y estructura',
|
||||
},
|
||||
{
|
||||
id: 'configuracion',
|
||||
title: 'Configuración',
|
||||
id: 'detalles',
|
||||
title: 'Detalles',
|
||||
description: 'Detalles según modo',
|
||||
},
|
||||
{
|
||||
@@ -35,8 +45,6 @@ const Wizard = defineStepper(
|
||||
},
|
||||
)
|
||||
|
||||
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
|
||||
|
||||
export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
||||
const navigate = useNavigate()
|
||||
const role = auth_get_current_user_role()
|
||||
@@ -46,82 +54,112 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
|
||||
setWizard,
|
||||
canContinueDesdeMetodo,
|
||||
canContinueDesdeBasicos,
|
||||
canContinueDesdeConfig,
|
||||
simularGeneracionIA,
|
||||
crearAsignatura,
|
||||
canContinueDesdeDetalles,
|
||||
} = useNuevaAsignaturaWizard(planId)
|
||||
|
||||
const titleOverrides =
|
||||
wizard.tipoOrigen === 'IA_MULTIPLE'
|
||||
? {
|
||||
basicos: 'Sugerencias',
|
||||
detalles: 'Estructura',
|
||||
}
|
||||
: undefined
|
||||
|
||||
const handleClose = () => {
|
||||
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
|
||||
}
|
||||
|
||||
if (role !== 'JEFE_CARRERA') {
|
||||
return (
|
||||
<WizardLayout title="Nueva Asignatura" onClose={handleClose}>
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive flex items-center gap-2">
|
||||
<Icons.ShieldAlert className="h-5 w-5" />
|
||||
Sin permisos
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Solo el Jefe de Carrera puede crear asignaturas.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-end">
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Volver
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WizardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent
|
||||
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
{role !== 'JEFE_CARRERA' ? (
|
||||
<VistaSinPermisos onClose={handleClose} />
|
||||
) : (
|
||||
<Wizard.Stepper.Provider
|
||||
initialStep={Wizard.utils.getFirst().id}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
{({ methods }) => (
|
||||
<>
|
||||
<WizardHeader
|
||||
title="Nueva Asignatura"
|
||||
Wizard={Wizard}
|
||||
methods={{ ...methods, onClose: handleClose }}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{Wizard.utils.getIndex(methods.current.id) === 0 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoMetodoCardGroup
|
||||
wizard={wizard}
|
||||
onChange={setWizard}
|
||||
/>
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
{Wizard.utils.getIndex(methods.current.id) === 1 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
{Wizard.utils.getIndex(methods.current.id) === 2 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoConfiguracionPanel
|
||||
wizard={wizard}
|
||||
onChange={setWizard}
|
||||
onGenerarIA={simularGeneracionIA}
|
||||
/>
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
{Wizard.utils.getIndex(methods.current.id) === 3 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoResumenCard wizard={wizard} />
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Wizard.Stepper.Provider
|
||||
initialStep={Wizard.utils.getFirst().id}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
{({ methods }) => {
|
||||
const idx = Wizard.utils.getIndex(methods.current.id)
|
||||
|
||||
return (
|
||||
<WizardLayout
|
||||
title="Nueva Asignatura"
|
||||
onClose={handleClose}
|
||||
headerSlot={
|
||||
<WizardResponsiveHeader
|
||||
wizard={Wizard}
|
||||
methods={methods}
|
||||
titleOverrides={titleOverrides}
|
||||
/>
|
||||
}
|
||||
footerSlot={
|
||||
<Wizard.Stepper.Controls>
|
||||
<WizardControls
|
||||
Wizard={Wizard}
|
||||
methods={methods}
|
||||
errorMessage={wizard.errorMessage}
|
||||
onPrev={() => methods.prev()}
|
||||
onNext={() => methods.next()}
|
||||
disablePrev={idx === 0 || wizard.isLoading}
|
||||
disableNext={
|
||||
wizard.isLoading ||
|
||||
(idx === 0 && !canContinueDesdeMetodo) ||
|
||||
(idx === 1 && !canContinueDesdeBasicos) ||
|
||||
(idx === 2 && !canContinueDesdeDetalles)
|
||||
}
|
||||
disableCreate={wizard.isLoading}
|
||||
isLastStep={idx >= Wizard.steps.length - 1}
|
||||
wizard={wizard}
|
||||
canContinueDesdeMetodo={canContinueDesdeMetodo}
|
||||
canContinueDesdeBasicos={canContinueDesdeBasicos}
|
||||
canContinueDesdeConfig={canContinueDesdeConfig}
|
||||
onCreate={() => crearAsignatura(handleClose)}
|
||||
setWizard={setWizard}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Wizard.Stepper.Provider>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Wizard.Stepper.Controls>
|
||||
}
|
||||
>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{idx === 0 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoMetodoCardGroup wizard={wizard} onChange={setWizard} />
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
|
||||
{idx === 1 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
|
||||
{idx === 2 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoDetallesPanel wizard={wizard} onChange={setWizard} />
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
|
||||
{idx === 3 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoResumenCard wizard={wizard} />
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
</div>
|
||||
</WizardLayout>
|
||||
)
|
||||
}}
|
||||
</Wizard.Stepper.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,90 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { AsignaturaPreview, NewSubjectWizardState } from "../types";
|
||||
import type { NewSubjectWizardState } from '../types'
|
||||
|
||||
export function useNuevaAsignaturaWizard(planId: string) {
|
||||
const [wizard, setWizard] = useState<NewSubjectWizardState>({
|
||||
step: 1,
|
||||
planId,
|
||||
modoCreacion: null,
|
||||
plan_estudio_id: planId,
|
||||
estructuraId: null,
|
||||
tipoOrigen: null,
|
||||
datosBasicos: {
|
||||
nombre: "",
|
||||
clave: "",
|
||||
tipo: "OBLIGATORIA",
|
||||
creditos: 0,
|
||||
horasSemana: 0,
|
||||
estructuraId: "",
|
||||
nombre: '',
|
||||
codigo: '',
|
||||
tipo: null,
|
||||
creditos: null,
|
||||
horasAcademicas: null,
|
||||
horasIndependientes: null,
|
||||
estructuraId: '',
|
||||
},
|
||||
sugerencias: [],
|
||||
clonInterno: {},
|
||||
clonTradicional: {
|
||||
archivoWordAsignaturaId: null,
|
||||
archivosAdicionalesIds: [],
|
||||
},
|
||||
iaConfig: {
|
||||
descripcionEnfoque: "",
|
||||
notasAdicionales: "",
|
||||
archivosExistentesIds: [],
|
||||
descripcionEnfoqueAcademico: '',
|
||||
instruccionesAdicionalesIA: '',
|
||||
archivosReferencia: [],
|
||||
repositoriosReferencia: [],
|
||||
archivosAdjuntos: [],
|
||||
},
|
||||
iaMultiple: {
|
||||
enfoque: '',
|
||||
cantidadDeSugerencias: 10,
|
||||
isLoading: false,
|
||||
},
|
||||
resumen: {},
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
})
|
||||
|
||||
const canContinueDesdeMetodo = wizard.modoCreacion === "MANUAL" ||
|
||||
wizard.modoCreacion === "IA" ||
|
||||
(wizard.modoCreacion === "CLONADO" && !!wizard.subModoClonado);
|
||||
const canContinueDesdeMetodo =
|
||||
wizard.tipoOrigen === 'MANUAL' ||
|
||||
wizard.tipoOrigen === 'IA_SIMPLE' ||
|
||||
wizard.tipoOrigen === 'IA_MULTIPLE' ||
|
||||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
||||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
|
||||
|
||||
const canContinueDesdeBasicos = !!wizard.datosBasicos.nombre &&
|
||||
wizard.datosBasicos.creditos > 0 &&
|
||||
!!wizard.datosBasicos.estructuraId;
|
||||
const canContinueDesdeBasicos =
|
||||
(!!wizard.datosBasicos.nombre &&
|
||||
wizard.datosBasicos.tipo !== null &&
|
||||
wizard.datosBasicos.creditos !== null &&
|
||||
wizard.datosBasicos.creditos > 0 &&
|
||||
!!wizard.datosBasicos.estructuraId) ||
|
||||
(wizard.tipoOrigen === 'IA_MULTIPLE' &&
|
||||
wizard.sugerencias.filter((s) => s.selected).length > 0)
|
||||
|
||||
const canContinueDesdeConfig = (() => {
|
||||
if (wizard.modoCreacion === "MANUAL") return true;
|
||||
if (wizard.modoCreacion === "IA") {
|
||||
return !!wizard.iaConfig?.descripcionEnfoque;
|
||||
const canContinueDesdeDetalles = (() => {
|
||||
if (wizard.tipoOrigen === 'MANUAL') return true
|
||||
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
||||
return !!wizard.iaConfig?.descripcionEnfoqueAcademico
|
||||
}
|
||||
if (wizard.modoCreacion === "CLONADO") {
|
||||
if (wizard.subModoClonado === "INTERNO") {
|
||||
return !!wizard.clonInterno?.asignaturaOrigenId;
|
||||
}
|
||||
if (wizard.subModoClonado === "TRADICIONAL") {
|
||||
return !!wizard.clonTradicional?.archivoWordAsignaturaId;
|
||||
}
|
||||
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
||||
return !!wizard.clonInterno?.asignaturaOrigenId
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
const simularGeneracionIA = async () => {
|
||||
setWizard((w) => ({ ...w, isLoading: true }));
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
resumen: {
|
||||
previewAsignatura: {
|
||||
nombre: w.datosBasicos.nombre,
|
||||
objetivo:
|
||||
"Aplicar los fundamentos teóricos para la resolución de problemas...",
|
||||
unidades: 5,
|
||||
bibliografiaCount: 3,
|
||||
} as AsignaturaPreview,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const crearAsignatura = async (onCreated: () => void) => {
|
||||
setWizard((w) => ({ ...w, isLoading: true }));
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
onCreated();
|
||||
};
|
||||
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
|
||||
return !!wizard.clonTradicional?.archivoWordAsignaturaId
|
||||
}
|
||||
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
|
||||
return wizard.estructuraId !== null
|
||||
}
|
||||
return false
|
||||
})()
|
||||
|
||||
return {
|
||||
wizard,
|
||||
setWizard,
|
||||
canContinueDesdeMetodo,
|
||||
canContinueDesdeBasicos,
|
||||
canContinueDesdeConfig,
|
||||
simularGeneracionIA,
|
||||
crearAsignatura,
|
||||
};
|
||||
canContinueDesdeDetalles,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,79 @@
|
||||
export type ModoCreacion = "MANUAL" | "IA" | "CLONADO";
|
||||
export type SubModoClonado = "INTERNO" | "TRADICIONAL";
|
||||
export type TipoAsignatura = "OBLIGATORIA" | "OPTATIVA" | "TRONCAL" | "OTRO";
|
||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
||||
import type { Asignatura } from '@/data'
|
||||
|
||||
export type ModoCreacion = 'MANUAL' | 'IA' | 'CLONADO'
|
||||
export type TipoAsignatura = 'OBLIGATORIA' | 'OPTATIVA' | 'TRONCAL' | 'OTRO'
|
||||
|
||||
export type AsignaturaPreview = {
|
||||
nombre: string;
|
||||
objetivo: string;
|
||||
unidades: number;
|
||||
bibliografiaCount: number;
|
||||
};
|
||||
nombre: string
|
||||
objetivo: string
|
||||
unidades: number
|
||||
bibliografiaCount: number
|
||||
}
|
||||
|
||||
export type DataAsignaturaSugerida = {
|
||||
nombre: Asignatura['nombre']
|
||||
codigo?: Asignatura['codigo']
|
||||
tipo: Asignatura['tipo'] | null
|
||||
creditos: Asignatura['creditos'] | null
|
||||
horasAcademicas?: number | null
|
||||
horasIndependientes?: number | null
|
||||
descripcion: string
|
||||
}
|
||||
|
||||
export type AsignaturaSugerida = {
|
||||
id: string
|
||||
selected: boolean
|
||||
source: 'IA' | 'MANUAL' | 'CLON'
|
||||
linea_plan_id: string | null
|
||||
numero_ciclo: number | null
|
||||
} & DataAsignaturaSugerida
|
||||
|
||||
export type NewSubjectWizardState = {
|
||||
step: 1 | 2 | 3 | 4;
|
||||
planId: string;
|
||||
modoCreacion: ModoCreacion | null;
|
||||
subModoClonado?: SubModoClonado;
|
||||
step: 1 | 2 | 3 | 4
|
||||
plan_estudio_id: Asignatura['plan_estudio_id']
|
||||
estructuraId: Asignatura['estructura_id'] | null
|
||||
tipoOrigen:
|
||||
| Asignatura['tipo_origen']
|
||||
| 'CLONADO'
|
||||
| 'IA_SIMPLE'
|
||||
| 'IA_MULTIPLE'
|
||||
| null
|
||||
datosBasicos: {
|
||||
nombre: string;
|
||||
clave?: string;
|
||||
tipo: TipoAsignatura;
|
||||
creditos: number;
|
||||
horasSemana?: number;
|
||||
estructuraId: string;
|
||||
};
|
||||
nombre: Asignatura['nombre']
|
||||
codigo?: Asignatura['codigo']
|
||||
tipo: Asignatura['tipo'] | null
|
||||
creditos: Asignatura['creditos'] | null
|
||||
horasAcademicas?: Asignatura['horas_academicas'] | null
|
||||
horasIndependientes?: Asignatura['horas_independientes'] | null
|
||||
estructuraId: Asignatura['estructura_id'] | null
|
||||
}
|
||||
sugerencias: Array<AsignaturaSugerida>
|
||||
clonInterno?: {
|
||||
facultadId?: string;
|
||||
carreraId?: string;
|
||||
planOrigenId?: string;
|
||||
asignaturaOrigenId?: string | null;
|
||||
};
|
||||
facultadId?: string
|
||||
carreraId?: string
|
||||
planOrigenId?: string
|
||||
asignaturaOrigenId?: string | null
|
||||
}
|
||||
clonTradicional?: {
|
||||
archivoWordAsignaturaId: string | null;
|
||||
archivosAdicionalesIds: Array<string>;
|
||||
};
|
||||
archivoWordAsignaturaId: string | null
|
||||
archivosAdicionalesIds: Array<string>
|
||||
}
|
||||
iaConfig?: {
|
||||
descripcionEnfoque: string;
|
||||
notasAdicionales: string;
|
||||
archivosExistentesIds: Array<string>;
|
||||
};
|
||||
descripcionEnfoqueAcademico: string
|
||||
instruccionesAdicionalesIA: string
|
||||
archivosReferencia: Array<string>
|
||||
repositoriosReferencia?: Array<string>
|
||||
archivosAdjuntos?: Array<UploadedFile>
|
||||
}
|
||||
iaMultiple?: {
|
||||
enfoque: string
|
||||
cantidadDeSugerencias: number
|
||||
isLoading: boolean
|
||||
}
|
||||
resumen: {
|
||||
previewAsignatura?: AsignaturaPreview;
|
||||
};
|
||||
isLoading: boolean;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
previewAsignatura?: AsignaturaPreview
|
||||
}
|
||||
isLoading: boolean
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
2745
src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx
Normal file
2745
src/features/bibliografia/nueva/NuevaBibliografiaModalContainer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,12 +3,13 @@ import * as Icons from 'lucide-react'
|
||||
|
||||
import { useNuevoPlanWizard } from './hooks/useNuevoPlanWizard'
|
||||
|
||||
// import type { NewPlanWizardState } from './types'
|
||||
|
||||
import { PasoBasicosForm } from '@/components/planes/wizard/PasoBasicosForm/PasoBasicosForm'
|
||||
import { PasoDetallesPanel } from '@/components/planes/wizard/PasoDetallesPanel/PasoDetallesPanel'
|
||||
import { PasoModoCardGroup } from '@/components/planes/wizard/PasoModoCardGroup'
|
||||
import { PasoResumenCard } from '@/components/planes/wizard/PasoResumenCard'
|
||||
import { WizardControls } from '@/components/planes/wizard/WizardControls'
|
||||
import { WizardHeader } from '@/components/planes/wizard/WizardHeader'
|
||||
import { defineStepper } from '@/components/stepper'
|
||||
import {
|
||||
Card,
|
||||
@@ -17,15 +18,12 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { WizardLayout } from '@/components/wizard/WizardLayout'
|
||||
import { WizardResponsiveHeader } from '@/components/wizard/WizardResponsiveHeader'
|
||||
// import { useGeneratePlanAI } from '@/data/hooks/usePlans'
|
||||
|
||||
// Mock de permisos/rol
|
||||
const auth_get_current_user_role = () => 'JEFE_CARRERA' as const
|
||||
const auth_get_current_user_role = (): string => 'JEFE_CARRERA'
|
||||
|
||||
const Wizard = defineStepper(
|
||||
{
|
||||
@@ -45,161 +43,113 @@ const Wizard = defineStepper(
|
||||
export default function NuevoPlanModalContainer() {
|
||||
const navigate = useNavigate()
|
||||
const role = auth_get_current_user_role()
|
||||
// const generatePlanAI = useGeneratePlanAI()
|
||||
|
||||
const {
|
||||
wizard,
|
||||
setWizard,
|
||||
carrerasFiltradas,
|
||||
canContinueDesdeModo,
|
||||
canContinueDesdeBasicos,
|
||||
canContinueDesdeDetalles,
|
||||
generarPreviewIA,
|
||||
} = useNuevoPlanWizard()
|
||||
|
||||
const handleClose = () => {
|
||||
navigate({ to: '/planes', resetScroll: false })
|
||||
}
|
||||
|
||||
const crearPlan = async () => {
|
||||
setWizard((w) => ({ ...w, isLoading: true, errorMessage: null }))
|
||||
await new Promise((r) => setTimeout(r, 900))
|
||||
const nuevoId = (() => {
|
||||
if (wizard.modoCreacion === 'MANUAL') return 'plan_new_manual_001'
|
||||
if (wizard.modoCreacion === 'IA') return 'plan_new_ai_001'
|
||||
if (wizard.subModoClonado === 'INTERNO') return 'plan_new_clone_001'
|
||||
return 'plan_new_import_001'
|
||||
})()
|
||||
navigate({ to: `/planes/${nuevoId}` })
|
||||
// Crear plan: ahora la lógica vive en WizardControls
|
||||
|
||||
if (role !== 'JEFE_CARRERA') {
|
||||
return (
|
||||
<WizardLayout title="Nuevo plan de estudios" onClose={handleClose}>
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icons.ShieldAlert className="text-destructive h-5 w-5" />
|
||||
Sin permisos
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
No tienes permisos para crear planes de estudio.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-end">
|
||||
<button
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-md border px-3 py-2 text-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WizardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent
|
||||
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
{role !== 'JEFE_CARRERA' ? (
|
||||
<>
|
||||
<DialogHeader className="flex-none border-b p-6">
|
||||
<DialogTitle>Nuevo plan de estudios</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 p-6">
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icons.ShieldAlert className="text-destructive h-5 w-5" />
|
||||
Sin permisos
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
No tienes permisos para crear planes de estudio.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-end">
|
||||
<button
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-md border px-3 py-2 text-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Wizard.Stepper.Provider
|
||||
initialStep={Wizard.utils.getFirst().id}
|
||||
className="flex h-full flex-col"
|
||||
<Wizard.Stepper.Provider
|
||||
initialStep={Wizard.utils.getFirst().id}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
{({ methods }) => {
|
||||
const idx = Wizard.utils.getIndex(methods.current.id)
|
||||
|
||||
return (
|
||||
<WizardLayout
|
||||
title="Nuevo plan de estudios"
|
||||
onClose={handleClose}
|
||||
headerSlot={
|
||||
<WizardResponsiveHeader wizard={Wizard} methods={methods} />
|
||||
}
|
||||
footerSlot={
|
||||
<Wizard.Stepper.Controls>
|
||||
<WizardControls
|
||||
errorMessage={wizard.errorMessage}
|
||||
onPrev={() => methods.prev()}
|
||||
onNext={() => methods.next()}
|
||||
disablePrev={idx === 0 || wizard.isLoading}
|
||||
disableNext={
|
||||
wizard.isLoading ||
|
||||
(idx === 0 && !canContinueDesdeModo) ||
|
||||
(idx === 1 && !canContinueDesdeBasicos) ||
|
||||
(idx === 2 && !canContinueDesdeDetalles)
|
||||
}
|
||||
disableCreate={wizard.isLoading}
|
||||
isLastStep={idx >= Wizard.steps.length - 1}
|
||||
wizard={wizard}
|
||||
setWizard={setWizard}
|
||||
/>
|
||||
</Wizard.Stepper.Controls>
|
||||
}
|
||||
>
|
||||
{({ methods }) => {
|
||||
const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1
|
||||
const totalSteps = Wizard.steps.length
|
||||
const nextStep = Wizard.steps[currentIndex]
|
||||
|
||||
return (
|
||||
<>
|
||||
<WizardHeader
|
||||
currentIndex={currentIndex}
|
||||
totalSteps={totalSteps}
|
||||
currentTitle={methods.current.title}
|
||||
currentDescription={methods.current.description}
|
||||
nextTitle={nextStep?.title}
|
||||
onClose={handleClose}
|
||||
Wizard={Wizard}
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{idx === 0 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoModoCardGroup wizard={wizard} onChange={setWizard} />
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
{idx === 1 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
{idx === 2 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoDetallesPanel
|
||||
wizard={wizard}
|
||||
onChange={setWizard}
|
||||
isLoading={wizard.isLoading}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{Wizard.utils.getIndex(methods.current.id) === 0 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoModoCardGroup
|
||||
wizard={wizard}
|
||||
onChange={setWizard}
|
||||
/>
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
{Wizard.utils.getIndex(methods.current.id) === 1 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoBasicosForm
|
||||
wizard={wizard}
|
||||
onChange={setWizard}
|
||||
carrerasFiltradas={carrerasFiltradas}
|
||||
/>
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
{Wizard.utils.getIndex(methods.current.id) === 2 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoDetallesPanel
|
||||
wizard={wizard}
|
||||
onChange={setWizard}
|
||||
onGenerarIA={generarPreviewIA}
|
||||
isLoading={wizard.isLoading}
|
||||
/>
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
{Wizard.utils.getIndex(methods.current.id) === 3 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoResumenCard wizard={wizard} />
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-none border-t bg-white p-6">
|
||||
<Wizard.Stepper.Controls>
|
||||
<WizardControls
|
||||
errorMessage={wizard.errorMessage}
|
||||
onPrev={() => methods.prev()}
|
||||
onNext={() => methods.next()}
|
||||
onCreate={crearPlan}
|
||||
disablePrev={
|
||||
Wizard.utils.getIndex(methods.current.id) === 0 ||
|
||||
wizard.isLoading
|
||||
}
|
||||
disableNext={
|
||||
wizard.isLoading ||
|
||||
(Wizard.utils.getIndex(methods.current.id) === 0 &&
|
||||
!canContinueDesdeModo) ||
|
||||
(Wizard.utils.getIndex(methods.current.id) === 1 &&
|
||||
!canContinueDesdeBasicos) ||
|
||||
(Wizard.utils.getIndex(methods.current.id) === 2 &&
|
||||
!canContinueDesdeDetalles)
|
||||
}
|
||||
disableCreate={wizard.isLoading}
|
||||
isLastStep={
|
||||
Wizard.utils.getIndex(methods.current.id) >=
|
||||
Wizard.steps.length - 1
|
||||
}
|
||||
/>
|
||||
</Wizard.Stepper.Controls>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Wizard.Stepper.Provider>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
{idx === 3 && (
|
||||
<Wizard.Stepper.Panel>
|
||||
<PasoResumenCard wizard={wizard} />
|
||||
</Wizard.Stepper.Panel>
|
||||
)}
|
||||
</div>
|
||||
</WizardLayout>
|
||||
)
|
||||
}}
|
||||
</Wizard.Stepper.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,152 +1,156 @@
|
||||
import type { TipoCiclo } from "./types";
|
||||
import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain'
|
||||
|
||||
export const FACULTADES = [
|
||||
{ id: "ing", nombre: "Facultad de Ingeniería" },
|
||||
{ id: 'ing', nombre: 'Facultad de Ingeniería' },
|
||||
{
|
||||
id: "med",
|
||||
nombre: "Facultad de Medicina en medicina en medicina en medicina",
|
||||
id: 'med',
|
||||
nombre: 'Facultad de Medicina en medicina en medicina en medicina',
|
||||
},
|
||||
{ id: "neg", nombre: "Facultad de Negocios" },
|
||||
];
|
||||
{ id: 'neg', nombre: 'Facultad de Negocios' },
|
||||
]
|
||||
|
||||
export const CARRERAS = [
|
||||
{ id: "sis", nombre: "Ing. en Sistemas", facultadId: "ing" },
|
||||
{ id: "ind", nombre: "Ing. Industrial", facultadId: "ing" },
|
||||
{ id: "medico", nombre: "Médico Cirujano", facultadId: "med" },
|
||||
{ id: "act", nombre: "Actuaría", facultadId: "neg" },
|
||||
];
|
||||
{ id: 'sis', nombre: 'Ing. en Sistemas', facultadId: 'ing' },
|
||||
{ id: 'ind', nombre: 'Ing. Industrial', facultadId: 'ing' },
|
||||
{ id: 'medico', nombre: 'Médico Cirujano', facultadId: 'med' },
|
||||
{ id: 'act', nombre: 'Actuaría', facultadId: 'neg' },
|
||||
]
|
||||
|
||||
export const NIVELES = [
|
||||
"Licenciatura",
|
||||
"Especialidad",
|
||||
"Maestría",
|
||||
"Doctorado",
|
||||
];
|
||||
export const TIPOS_CICLO: Array<{ value: TipoCiclo; label: string }> = [
|
||||
{ value: "SEMESTRE", label: "Semestre" },
|
||||
{ value: "CUATRIMESTRE", label: "Cuatrimestre" },
|
||||
{ value: "TRIMESTRE", label: "Trimestre" },
|
||||
];
|
||||
export const NIVELES: Array<NivelPlanEstudio> = [
|
||||
'Licenciatura',
|
||||
'Maestría',
|
||||
'Doctorado',
|
||||
'Especialidad',
|
||||
'Diplomado',
|
||||
'Otro',
|
||||
]
|
||||
|
||||
export const TIPOS_CICLO: Array<TipoCiclo> = [
|
||||
'Semestre',
|
||||
'Cuatrimestre',
|
||||
'Trimestre',
|
||||
'Otro',
|
||||
]
|
||||
|
||||
export const PLANES_EXISTENTES = [
|
||||
{
|
||||
id: "plan-2021-sis",
|
||||
nombre: "ISC 2021",
|
||||
estado: "Aprobado",
|
||||
id: 'plan-2021-sis',
|
||||
nombre: 'ISC 2021',
|
||||
estado: 'Aprobado',
|
||||
anio: 2021,
|
||||
facultadId: "ing",
|
||||
carreraId: "sis",
|
||||
facultadId: 'ing',
|
||||
carreraId: 'sis',
|
||||
},
|
||||
{
|
||||
id: "plan-2020-ind",
|
||||
nombre: "I. Industrial 2020",
|
||||
estado: "Aprobado",
|
||||
id: 'plan-2020-ind',
|
||||
nombre: 'I. Industrial 2020',
|
||||
estado: 'Aprobado',
|
||||
anio: 2020,
|
||||
facultadId: "ing",
|
||||
carreraId: "ind",
|
||||
facultadId: 'ing',
|
||||
carreraId: 'ind',
|
||||
},
|
||||
{
|
||||
id: "plan-2019-med",
|
||||
nombre: "Medicina 2019",
|
||||
estado: "Vigente",
|
||||
id: 'plan-2019-med',
|
||||
nombre: 'Medicina 2019',
|
||||
estado: 'Vigente',
|
||||
anio: 2019,
|
||||
facultadId: "med",
|
||||
carreraId: "medico",
|
||||
facultadId: 'med',
|
||||
carreraId: 'medico',
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
export const ARCHIVOS = [
|
||||
{
|
||||
id: "file-1",
|
||||
nombre: "Sílabo POO 2023.docx",
|
||||
tipo: "docx",
|
||||
tamaño: "245 KB",
|
||||
id: 'file-1',
|
||||
nombre: 'Sílabo POO 2023.docx',
|
||||
tipo: 'docx',
|
||||
tamaño: '245 KB',
|
||||
},
|
||||
{
|
||||
id: "file-2",
|
||||
nombre: "Guía de prácticas BD.pdf",
|
||||
tipo: "pdf",
|
||||
tamaño: "1.2 MB",
|
||||
id: 'file-2',
|
||||
nombre: 'Guía de prácticas BD.pdf',
|
||||
tipo: 'pdf',
|
||||
tamaño: '1.2 MB',
|
||||
},
|
||||
{
|
||||
id: "file-3",
|
||||
nombre: "Rúbrica evaluación proyectos.xlsx",
|
||||
tipo: "xlsx",
|
||||
tamaño: "89 KB",
|
||||
id: 'file-3',
|
||||
nombre: 'Rúbrica evaluación proyectos.xlsx',
|
||||
tipo: 'xlsx',
|
||||
tamaño: '89 KB',
|
||||
},
|
||||
{
|
||||
id: "file-4",
|
||||
nombre: "Banco de reactivos IA.docx",
|
||||
tipo: "docx",
|
||||
tamaño: "567 KB",
|
||||
id: 'file-4',
|
||||
nombre: 'Banco de reactivos IA.docx',
|
||||
tipo: 'docx',
|
||||
tamaño: '567 KB',
|
||||
},
|
||||
{
|
||||
id: "file-5",
|
||||
nombre: "Material didáctico Web.pdf",
|
||||
tipo: "pdf",
|
||||
tamaño: "3.4 MB",
|
||||
id: 'file-5',
|
||||
nombre: 'Asignatural didáctico Web.pdf',
|
||||
tipo: 'pdf',
|
||||
tamaño: '3.4 MB',
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
export const REPOSITORIOS = [
|
||||
{
|
||||
id: "repo-1",
|
||||
nombre: "Materiales ISC 2024",
|
||||
descripcion: "Documentos de referencia para Ingeniería en Sistemas",
|
||||
id: 'repo-1',
|
||||
nombre: 'Asignaturales ISC 2024',
|
||||
descripcion: 'Documentos de referencia para Ingeniería en Sistemas',
|
||||
cantidadArchivos: 45,
|
||||
},
|
||||
{
|
||||
id: "repo-2",
|
||||
nombre: "Lineamientos SEP",
|
||||
descripcion: "Documentos oficiales y normativas SEP actualizadas",
|
||||
id: 'repo-2',
|
||||
nombre: 'Lineamientos SEP',
|
||||
descripcion: 'Documentos oficiales y normativas SEP actualizadas',
|
||||
cantidadArchivos: 12,
|
||||
},
|
||||
{
|
||||
id: "repo-3",
|
||||
nombre: "Bibliografía Digital",
|
||||
descripcion: "Recursos bibliográficos digitalizados",
|
||||
id: 'repo-3',
|
||||
nombre: 'Bibliografía Digital',
|
||||
descripcion: 'Recursos bibliográficos digitalizados',
|
||||
cantidadArchivos: 128,
|
||||
},
|
||||
{
|
||||
id: "repo-4",
|
||||
nombre: "Plantillas Institucionales",
|
||||
descripcion: "Formatos y plantillas oficiales ULSA",
|
||||
id: 'repo-4',
|
||||
nombre: 'Plantillas Institucionales',
|
||||
descripcion: 'Formatos y plantillas oficiales ULSA',
|
||||
cantidadArchivos: 23,
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
export const PLANTILLAS_ANEXO_1 = [
|
||||
{
|
||||
id: "sep-2025",
|
||||
name: "Licenciatura RVOE SEP.docx",
|
||||
versions: ["v2025.2 (Vigente)", "v2025.1", "v2024.Final"],
|
||||
id: 'sep-2025',
|
||||
name: 'Licenciatura RVOE SEP.docx',
|
||||
versions: ['v2025.2 (Vigente)', 'v2025.1', 'v2024.Final'],
|
||||
},
|
||||
{
|
||||
id: "interno-mix",
|
||||
name: "Estándar Institucional Mixto.docx",
|
||||
versions: ["v2.0", "v1.5", "v1.0-beta"],
|
||||
id: 'interno-mix',
|
||||
name: 'Estándar Institucional Mixto.docx',
|
||||
versions: ['v2.0', 'v1.5', 'v1.0-beta'],
|
||||
},
|
||||
{
|
||||
id: "conacyt",
|
||||
name: "Formato Posgrado CONAHCYT.docx",
|
||||
versions: ["v3.0 (2025)", "v2.8"],
|
||||
id: 'conacyt',
|
||||
name: 'Formato Posgrado CONAHCYT.docx',
|
||||
versions: ['v3.0 (2025)', 'v2.8'],
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
export const PLANTILLAS_ANEXO_2 = [
|
||||
{
|
||||
id: "sep-2017-xlsx",
|
||||
name: "Licenciatura RVOE 2017.xlsx",
|
||||
versions: ["v2017.0", "v2018.1", "v2019.2", "v2020.Final"],
|
||||
id: 'sep-2017-xlsx',
|
||||
name: 'Licenciatura RVOE 2017.xlsx',
|
||||
versions: ['v2017.0', 'v2018.1', 'v2019.2', 'v2020.Final'],
|
||||
},
|
||||
{
|
||||
id: "interno-mix-xlsx",
|
||||
name: "Estándar Institucional Mixto.xlsx",
|
||||
versions: ["v1.0", "v1.5"],
|
||||
id: 'interno-mix-xlsx',
|
||||
name: 'Estándar Institucional Mixto.xlsx',
|
||||
versions: ['v1.0', 'v1.5'],
|
||||
},
|
||||
{
|
||||
id: "conacyt-xlsx",
|
||||
name: "Formato Posgrado CONAHCYT.xlsx",
|
||||
versions: ["v1.0", "v2.0"],
|
||||
id: 'conacyt-xlsx',
|
||||
name: 'Formato Posgrado CONAHCYT.xlsx',
|
||||
versions: ['v1.0', 'v2.0'],
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
@@ -1,37 +1,32 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from 'react'
|
||||
|
||||
import { CARRERAS } from "../catalogs";
|
||||
|
||||
import type { NewPlanWizardState, PlanPreview, TipoCiclo } from "../types";
|
||||
import type { NewPlanWizardState } from '../types'
|
||||
|
||||
export function useNuevoPlanWizard() {
|
||||
const [wizard, setWizard] = useState<NewPlanWizardState>({
|
||||
step: 1,
|
||||
modoCreacion: null,
|
||||
// datosBasicos: {
|
||||
// nombrePlan: "",
|
||||
// carreraId: "",
|
||||
// facultadId: "",
|
||||
// nivel: "",
|
||||
// tipoCiclo: "",
|
||||
// numCiclos: undefined,
|
||||
// plantillaPlanId: "",
|
||||
// plantillaPlanVersion: "",
|
||||
// plantillaMapaId: "",
|
||||
// plantillaMapaVersion: "",
|
||||
// },
|
||||
tipoOrigen: null,
|
||||
datosBasicos: {
|
||||
nombrePlan: "Medicina",
|
||||
carreraId: "medico",
|
||||
facultadId: "med",
|
||||
nivel: "Licenciatura",
|
||||
tipoCiclo: "SEMESTRE",
|
||||
numCiclos: 8,
|
||||
plantillaPlanId: "sep-2025",
|
||||
plantillaPlanVersion: "v2025.2 (Vigente)",
|
||||
plantillaMapaId: "sep-2017-xlsx",
|
||||
plantillaMapaVersion: "v2017.0",
|
||||
nombrePlan: '',
|
||||
facultad: { id: '', nombre: '' },
|
||||
carrera: { id: '', nombre: '' },
|
||||
nivel: '',
|
||||
tipoCiclo: '',
|
||||
numCiclos: null,
|
||||
estructuraPlanId: null,
|
||||
},
|
||||
// datosBasicos: {
|
||||
// nombrePlan: "Medicina",
|
||||
// carreraId: "medico",
|
||||
// facultadId: "med",
|
||||
// nivel: "Licenciatura",
|
||||
// tipoCiclo: "SEMESTRE",
|
||||
// numCiclos: 8,
|
||||
// plantillaPlanId: "sep-2025",
|
||||
// plantillaPlanVersion: "v2025.2 (Vigente)",
|
||||
// plantillaMapaId: "sep-2017-xlsx",
|
||||
// plantillaMapaVersion: "v2017.0",
|
||||
// },
|
||||
clonInterno: { planOrigenId: null },
|
||||
clonTradicional: {
|
||||
archivoWordPlanId: null,
|
||||
@@ -39,9 +34,8 @@ export function useNuevoPlanWizard() {
|
||||
archivoAsignaturasExcelId: null,
|
||||
},
|
||||
iaConfig: {
|
||||
descripcionEnfoque: "",
|
||||
poblacionObjetivo: "",
|
||||
notasAdicionales: "",
|
||||
descripcionEnfoqueAcademico: '',
|
||||
instruccionesAdicionalesIA: '',
|
||||
archivosReferencia: [],
|
||||
repositoriosReferencia: [],
|
||||
archivosAdjuntos: [],
|
||||
@@ -49,92 +43,49 @@ export function useNuevoPlanWizard() {
|
||||
resumen: {},
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
})
|
||||
|
||||
const carrerasFiltradas = useMemo(() => {
|
||||
const fac = wizard.datosBasicos.facultadId;
|
||||
return fac ? CARRERAS.filter((c) => c.facultadId === fac) : CARRERAS;
|
||||
}, [wizard.datosBasicos.facultadId]);
|
||||
const canContinueDesdeModo =
|
||||
wizard.tipoOrigen === 'MANUAL' ||
|
||||
wizard.tipoOrigen === 'IA' ||
|
||||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
||||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
|
||||
|
||||
const canContinueDesdeModo = wizard.modoCreacion === "MANUAL" ||
|
||||
wizard.modoCreacion === "IA" ||
|
||||
(wizard.modoCreacion === "CLONADO" && !!wizard.subModoClonado);
|
||||
|
||||
const canContinueDesdeBasicos = !!wizard.datosBasicos.nombrePlan &&
|
||||
!!wizard.datosBasicos.carreraId &&
|
||||
!!wizard.datosBasicos.facultadId &&
|
||||
const canContinueDesdeBasicos =
|
||||
!!wizard.datosBasicos.nombrePlan &&
|
||||
!!wizard.datosBasicos.carrera.id &&
|
||||
!!wizard.datosBasicos.facultad.id &&
|
||||
!!wizard.datosBasicos.nivel &&
|
||||
(wizard.datosBasicos.numCiclos !== undefined &&
|
||||
wizard.datosBasicos.numCiclos > 0) &&
|
||||
wizard.datosBasicos.numCiclos !== null &&
|
||||
wizard.datosBasicos.numCiclos > 0 &&
|
||||
// Requerir ambas plantillas (plan y mapa) con versión
|
||||
!!wizard.datosBasicos.plantillaPlanId &&
|
||||
!!wizard.datosBasicos.plantillaPlanVersion &&
|
||||
!!wizard.datosBasicos.plantillaMapaId &&
|
||||
!!wizard.datosBasicos.plantillaMapaVersion;
|
||||
!!wizard.datosBasicos.estructuraPlanId
|
||||
|
||||
const canContinueDesdeDetalles = (() => {
|
||||
if (wizard.modoCreacion === "MANUAL") return true;
|
||||
if (wizard.modoCreacion === "IA") {
|
||||
if (wizard.tipoOrigen === 'MANUAL') return true
|
||||
if (wizard.tipoOrigen === 'IA') {
|
||||
// Requerimos descripción del enfoque y notas adicionales
|
||||
return !!wizard.iaConfig?.descripcionEnfoque &&
|
||||
!!wizard.iaConfig?.notasAdicionales;
|
||||
return !!wizard.iaConfig?.descripcionEnfoqueAcademico
|
||||
}
|
||||
if (wizard.modoCreacion === "CLONADO") {
|
||||
if (wizard.subModoClonado === "INTERNO") {
|
||||
return !!wizard.clonInterno?.planOrigenId;
|
||||
}
|
||||
if (wizard.subModoClonado === "TRADICIONAL") {
|
||||
const t = wizard.clonTradicional;
|
||||
if (!t) return false;
|
||||
const tieneWord = !!t.archivoWordPlanId;
|
||||
const tieneAlMenosUnExcel = !!t.archivoMapaExcelId ||
|
||||
!!t.archivoAsignaturasExcelId;
|
||||
return tieneWord && tieneAlMenosUnExcel;
|
||||
}
|
||||
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
||||
return !!wizard.clonInterno?.planOrigenId
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
const generarPreviewIA = async () => {
|
||||
setWizard((w) => ({ ...w, isLoading: true, errorMessage: null }));
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
// Ensure preview has the stricter types required by `PlanPreview`.
|
||||
let tipoCicloSafe: TipoCiclo;
|
||||
if (wizard.datosBasicos.tipoCiclo === "") {
|
||||
tipoCicloSafe = "SEMESTRE";
|
||||
} else {
|
||||
tipoCicloSafe = wizard.datosBasicos.tipoCiclo;
|
||||
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
|
||||
const t = wizard.clonTradicional
|
||||
if (!t) return false
|
||||
const tieneWord = !!t.archivoWordPlanId
|
||||
const tieneAlMenosUnExcel =
|
||||
!!t.archivoMapaExcelId || !!t.archivoAsignaturasExcelId
|
||||
return tieneWord && tieneAlMenosUnExcel
|
||||
}
|
||||
const numCiclosSafe: number =
|
||||
typeof wizard.datosBasicos.numCiclos === "number"
|
||||
? wizard.datosBasicos.numCiclos
|
||||
: 1;
|
||||
|
||||
const preview: PlanPreview = {
|
||||
nombrePlan: wizard.datosBasicos.nombrePlan || "Plan sin nombre",
|
||||
nivel: wizard.datosBasicos.nivel || "Licenciatura",
|
||||
tipoCiclo: tipoCicloSafe,
|
||||
numCiclos: numCiclosSafe,
|
||||
numAsignaturasAprox: numCiclosSafe * 6,
|
||||
secciones: [
|
||||
{ id: "obj", titulo: "Objetivos", resumen: "Borrador de objetivos…" },
|
||||
{ id: "perfil", titulo: "Perfil de egreso", resumen: "Borrador…" },
|
||||
],
|
||||
};
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
resumen: { previewPlan: preview },
|
||||
}));
|
||||
};
|
||||
return false
|
||||
})()
|
||||
|
||||
return {
|
||||
wizard,
|
||||
setWizard,
|
||||
carrerasFiltradas,
|
||||
canContinueDesdeModo,
|
||||
canContinueDesdeBasicos,
|
||||
canContinueDesdeDetalles,
|
||||
generarPreviewIA,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
export type TipoCiclo = "SEMESTRE" | "CUATRIMESTRE" | "TRIMESTRE";
|
||||
export type ModoCreacion = "MANUAL" | "IA" | "CLONADO";
|
||||
export type SubModoClonado = "INTERNO" | "TRADICIONAL";
|
||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
||||
import type {
|
||||
NivelPlanEstudio,
|
||||
TipoCiclo,
|
||||
TipoOrigen,
|
||||
} from '@/data/types/domain'
|
||||
|
||||
export type PlanPreview = {
|
||||
nombrePlan: string;
|
||||
nivel: string;
|
||||
tipoCiclo: TipoCiclo;
|
||||
numCiclos: number;
|
||||
numAsignaturasAprox?: number;
|
||||
secciones?: Array<{ id: string; titulo: string; resumen: string }>;
|
||||
};
|
||||
nombrePlan: string
|
||||
nivel: NivelPlanEstudio
|
||||
tipoCiclo: TipoCiclo
|
||||
numCiclos: number
|
||||
numAsignaturasAprox?: number
|
||||
secciones?: Array<{ id: string; titulo: string; resumen: string }>
|
||||
}
|
||||
|
||||
export type NewPlanWizardState = {
|
||||
step: 1 | 2 | 3 | 4;
|
||||
modoCreacion: ModoCreacion | null;
|
||||
subModoClonado?: SubModoClonado;
|
||||
step: 1 | 2 | 3 | 4
|
||||
tipoOrigen: TipoOrigen | null
|
||||
datosBasicos: {
|
||||
nombrePlan: string;
|
||||
carreraId: string;
|
||||
facultadId: string;
|
||||
nivel: string;
|
||||
tipoCiclo: TipoCiclo | "";
|
||||
numCiclos: number | undefined;
|
||||
nombrePlan: string
|
||||
facultad: {
|
||||
id: string
|
||||
nombre: string
|
||||
}
|
||||
carrera: {
|
||||
id: string
|
||||
nombre: string
|
||||
}
|
||||
nivel: NivelPlanEstudio | ''
|
||||
tipoCiclo: TipoCiclo | ''
|
||||
numCiclos: number | null
|
||||
// Selección de plantillas (obligatorias)
|
||||
plantillaPlanId?: string;
|
||||
plantillaPlanVersion?: string;
|
||||
plantillaMapaId?: string;
|
||||
plantillaMapaVersion?: string;
|
||||
};
|
||||
clonInterno?: { planOrigenId: string | null };
|
||||
estructuraPlanId: string | null
|
||||
}
|
||||
clonInterno?: { planOrigenId: string | null }
|
||||
clonTradicional?: {
|
||||
archivoWordPlanId:
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
}
|
||||
| null;
|
||||
archivoWordPlanId: {
|
||||
id: string
|
||||
name: string
|
||||
size: string
|
||||
type: string
|
||||
} | null
|
||||
archivoMapaExcelId: {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
} | null;
|
||||
id: string
|
||||
name: string
|
||||
size: string
|
||||
type: string
|
||||
} | null
|
||||
archivoAsignaturasExcelId: {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
} | null;
|
||||
};
|
||||
id: string
|
||||
name: string
|
||||
size: string
|
||||
type: string
|
||||
} | null
|
||||
}
|
||||
iaConfig?: {
|
||||
descripcionEnfoque: string;
|
||||
poblacionObjetivo: string;
|
||||
notasAdicionales: string;
|
||||
archivosReferencia: Array<string>;
|
||||
repositoriosReferencia?: Array<string>;
|
||||
archivosAdjuntos?: Array<
|
||||
{ id: string; name: string; size: string; type: string }
|
||||
>;
|
||||
};
|
||||
resumen: { previewPlan?: PlanPreview };
|
||||
isLoading: boolean;
|
||||
errorMessage: string | null;
|
||||
};
|
||||
descripcionEnfoqueAcademico: string
|
||||
instruccionesAdicionalesIA?: string
|
||||
archivosReferencia: Array<string>
|
||||
repositoriosReferencia?: Array<string>
|
||||
archivosAdjuntos?: Array<UploadedFile>
|
||||
}
|
||||
resumen: { previewPlan?: PlanPreview }
|
||||
isLoading: boolean
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
5
src/features/planes/utils/format-file-size.ts
Normal file
5
src/features/planes/utils/format-file-size.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
};
|
||||
10
src/features/planes/utils/icon-utils.ts
Normal file
10
src/features/planes/utils/icon-utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/features/planes/utils/icon-utils.ts
|
||||
import * as Icons from "lucide-react";
|
||||
import { BookOpen } from "lucide-react";
|
||||
|
||||
export const getIconByName = (iconName: string | null) => {
|
||||
if (!iconName) return BookOpen;
|
||||
// "as any" es necesario aquí porque el string es dinámico
|
||||
const Icon = (Icons as any)[iconName];
|
||||
return Icon || BookOpen;
|
||||
};
|
||||
@@ -28,6 +28,9 @@ declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
interface HistoryState {
|
||||
showConfetti?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// Render the app
|
||||
|
||||
@@ -12,23 +12,27 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as PlanesPlanesListRouteRouteImport } from './routes/planes/PlanesListRoute'
|
||||
import { Route as PlanesListaRouteImport } from './routes/planes/_lista'
|
||||
import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query'
|
||||
import { Route as PlanesListaRouteRouteImport } from './routes/planes/_lista/route'
|
||||
import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo'
|
||||
import { Route as PlanesPlanIdAsignaturasRouteRouteImport } from './routes/planes/$planId/asignaturas/route'
|
||||
import { Route as PlanesPlanIdDetalleRouteRouteImport } from './routes/planes/$planId/_detalle/route'
|
||||
import { Route as PlanesPlanIdAsignaturasIndexRouteImport } from './routes/planes/$planId/asignaturas/index'
|
||||
import { Route as PlanesPlanIdDetalleMateriasRouteImport } from './routes/planes/$planId/_detalle/materias'
|
||||
import { Route as PlanesPlanIdDetalleRouteImport } from './routes/planes/$planId/_detalle'
|
||||
import { Route as PlanesPlanIdDetalleIndexRouteImport } from './routes/planes/$planId/_detalle/index'
|
||||
import { Route as PlanesPlanIdDetalleMapaRouteImport } from './routes/planes/$planId/_detalle/mapa'
|
||||
import { Route as PlanesPlanIdDetalleIaplanRouteImport } from './routes/planes/$planId/_detalle/iaplan'
|
||||
import { Route as PlanesPlanIdDetalleHistorialRouteImport } from './routes/planes/$planId/_detalle/historial'
|
||||
import { Route as PlanesPlanIdDetalleFlujoRouteImport } from './routes/planes/$planId/_detalle/flujo'
|
||||
import { Route as PlanesPlanIdDetalleDocumentoRouteImport } from './routes/planes/$planId/_detalle/documento'
|
||||
import { Route as PlanesPlanIdDetalleDatosRouteImport } from './routes/planes/$planId/_detalle/datos'
|
||||
import { Route as PlanesPlanIdAsignaturasListaRouteRouteImport } from './routes/planes/$planId/asignaturas/_lista/route'
|
||||
import { Route as PlanesPlanIdDetalleAsignaturasRouteImport } from './routes/planes/$planId/_detalle/asignaturas'
|
||||
import { Route as PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/route'
|
||||
import { Route as PlanesPlanIdAsignaturasListaNuevaRouteImport } from './routes/planes/$planId/asignaturas/_lista/nueva'
|
||||
import { Route as PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/index'
|
||||
import { Route as PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||
import { Route as PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||
import { Route as PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||
import { Route as PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||
import { Route as PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport } from './routes/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||
import { Route as PlanesPlanIdDetalleAsignaturasNuevaRouteImport } from './routes/planes/$planId/_detalle/asignaturas/nueva'
|
||||
import { Route as 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',
|
||||
@@ -45,9 +49,9 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlanesPlanesListRouteRoute = PlanesPlanesListRouteRouteImport.update({
|
||||
id: '/planes/PlanesListRoute',
|
||||
path: '/planes/PlanesListRoute',
|
||||
const PlanesListaRoute = PlanesListaRouteImport.update({
|
||||
id: '/planes/_lista',
|
||||
path: '/planes',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
|
||||
@@ -55,156 +59,193 @@ const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({
|
||||
path: '/demo/tanstack-query',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlanesListaRouteRoute = PlanesListaRouteRouteImport.update({
|
||||
id: '/planes/_lista',
|
||||
path: '/planes',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlanesListaNuevoRoute = PlanesListaNuevoRouteImport.update({
|
||||
id: '/nuevo',
|
||||
path: '/nuevo',
|
||||
getParentRoute: () => PlanesListaRouteRoute,
|
||||
getParentRoute: () => PlanesListaRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdAsignaturasRouteRoute =
|
||||
PlanesPlanIdAsignaturasRouteRouteImport.update({
|
||||
id: '/planes/$planId/asignaturas',
|
||||
path: '/planes/$planId/asignaturas',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlanesPlanIdDetalleRouteRoute =
|
||||
PlanesPlanIdDetalleRouteRouteImport.update({
|
||||
id: '/planes/$planId/_detalle',
|
||||
path: '/planes/$planId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlanesPlanIdAsignaturasIndexRoute =
|
||||
PlanesPlanIdAsignaturasIndexRouteImport.update({
|
||||
const PlanesPlanIdDetalleRoute = PlanesPlanIdDetalleRouteImport.update({
|
||||
id: '/planes/$planId/_detalle',
|
||||
path: '/planes/$planId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlanesPlanIdDetalleIndexRoute =
|
||||
PlanesPlanIdDetalleIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => PlanesPlanIdAsignaturasRouteRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdDetalleMateriasRoute =
|
||||
PlanesPlanIdDetalleMateriasRouteImport.update({
|
||||
id: '/materias',
|
||||
path: '/materias',
|
||||
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
|
||||
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdDetalleMapaRoute = PlanesPlanIdDetalleMapaRouteImport.update({
|
||||
id: '/mapa',
|
||||
path: '/mapa',
|
||||
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
|
||||
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdDetalleIaplanRoute =
|
||||
PlanesPlanIdDetalleIaplanRouteImport.update({
|
||||
id: '/iaplan',
|
||||
path: '/iaplan',
|
||||
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
|
||||
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdDetalleHistorialRoute =
|
||||
PlanesPlanIdDetalleHistorialRouteImport.update({
|
||||
id: '/historial',
|
||||
path: '/historial',
|
||||
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
|
||||
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdDetalleFlujoRoute =
|
||||
PlanesPlanIdDetalleFlujoRouteImport.update({
|
||||
id: '/flujo',
|
||||
path: '/flujo',
|
||||
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
|
||||
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdDetalleDocumentoRoute =
|
||||
PlanesPlanIdDetalleDocumentoRouteImport.update({
|
||||
id: '/documento',
|
||||
path: '/documento',
|
||||
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
|
||||
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdDetalleDatosRoute =
|
||||
PlanesPlanIdDetalleDatosRouteImport.update({
|
||||
id: '/datos',
|
||||
path: '/datos',
|
||||
getParentRoute: () => PlanesPlanIdDetalleRouteRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdAsignaturasListaRouteRoute =
|
||||
PlanesPlanIdAsignaturasListaRouteRouteImport.update({
|
||||
id: '/_lista',
|
||||
getParentRoute: () => PlanesPlanIdAsignaturasRouteRoute,
|
||||
const PlanesPlanIdDetalleAsignaturasRoute =
|
||||
PlanesPlanIdDetalleAsignaturasRouteImport.update({
|
||||
id: '/asignaturas',
|
||||
path: '/asignaturas',
|
||||
getParentRoute: () => PlanesPlanIdDetalleRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdAsignaturasAsignaturaIdRouteRoute =
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport.update({
|
||||
id: '/$asignaturaId',
|
||||
path: '/$asignaturaId',
|
||||
getParentRoute: () => PlanesPlanIdAsignaturasRouteRoute,
|
||||
id: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
path: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PlanesPlanIdAsignaturasListaNuevaRoute =
|
||||
PlanesPlanIdAsignaturasListaNuevaRouteImport.update({
|
||||
const PlanesPlanIdAsignaturasAsignaturaIdIndexRoute =
|
||||
PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute =
|
||||
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport.update({
|
||||
id: '/iaasignatura',
|
||||
path: '/iaasignatura',
|
||||
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute =
|
||||
PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport.update({
|
||||
id: '/historial',
|
||||
path: '/historial',
|
||||
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute =
|
||||
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport.update({
|
||||
id: '/documento',
|
||||
path: '/documento',
|
||||
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute =
|
||||
PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport.update({
|
||||
id: '/contenido',
|
||||
path: '/contenido',
|
||||
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute =
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport.update({
|
||||
id: '/bibliografia',
|
||||
path: '/bibliografia',
|
||||
getParentRoute: () => PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||
} as any)
|
||||
const PlanesPlanIdDetalleAsignaturasNuevaRoute =
|
||||
PlanesPlanIdDetalleAsignaturasNuevaRouteImport.update({
|
||||
id: '/nueva',
|
||||
path: '/nueva',
|
||||
getParentRoute: () => PlanesPlanIdAsignaturasListaRouteRoute,
|
||||
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
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/planes': typeof PlanesListaRouteRouteWithChildren
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
|
||||
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
|
||||
'/planes': typeof PlanesListaRouteWithChildren
|
||||
'/planes/$planId': typeof PlanesPlanIdDetalleRouteWithChildren
|
||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
'/planes/$planId/datos': typeof PlanesPlanIdDetalleDatosRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
||||
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
|
||||
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
|
||||
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
|
||||
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
|
||||
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
|
||||
'/planes/$planId/materias': typeof PlanesPlanIdDetalleMateriasRoute
|
||||
'/planes/$planId/asignaturas/': typeof PlanesPlanIdAsignaturasIndexRoute
|
||||
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdAsignaturasListaNuevaRoute
|
||||
'/planes/$planId/': typeof PlanesPlanIdDetalleIndexRoute
|
||||
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||
'/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
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/planes': typeof PlanesListaRouteRouteWithChildren
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
|
||||
'/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
'/planes': typeof PlanesListaRouteWithChildren
|
||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasIndexRoute
|
||||
'/planes/$planId/datos': typeof PlanesPlanIdDetalleDatosRoute
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
||||
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
|
||||
'/planes/$planId/flujo': typeof PlanesPlanIdDetalleFlujoRoute
|
||||
'/planes/$planId/historial': typeof PlanesPlanIdDetalleHistorialRoute
|
||||
'/planes/$planId/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
|
||||
'/planes/$planId/mapa': typeof PlanesPlanIdDetalleMapaRoute
|
||||
'/planes/$planId/materias': typeof PlanesPlanIdDetalleMateriasRoute
|
||||
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdAsignaturasListaNuevaRoute
|
||||
'/planes/$planId': typeof PlanesPlanIdDetalleIndexRoute
|
||||
'/planes/$planId/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||
'/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
|
||||
'/': typeof IndexRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/planes/_lista': typeof PlanesListaRouteRouteWithChildren
|
||||
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
|
||||
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
|
||||
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
|
||||
'/planes/_lista': typeof PlanesListaRouteWithChildren
|
||||
'/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteWithChildren
|
||||
'/planes/_lista/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
'/planes/$planId/asignaturas/_lista': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
|
||||
'/planes/$planId/_detalle/datos': typeof PlanesPlanIdDetalleDatosRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
|
||||
'/planes/$planId/_detalle/asignaturas': typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
||||
'/planes/$planId/_detalle/documento': typeof PlanesPlanIdDetalleDocumentoRoute
|
||||
'/planes/$planId/_detalle/flujo': typeof PlanesPlanIdDetalleFlujoRoute
|
||||
'/planes/$planId/_detalle/historial': typeof PlanesPlanIdDetalleHistorialRoute
|
||||
'/planes/$planId/_detalle/iaplan': typeof PlanesPlanIdDetalleIaplanRoute
|
||||
'/planes/$planId/_detalle/mapa': typeof PlanesPlanIdDetalleMapaRoute
|
||||
'/planes/$planId/_detalle/materias': typeof PlanesPlanIdDetalleMateriasRoute
|
||||
'/planes/$planId/asignaturas/': typeof PlanesPlanIdAsignaturasIndexRoute
|
||||
'/planes/$planId/asignaturas/_lista/nueva': typeof PlanesPlanIdAsignaturasListaNuevaRoute
|
||||
'/planes/$planId/_detalle/': typeof PlanesPlanIdDetalleIndexRoute
|
||||
'/planes/$planId/_detalle/asignaturas/nueva': typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||
'/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
|
||||
@@ -212,75 +253,86 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/dashboard'
|
||||
| '/login'
|
||||
| '/planes'
|
||||
| '/demo/tanstack-query'
|
||||
| '/planes/PlanesListRoute'
|
||||
| '/planes'
|
||||
| '/planes/$planId'
|
||||
| '/planes/$planId/asignaturas'
|
||||
| '/planes/nuevo'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/datos'
|
||||
| '/planes/$planId/asignaturas'
|
||||
| '/planes/$planId/documento'
|
||||
| '/planes/$planId/flujo'
|
||||
| '/planes/$planId/historial'
|
||||
| '/planes/$planId/iaplan'
|
||||
| '/planes/$planId/mapa'
|
||||
| '/planes/$planId/materias'
|
||||
| '/planes/$planId/asignaturas/'
|
||||
| '/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/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/dashboard'
|
||||
| '/login'
|
||||
| '/planes'
|
||||
| '/demo/tanstack-query'
|
||||
| '/planes/PlanesListRoute'
|
||||
| '/planes/$planId'
|
||||
| '/planes'
|
||||
| '/planes/nuevo'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/asignaturas'
|
||||
| '/planes/$planId/datos'
|
||||
| '/planes/$planId/documento'
|
||||
| '/planes/$planId/flujo'
|
||||
| '/planes/$planId/historial'
|
||||
| '/planes/$planId/iaplan'
|
||||
| '/planes/$planId/mapa'
|
||||
| '/planes/$planId/materias'
|
||||
| '/planes/$planId'
|
||||
| '/planes/$planId/asignaturas/nueva'
|
||||
| '/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__'
|
||||
| '/'
|
||||
| '/dashboard'
|
||||
| '/login'
|
||||
| '/planes/_lista'
|
||||
| '/demo/tanstack-query'
|
||||
| '/planes/PlanesListRoute'
|
||||
| '/planes/_lista'
|
||||
| '/planes/$planId/_detalle'
|
||||
| '/planes/$planId/asignaturas'
|
||||
| '/planes/_lista/nuevo'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/asignaturas/_lista'
|
||||
| '/planes/$planId/_detalle/datos'
|
||||
| '/planes/$planId/_detalle/asignaturas'
|
||||
| '/planes/$planId/_detalle/documento'
|
||||
| '/planes/$planId/_detalle/flujo'
|
||||
| '/planes/$planId/_detalle/historial'
|
||||
| '/planes/$planId/_detalle/iaplan'
|
||||
| '/planes/$planId/_detalle/mapa'
|
||||
| '/planes/$planId/_detalle/materias'
|
||||
| '/planes/$planId/asignaturas/'
|
||||
| '/planes/$planId/asignaturas/_lista/nueva'
|
||||
| '/planes/$planId/_detalle/'
|
||||
| '/planes/$planId/_detalle/asignaturas/nueva'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/nueva'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId/bibliografia/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
DashboardRoute: typeof DashboardRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren
|
||||
DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute
|
||||
PlanesPlanesListRouteRoute: typeof PlanesPlanesListRouteRoute
|
||||
PlanesPlanIdDetalleRouteRoute: typeof PlanesPlanIdDetalleRouteRouteWithChildren
|
||||
PlanesPlanIdAsignaturasRouteRoute: typeof PlanesPlanIdAsignaturasRouteRouteWithChildren
|
||||
PlanesListaRoute: typeof PlanesListaRouteWithChildren
|
||||
PlanesPlanIdDetalleRoute: typeof PlanesPlanIdDetalleRouteWithChildren
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -306,11 +358,11 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/planes/PlanesListRoute': {
|
||||
id: '/planes/PlanesListRoute'
|
||||
path: '/planes/PlanesListRoute'
|
||||
fullPath: '/planes/PlanesListRoute'
|
||||
preLoaderRoute: typeof PlanesPlanesListRouteRouteImport
|
||||
'/planes/_lista': {
|
||||
id: '/planes/_lista'
|
||||
path: '/planes'
|
||||
fullPath: '/planes'
|
||||
preLoaderRoute: typeof PlanesListaRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/demo/tanstack-query': {
|
||||
@@ -320,196 +372,250 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DemoTanstackQueryRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/planes/_lista': {
|
||||
id: '/planes/_lista'
|
||||
path: '/planes'
|
||||
fullPath: '/planes'
|
||||
preLoaderRoute: typeof PlanesListaRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/planes/_lista/nuevo': {
|
||||
id: '/planes/_lista/nuevo'
|
||||
path: '/nuevo'
|
||||
fullPath: '/planes/nuevo'
|
||||
preLoaderRoute: typeof PlanesListaNuevoRouteImport
|
||||
parentRoute: typeof PlanesListaRouteRoute
|
||||
}
|
||||
'/planes/$planId/asignaturas': {
|
||||
id: '/planes/$planId/asignaturas'
|
||||
path: '/planes/$planId/asignaturas'
|
||||
fullPath: '/planes/$planId/asignaturas'
|
||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasRouteRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
parentRoute: typeof PlanesListaRoute
|
||||
}
|
||||
'/planes/$planId/_detalle': {
|
||||
id: '/planes/$planId/_detalle'
|
||||
path: '/planes/$planId'
|
||||
fullPath: '/planes/$planId'
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleRouteRouteImport
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/planes/$planId/asignaturas/': {
|
||||
id: '/planes/$planId/asignaturas/'
|
||||
'/planes/$planId/_detalle/': {
|
||||
id: '/planes/$planId/_detalle/'
|
||||
path: '/'
|
||||
fullPath: '/planes/$planId/asignaturas/'
|
||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasIndexRouteImport
|
||||
parentRoute: typeof PlanesPlanIdAsignaturasRouteRoute
|
||||
}
|
||||
'/planes/$planId/_detalle/materias': {
|
||||
id: '/planes/$planId/_detalle/materias'
|
||||
path: '/materias'
|
||||
fullPath: '/planes/$planId/materias'
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleMateriasRouteImport
|
||||
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
|
||||
fullPath: '/planes/$planId/'
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleIndexRouteImport
|
||||
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||
}
|
||||
'/planes/$planId/_detalle/mapa': {
|
||||
id: '/planes/$planId/_detalle/mapa'
|
||||
path: '/mapa'
|
||||
fullPath: '/planes/$planId/mapa'
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleMapaRouteImport
|
||||
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
|
||||
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||
}
|
||||
'/planes/$planId/_detalle/iaplan': {
|
||||
id: '/planes/$planId/_detalle/iaplan'
|
||||
path: '/iaplan'
|
||||
fullPath: '/planes/$planId/iaplan'
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleIaplanRouteImport
|
||||
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
|
||||
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||
}
|
||||
'/planes/$planId/_detalle/historial': {
|
||||
id: '/planes/$planId/_detalle/historial'
|
||||
path: '/historial'
|
||||
fullPath: '/planes/$planId/historial'
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleHistorialRouteImport
|
||||
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
|
||||
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||
}
|
||||
'/planes/$planId/_detalle/flujo': {
|
||||
id: '/planes/$planId/_detalle/flujo'
|
||||
path: '/flujo'
|
||||
fullPath: '/planes/$planId/flujo'
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleFlujoRouteImport
|
||||
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
|
||||
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||
}
|
||||
'/planes/$planId/_detalle/documento': {
|
||||
id: '/planes/$planId/_detalle/documento'
|
||||
path: '/documento'
|
||||
fullPath: '/planes/$planId/documento'
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleDocumentoRouteImport
|
||||
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
|
||||
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||
}
|
||||
'/planes/$planId/_detalle/datos': {
|
||||
id: '/planes/$planId/_detalle/datos'
|
||||
path: '/datos'
|
||||
fullPath: '/planes/$planId/datos'
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleDatosRouteImport
|
||||
parentRoute: typeof PlanesPlanIdDetalleRouteRoute
|
||||
}
|
||||
'/planes/$planId/asignaturas/_lista': {
|
||||
id: '/planes/$planId/asignaturas/_lista'
|
||||
path: ''
|
||||
'/planes/$planId/_detalle/asignaturas': {
|
||||
id: '/planes/$planId/_detalle/asignaturas'
|
||||
path: '/asignaturas'
|
||||
fullPath: '/planes/$planId/asignaturas'
|
||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasListaRouteRouteImport
|
||||
parentRoute: typeof PlanesPlanIdAsignaturasRouteRoute
|
||||
preLoaderRoute: typeof PlanesPlanIdDetalleAsignaturasRouteImport
|
||||
parentRoute: typeof PlanesPlanIdDetalleRoute
|
||||
}
|
||||
'/planes/$planId/asignaturas/$asignaturaId': {
|
||||
id: '/planes/$planId/asignaturas/$asignaturaId'
|
||||
path: '/$asignaturaId'
|
||||
path: '/planes/$planId/asignaturas/$asignaturaId'
|
||||
fullPath: '/planes/$planId/asignaturas/$asignaturaId'
|
||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRouteImport
|
||||
parentRoute: typeof PlanesPlanIdAsignaturasRouteRoute
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/planes/$planId/asignaturas/_lista/nueva': {
|
||||
id: '/planes/$planId/asignaturas/_lista/nueva'
|
||||
'/planes/$planId/asignaturas/$asignaturaId/': {
|
||||
id: '/planes/$planId/asignaturas/$asignaturaId/'
|
||||
path: '/'
|
||||
fullPath: '/planes/$planId/asignaturas/$asignaturaId/'
|
||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRouteImport
|
||||
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
}
|
||||
'/planes/$planId/asignaturas/$asignaturaId/iaasignatura': {
|
||||
id: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||
path: '/iaasignatura'
|
||||
fullPath: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura'
|
||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRouteImport
|
||||
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
}
|
||||
'/planes/$planId/asignaturas/$asignaturaId/historial': {
|
||||
id: '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||
path: '/historial'
|
||||
fullPath: '/planes/$planId/asignaturas/$asignaturaId/historial'
|
||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRouteImport
|
||||
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
}
|
||||
'/planes/$planId/asignaturas/$asignaturaId/documento': {
|
||||
id: '/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||
path: '/documento'
|
||||
fullPath: '/planes/$planId/asignaturas/$asignaturaId/documento'
|
||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRouteImport
|
||||
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
}
|
||||
'/planes/$planId/asignaturas/$asignaturaId/contenido': {
|
||||
id: '/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||
path: '/contenido'
|
||||
fullPath: '/planes/$planId/asignaturas/$asignaturaId/contenido'
|
||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRouteImport
|
||||
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
}
|
||||
'/planes/$planId/asignaturas/$asignaturaId/bibliografia': {
|
||||
id: '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||
path: '/bibliografia'
|
||||
fullPath: '/planes/$planId/asignaturas/$asignaturaId/bibliografia'
|
||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteImport
|
||||
parentRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
}
|
||||
'/planes/$planId/_detalle/asignaturas/nueva': {
|
||||
id: '/planes/$planId/_detalle/asignaturas/nueva'
|
||||
path: '/nueva'
|
||||
fullPath: '/planes/$planId/asignaturas/nueva'
|
||||
preLoaderRoute: typeof PlanesPlanIdAsignaturasListaNuevaRouteImport
|
||||
parentRoute: typeof PlanesPlanIdAsignaturasListaRouteRoute
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface PlanesListaRouteRouteChildren {
|
||||
interface PlanesListaRouteChildren {
|
||||
PlanesListaNuevoRoute: typeof PlanesListaNuevoRoute
|
||||
}
|
||||
|
||||
const PlanesListaRouteRouteChildren: PlanesListaRouteRouteChildren = {
|
||||
const PlanesListaRouteChildren: PlanesListaRouteChildren = {
|
||||
PlanesListaNuevoRoute: PlanesListaNuevoRoute,
|
||||
}
|
||||
|
||||
const PlanesListaRouteRouteWithChildren =
|
||||
PlanesListaRouteRoute._addFileChildren(PlanesListaRouteRouteChildren)
|
||||
const PlanesListaRouteWithChildren = PlanesListaRoute._addFileChildren(
|
||||
PlanesListaRouteChildren,
|
||||
)
|
||||
|
||||
interface PlanesPlanIdDetalleRouteRouteChildren {
|
||||
PlanesPlanIdDetalleDatosRoute: typeof PlanesPlanIdDetalleDatosRoute
|
||||
interface PlanesPlanIdDetalleAsignaturasRouteChildren {
|
||||
PlanesPlanIdDetalleAsignaturasNuevaRoute: typeof PlanesPlanIdDetalleAsignaturasNuevaRoute
|
||||
}
|
||||
|
||||
const PlanesPlanIdDetalleAsignaturasRouteChildren: PlanesPlanIdDetalleAsignaturasRouteChildren =
|
||||
{
|
||||
PlanesPlanIdDetalleAsignaturasNuevaRoute:
|
||||
PlanesPlanIdDetalleAsignaturasNuevaRoute,
|
||||
}
|
||||
|
||||
const PlanesPlanIdDetalleAsignaturasRouteWithChildren =
|
||||
PlanesPlanIdDetalleAsignaturasRoute._addFileChildren(
|
||||
PlanesPlanIdDetalleAsignaturasRouteChildren,
|
||||
)
|
||||
|
||||
interface PlanesPlanIdDetalleRouteChildren {
|
||||
PlanesPlanIdDetalleAsignaturasRoute: typeof PlanesPlanIdDetalleAsignaturasRouteWithChildren
|
||||
PlanesPlanIdDetalleDocumentoRoute: typeof PlanesPlanIdDetalleDocumentoRoute
|
||||
PlanesPlanIdDetalleFlujoRoute: typeof PlanesPlanIdDetalleFlujoRoute
|
||||
PlanesPlanIdDetalleHistorialRoute: typeof PlanesPlanIdDetalleHistorialRoute
|
||||
PlanesPlanIdDetalleIaplanRoute: typeof PlanesPlanIdDetalleIaplanRoute
|
||||
PlanesPlanIdDetalleMapaRoute: typeof PlanesPlanIdDetalleMapaRoute
|
||||
PlanesPlanIdDetalleMateriasRoute: typeof PlanesPlanIdDetalleMateriasRoute
|
||||
PlanesPlanIdDetalleIndexRoute: typeof PlanesPlanIdDetalleIndexRoute
|
||||
}
|
||||
|
||||
const PlanesPlanIdDetalleRouteRouteChildren: PlanesPlanIdDetalleRouteRouteChildren =
|
||||
const PlanesPlanIdDetalleRouteChildren: PlanesPlanIdDetalleRouteChildren = {
|
||||
PlanesPlanIdDetalleAsignaturasRoute:
|
||||
PlanesPlanIdDetalleAsignaturasRouteWithChildren,
|
||||
PlanesPlanIdDetalleDocumentoRoute: PlanesPlanIdDetalleDocumentoRoute,
|
||||
PlanesPlanIdDetalleFlujoRoute: PlanesPlanIdDetalleFlujoRoute,
|
||||
PlanesPlanIdDetalleHistorialRoute: PlanesPlanIdDetalleHistorialRoute,
|
||||
PlanesPlanIdDetalleIaplanRoute: PlanesPlanIdDetalleIaplanRoute,
|
||||
PlanesPlanIdDetalleMapaRoute: PlanesPlanIdDetalleMapaRoute,
|
||||
PlanesPlanIdDetalleIndexRoute: PlanesPlanIdDetalleIndexRoute,
|
||||
}
|
||||
|
||||
const PlanesPlanIdDetalleRouteWithChildren =
|
||||
PlanesPlanIdDetalleRoute._addFileChildren(PlanesPlanIdDetalleRouteChildren)
|
||||
|
||||
interface PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren {
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute
|
||||
}
|
||||
|
||||
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren =
|
||||
{
|
||||
PlanesPlanIdDetalleDatosRoute: PlanesPlanIdDetalleDatosRoute,
|
||||
PlanesPlanIdDetalleDocumentoRoute: PlanesPlanIdDetalleDocumentoRoute,
|
||||
PlanesPlanIdDetalleFlujoRoute: PlanesPlanIdDetalleFlujoRoute,
|
||||
PlanesPlanIdDetalleHistorialRoute: PlanesPlanIdDetalleHistorialRoute,
|
||||
PlanesPlanIdDetalleIaplanRoute: PlanesPlanIdDetalleIaplanRoute,
|
||||
PlanesPlanIdDetalleMapaRoute: PlanesPlanIdDetalleMapaRoute,
|
||||
PlanesPlanIdDetalleMateriasRoute: PlanesPlanIdDetalleMateriasRoute,
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute:
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaNuevaRoute,
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute:
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaIndexRoute,
|
||||
}
|
||||
|
||||
const PlanesPlanIdDetalleRouteRouteWithChildren =
|
||||
PlanesPlanIdDetalleRouteRoute._addFileChildren(
|
||||
PlanesPlanIdDetalleRouteRouteChildren,
|
||||
const PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren =
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute._addFileChildren(
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteChildren,
|
||||
)
|
||||
|
||||
interface PlanesPlanIdAsignaturasListaRouteRouteChildren {
|
||||
PlanesPlanIdAsignaturasListaNuevaRoute: typeof PlanesPlanIdAsignaturasListaNuevaRoute
|
||||
interface PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren {
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren
|
||||
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute
|
||||
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute
|
||||
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute
|
||||
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute
|
||||
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdIndexRoute
|
||||
}
|
||||
|
||||
const PlanesPlanIdAsignaturasListaRouteRouteChildren: PlanesPlanIdAsignaturasListaRouteRouteChildren =
|
||||
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren: PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren =
|
||||
{
|
||||
PlanesPlanIdAsignaturasListaNuevaRoute:
|
||||
PlanesPlanIdAsignaturasListaNuevaRoute,
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRoute:
|
||||
PlanesPlanIdAsignaturasAsignaturaIdBibliografiaRouteWithChildren,
|
||||
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute:
|
||||
PlanesPlanIdAsignaturasAsignaturaIdContenidoRoute,
|
||||
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute:
|
||||
PlanesPlanIdAsignaturasAsignaturaIdDocumentoRoute,
|
||||
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute:
|
||||
PlanesPlanIdAsignaturasAsignaturaIdHistorialRoute,
|
||||
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute:
|
||||
PlanesPlanIdAsignaturasAsignaturaIdIaasignaturaRoute,
|
||||
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute:
|
||||
PlanesPlanIdAsignaturasAsignaturaIdIndexRoute,
|
||||
}
|
||||
|
||||
const PlanesPlanIdAsignaturasListaRouteRouteWithChildren =
|
||||
PlanesPlanIdAsignaturasListaRouteRoute._addFileChildren(
|
||||
PlanesPlanIdAsignaturasListaRouteRouteChildren,
|
||||
)
|
||||
|
||||
interface PlanesPlanIdAsignaturasRouteRouteChildren {
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute: typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
PlanesPlanIdAsignaturasListaRouteRoute: typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
|
||||
PlanesPlanIdAsignaturasIndexRoute: typeof PlanesPlanIdAsignaturasIndexRoute
|
||||
}
|
||||
|
||||
const PlanesPlanIdAsignaturasRouteRouteChildren: PlanesPlanIdAsignaturasRouteRouteChildren =
|
||||
{
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute:
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute,
|
||||
PlanesPlanIdAsignaturasListaRouteRoute:
|
||||
PlanesPlanIdAsignaturasListaRouteRouteWithChildren,
|
||||
PlanesPlanIdAsignaturasIndexRoute: PlanesPlanIdAsignaturasIndexRoute,
|
||||
}
|
||||
|
||||
const PlanesPlanIdAsignaturasRouteRouteWithChildren =
|
||||
PlanesPlanIdAsignaturasRouteRoute._addFileChildren(
|
||||
PlanesPlanIdAsignaturasRouteRouteChildren,
|
||||
const PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren =
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute._addFileChildren(
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
DashboardRoute: DashboardRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren,
|
||||
DemoTanstackQueryRoute: DemoTanstackQueryRoute,
|
||||
PlanesPlanesListRouteRoute: PlanesPlanesListRouteRoute,
|
||||
PlanesPlanIdDetalleRouteRoute: PlanesPlanIdDetalleRouteRouteWithChildren,
|
||||
PlanesPlanIdAsignaturasRouteRoute:
|
||||
PlanesPlanIdAsignaturasRouteRouteWithChildren,
|
||||
PlanesListaRoute: PlanesListaRouteWithChildren,
|
||||
PlanesPlanIdDetalleRoute: PlanesPlanIdDetalleRouteWithChildren,
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRoute:
|
||||
PlanesPlanIdAsignaturasAsignaturaIdRouteRouteWithChildren,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -7,6 +7,8 @@ import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'
|
||||
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { NotFoundPage } from '@/components/ui/NotFoundPage'
|
||||
|
||||
interface MyRouterContext {
|
||||
queryClient: QueryClient
|
||||
}
|
||||
@@ -30,4 +32,31 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
|
||||
/>
|
||||
</>
|
||||
),
|
||||
|
||||
notFoundComponent: () => <NotFoundPage />,
|
||||
|
||||
errorComponent: ({ error, reset }) => {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center space-y-4 p-6 text-center">
|
||||
<h2 className="text-2xl font-bold text-red-600">
|
||||
¡Ups! Algo salió mal
|
||||
</h2>
|
||||
<p className="max-w-md text-gray-600">
|
||||
Ocurrió un error inesperado al cargar esta sección.
|
||||
</p>
|
||||
|
||||
{/* Opcional: Mostrar el detalle técnico en desarrollo */}
|
||||
<pre className="max-w-full overflow-auto rounded border border-gray-300 bg-gray-100 p-4 text-left text-xs">
|
||||
{error.message}
|
||||
</pre>
|
||||
|
||||
<button
|
||||
onClick={reset}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
Intentar de nuevo
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
343
src/routes/planes/$planId/_detalle.tsx
Normal file
343
src/routes/planes/$planId/_detalle.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import { createFileRoute, Outlet, Link, notFound } from '@tanstack/react-router'
|
||||
import {
|
||||
ChevronLeft,
|
||||
GraduationCap,
|
||||
Clock,
|
||||
Hash,
|
||||
CalendarDays,
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, forwardRef } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { NotFoundPage } from '@/components/ui/NotFoundPage'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { plans_get } from '@/data/api/plans.api'
|
||||
import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans'
|
||||
import { qk } from '@/data/query/keys'
|
||||
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle')({
|
||||
loader: async ({ context: { queryClient }, params: { planId } }) => {
|
||||
try {
|
||||
await queryClient.ensureQueryData({
|
||||
queryKey: qk.plan(planId),
|
||||
queryFn: () => plans_get(planId),
|
||||
})
|
||||
} catch (e: any) {
|
||||
// PGRST116: The result contains 0 rows
|
||||
if (e?.code === 'PGRST116') {
|
||||
throw notFound()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
},
|
||||
notFoundComponent: () => {
|
||||
return (
|
||||
<NotFoundPage
|
||||
title="Plan de Estudios no encontrado"
|
||||
message="El plan de estudios que intentas consultar no existe o no tienes permisos para verlo."
|
||||
/>
|
||||
)
|
||||
},
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const { planId } = Route.useParams()
|
||||
const { data, isLoading } = usePlan(planId)
|
||||
const { mutate } = useUpdatePlanFields()
|
||||
|
||||
// Estados locales para manejar la edición "en vivo" antes de persistir
|
||||
const [nombrePlan, setNombrePlan] = useState('')
|
||||
const [nivelPlan, setNivelPlan] = useState('')
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setNombrePlan(data.nombre || '')
|
||||
setNivelPlan(data.nivel || '')
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const niveles = [
|
||||
'Licenciatura',
|
||||
'Maestría',
|
||||
'Doctorado',
|
||||
'Diplomado',
|
||||
'Especialidad',
|
||||
]
|
||||
|
||||
const persistChange = (patch: any) => {
|
||||
mutate({ planId, patch })
|
||||
}
|
||||
|
||||
const MAX_CHARACTERS = 200
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
// 1. Permitir teclas de control (Borrar, flechas, etc.) siempre
|
||||
const isControlKey =
|
||||
e.key === 'Backspace' ||
|
||||
e.key === 'Delete' ||
|
||||
e.key.includes('Arrow') ||
|
||||
e.metaKey ||
|
||||
e.ctrlKey
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Bloquear si excede los 200 caracteres y no es una tecla de control
|
||||
const currentText = e.currentTarget.textContent || ''
|
||||
if (currentText.length >= MAX_CHARACTERS && !isControlKey) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLSpanElement>) => {
|
||||
e.preventDefault()
|
||||
const text = e.clipboardData.getData('text/plain')
|
||||
const currentText = e.currentTarget.textContent || ''
|
||||
|
||||
// Calcular cuánto espacio queda
|
||||
const remainingSpace = MAX_CHARACTERS - currentText.length
|
||||
|
||||
if (remainingSpace > 0) {
|
||||
const slicedText = text.slice(0, remainingSpace)
|
||||
document.execCommand('insertText', false, slicedText)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* 1. Header Superior */}
|
||||
<div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm">
|
||||
<div className="px-6 py-2">
|
||||
<Link
|
||||
to="/planes"
|
||||
className="flex w-fit items-center gap-1 text-xs text-gray-500 transition-colors hover:text-gray-800"
|
||||
>
|
||||
<ChevronLeft size={14} /> Volver a planes
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-400 space-y-8 p-8">
|
||||
{/* 2. Header del Plan */}
|
||||
{isLoading ? (
|
||||
/* ===== SKELETON ===== */
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<DatosGeneralesSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-start justify-between gap-4 md:flex-row">
|
||||
<div>
|
||||
<h1 className="flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight text-slate-900">
|
||||
{/* El prefijo "Nivel en" lo mantenemos simple */}
|
||||
<span className="shrink-0">{nivelPlan} en</span>
|
||||
<span
|
||||
role="textbox"
|
||||
tabIndex={0}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste} // Añadido para controlar lo que pegan
|
||||
onBlur={(e) => {
|
||||
const nuevoNombre =
|
||||
e.currentTarget.textContent?.trim() || ''
|
||||
setNombrePlan(nuevoNombre)
|
||||
if (nuevoNombre !== data?.nombre) {
|
||||
mutate({ planId, patch: { nombre: nuevoNombre } })
|
||||
}
|
||||
}}
|
||||
// Clases añadidas: break-words y whitespace-pre-wrap para el wrap
|
||||
className="block w-full cursor-text border-b border-transparent break-words whitespace-pre-wrap transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500 sm:inline-block sm:w-auto"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
{nombrePlan}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-1 text-lg font-medium text-slate-500">
|
||||
{data?.carreras?.facultades?.nombre}{' '}
|
||||
{data?.carreras?.nombre_corto}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
|
||||
{data?.estados_plan?.etiqueta}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Cards de Información */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<InfoCard
|
||||
icon={<GraduationCap className="text-slate-400" />}
|
||||
label="Nivel"
|
||||
value={nivelPlan}
|
||||
isEditable
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-48">
|
||||
{niveles.map((n) => (
|
||||
<DropdownMenuItem
|
||||
key={n}
|
||||
onClick={() => {
|
||||
setNivelPlan(n)
|
||||
if (n !== data?.nivel) {
|
||||
mutate({ planId, patch: { nivel: n } })
|
||||
}
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<InfoCard
|
||||
icon={<Clock className="text-slate-400" />}
|
||||
label="Duración"
|
||||
value={`${data?.numero_ciclos || 0} Ciclos`}
|
||||
/>
|
||||
<InfoCard
|
||||
icon={<Hash className="text-slate-400" />}
|
||||
label="Créditos"
|
||||
value="320"
|
||||
/>
|
||||
<InfoCard
|
||||
icon={<CalendarDays className="text-slate-400" />}
|
||||
label="Creación"
|
||||
value={data?.creado_en?.split('T')[0]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 4. Navegación de Tabs */}
|
||||
<div className="scrollbar-hide overflow-x-auto border-b">
|
||||
<nav className="flex min-w-max gap-8">
|
||||
<Tab to="/planes/$planId/" params={{ planId }}>
|
||||
Datos Generales
|
||||
</Tab>
|
||||
<Tab to="/planes/$planId/mapa" params={{ planId }}>
|
||||
Mapa Curricular
|
||||
</Tab>
|
||||
<Tab to="/planes/$planId/asignaturas" params={{ planId }}>
|
||||
Asignaturas
|
||||
</Tab>
|
||||
<Tab to="/planes/$planId/flujo" params={{ planId }}>
|
||||
Flujo y Estados
|
||||
</Tab>
|
||||
<Tab to="/planes/$planId/iaplan" params={{ planId }}>
|
||||
IA del Plan
|
||||
</Tab>
|
||||
<Tab to="/planes/$planId/documento" params={{ planId }}>
|
||||
Documento
|
||||
</Tab>
|
||||
<Tab to="/planes/$planId/historial" params={{ planId }}>
|
||||
Historial
|
||||
</Tab>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<main className="animate-in fade-in pt-2 duration-500">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const InfoCard = forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: string | number | undefined
|
||||
isEditable?: boolean
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
>(function InfoCard(
|
||||
{ icon, label, value, isEditable, className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={`flex h-18 w-full items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm transition-all ${
|
||||
isEditable
|
||||
? 'cursor-pointer hover:border-teal-200 hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500/40'
|
||||
: ''
|
||||
} ${className ?? ''}`}
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-white shadow-sm">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="mb-0.5 truncate text-[10px] font-bold tracking-wider text-slate-400 uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="truncate text-sm font-semibold text-slate-700">
|
||||
{value || '---'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function Tab({
|
||||
to,
|
||||
params,
|
||||
children,
|
||||
}: {
|
||||
to: string
|
||||
params?: any
|
||||
search?: any
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
params={params}
|
||||
className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800"
|
||||
activeProps={{ className: 'border-teal-600 text-teal-700 font-bold' }}
|
||||
activeOptions={{
|
||||
exact: true,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function DatosGeneralesSkeleton() {
|
||||
return (
|
||||
<div className="rounded-xl border bg-white">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-5 py-3">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-3 p-5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-11/12" />
|
||||
<Skeleton className="h-4 w-10/12" />
|
||||
<Skeleton className="h-4 w-9/12" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { Pencil, X } from 'lucide-react';
|
||||
export type Materia = {
|
||||
id: string;
|
||||
clave: string;
|
||||
nombre: string;
|
||||
creditos: number;
|
||||
hd: number; // Horas Docente
|
||||
hi: number; // Horas Independientes
|
||||
tipo: 'Obligatoria' | 'Optativa' | 'Especialidad';
|
||||
ciclo: number;
|
||||
linea: string;
|
||||
estado: string;
|
||||
};
|
||||
|
||||
interface MateriaCardProps {
|
||||
materia: Materia;
|
||||
}
|
||||
|
||||
export function MateriaCard({ materia }: MateriaCardProps) {
|
||||
return (
|
||||
<Dialog.Root>
|
||||
{/* Trigger: La tarjeta en sí misma */}
|
||||
<Dialog.Trigger asChild>
|
||||
<div className="group relative flex flex-col p-2 mb-2 rounded-lg border border-slate-200 bg-white hover:border-emerald-500 hover:shadow-md transition-all cursor-pointer select-none">
|
||||
{/* Header de la tarjeta */}
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<span className="text-[9px] font-mono font-bold text-slate-400 uppercase">{materia.clave}</span>
|
||||
<div className="flex gap-1">
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-[8px] font-bold uppercase">
|
||||
{materia.tipo === 'Obligatoria' ? 'OB' : 'OP'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nombre */}
|
||||
<h4 className="text-[11px] font-semibold text-slate-800 leading-tight mb-2 min-h-[2rem]">
|
||||
{materia.nombre}
|
||||
</h4>
|
||||
|
||||
{/* Footer de la tarjeta (Créditos y Horas) */}
|
||||
<div className="flex justify-between items-center text-[9px] text-slate-500 border-t pt-1 border-slate-50">
|
||||
<span>{materia.creditos} cr</span>
|
||||
<div className="flex gap-1">
|
||||
<span>HD:{materia.hd}</span>
|
||||
<span>HI:{materia.hi}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlay de Hover (Opcional: un iconito de editar) */}
|
||||
<div className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Pencil className="w-3 h-3 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Trigger>
|
||||
|
||||
{/* Modal / Portal */}
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 animate-in fade-in" />
|
||||
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white rounded-xl shadow-2xl p-6 z-50 border border-slate-200 animate-in zoom-in-95">
|
||||
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Dialog.Title className="text-lg font-bold text-slate-800">Editar Materia</Dialog.Title>
|
||||
<Dialog.Close className="text-slate-400 hover:text-slate-600 transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4">
|
||||
{/* Clave y Nombre */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-slate-600 uppercase">Clave</label>
|
||||
<input
|
||||
defaultValue={materia.clave}
|
||||
className="px-3 py-2 rounded-lg border border-slate-300 focus:ring-2 focus:ring-emerald-500 outline-none text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-slate-600 uppercase">Nombre</label>
|
||||
<input
|
||||
defaultValue={materia.nombre}
|
||||
className="px-3 py-2 rounded-lg border border-slate-300 focus:ring-2 focus:ring-emerald-500 outline-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Créditos y Horas */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-slate-600 uppercase italic">Créditos</label>
|
||||
<input type="number" defaultValue={materia.creditos} className="px-3 py-2 rounded-lg border border-slate-300 text-sm" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-slate-600 uppercase italic">HD (Hrs Docente)</label>
|
||||
<input type="number" defaultValue={materia.hd} className="px-3 py-2 rounded-lg border border-slate-300 text-sm" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-slate-600 uppercase italic">HI (Hrs Indep.)</label>
|
||||
<input type="number" defaultValue={materia.hi} className="px-3 py-2 rounded-lg border border-slate-300 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ciclo y Línea */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-slate-600 uppercase">Ciclo</label>
|
||||
<select className="px-3 py-2 rounded-lg border border-slate-300 text-sm bg-white">
|
||||
<option>Ciclo {materia.ciclo}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-slate-600 uppercase">Línea Curricular</label>
|
||||
<select className="px-3 py-2 rounded-lg border border-slate-300 text-sm bg-white">
|
||||
<option>{materia.linea}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botones de acción */}
|
||||
<div className="flex justify-end gap-3 pt-6">
|
||||
<Dialog.Close className="px-4 py-2 rounded-lg text-sm font-semibold text-slate-600 hover:bg-slate-100 transition-colors">
|
||||
Cancelar
|
||||
</Dialog.Close>
|
||||
<button
|
||||
type="button"
|
||||
className="px-6 py-2 rounded-lg text-sm font-semibold bg-emerald-700 text-white hover:bg-emerald-800 transition-colors shadow-sm"
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user