Compare commits
368
Commits
c3abc377bf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c744b8c89 | ||
|
|
18ba592378 | ||
|
|
1ede5bca32 | ||
|
|
80cc0a9f2b | ||
|
|
7b437c56e0 | ||
|
|
b37d36fe15 | ||
|
|
d3267e5890 | ||
|
|
b77906e2e9 | ||
|
|
03cdd24c1d | ||
|
|
a98982d228 | ||
|
|
c710b85090 | ||
|
|
36b9c559fa | ||
|
|
434b50cfc3 | ||
|
|
51f967a955 | ||
|
|
f5a4b0b9af | ||
|
|
8ae3469e10 | ||
|
|
ddb83782a6 | ||
|
|
085e6fa0e9 | ||
|
|
b3e9d63833 | ||
|
|
7a2f16b160 | ||
|
|
54200986ab | ||
|
|
bfafb8999e | ||
|
|
883e6e7fa4 | ||
|
|
dd11c2707e | ||
|
|
60c2e8402d | ||
|
|
350d7c0f69 | ||
|
|
f2900f3d22 | ||
|
|
1f27ea6252 | ||
|
|
0e710f9637 | ||
|
|
3bb3a11779 | ||
|
|
75784658bf | ||
|
|
d2f12d96de | ||
|
|
7f9c3162a0 | ||
|
|
03ef51d8a6 | ||
|
|
f81b404223 | ||
|
|
614150f8d1 | ||
|
|
54a3b74836 | ||
|
|
0820bf19a5 | ||
|
|
7ddc5c7f1f | ||
|
|
ad7a741d1e | ||
|
|
60bfa31186 | ||
|
|
1bd37c7e27 | ||
|
|
7eb9ca0bec | ||
|
|
10dc299311 | ||
|
|
b3954ab16c | ||
|
|
88a2a28a8d | ||
|
|
36a11e3793 | ||
|
|
33efaed03f | ||
|
|
d481e9706c | ||
|
|
ee3b7a56ec | ||
|
|
2359e38f85 | ||
|
|
ed318fa67b | ||
|
|
c262cd16be | ||
|
|
a08b2abf87 | ||
|
|
a07213d959 | ||
|
|
a2234e5022 | ||
|
|
658c392f96 | ||
|
|
4c730fa0ab | ||
|
|
2abe296b9e | ||
|
|
1bce226d15 | ||
|
|
b986ec343e | ||
|
|
9fd816bfa1 | ||
|
|
379e2d3826 | ||
|
|
cb5422f57c | ||
|
|
67724181fd | ||
|
|
d9a5cec3c5 | ||
|
|
96848e1793 | ||
|
|
cbaf96c6b5 | ||
|
|
0fb831fb58 | ||
|
|
0d1aa61022 | ||
|
|
84281a88f2 | ||
|
|
d91018c612 | ||
|
|
658b2e245c | ||
|
|
30562fead0 | ||
|
|
2b91004129 | ||
|
|
96a045dc67 | ||
|
|
a8229f12d5 | ||
|
|
dd4ac5374a | ||
|
|
670e0b1d14 | ||
|
|
93fe247a19 | ||
|
|
32ebfde9ed | ||
|
|
32f0c4c4d4 | ||
|
|
6a520ef6b1 | ||
|
|
25d451839e | ||
|
|
fe8f1d4753 | ||
|
|
518b1124d8 | ||
|
|
8bdaf935ca | ||
|
|
0d636cbf3b | ||
|
|
82d047e1c2 | ||
|
|
674c8a6bee | ||
|
|
3acea813b6 | ||
|
|
e68954e03c | ||
|
|
296fbfee79 | ||
|
|
a55910c226 | ||
|
|
88c6dc6b4d | ||
|
|
03caa791ad | ||
|
|
577daaff03 | ||
|
|
f75680e8dd | ||
|
|
0b7f45c150 | ||
|
|
56ac8c0155 | ||
|
|
8ecb0f205a | ||
|
|
ea842ee46c | ||
|
|
11369ce792 | ||
|
|
78471c19d9 | ||
|
|
3e8b8cd011 | ||
|
|
9eb7aae7d0 | ||
|
|
e5afaa0c7c | ||
|
|
06bae3ba3e | ||
|
|
614ef3ffaf | ||
|
|
2c0c9e0ba4 | ||
|
|
a07304c555 | ||
|
|
ab2510ba1c | ||
|
|
4624c9add1 | ||
|
|
1b178dd2a8 | ||
|
|
203e8608a2 | ||
|
|
a9f38e6d72 | ||
|
|
2c594fb9f7 | ||
|
|
98be1a0405 | ||
|
|
2165d4a976 | ||
|
|
772f3b6750 | ||
|
|
e84e0abe8d | ||
|
|
37fab3ead6 | ||
|
|
fa200acbfd | ||
|
|
020caf4e68 | ||
|
|
896c694a85 | ||
|
|
990daf5786 | ||
|
|
c1197413db | ||
|
|
bf2b8a9b6e | ||
|
|
d6ecee7549 | ||
|
|
66bbf8ae17 | ||
|
|
6012d0ced8 | ||
|
|
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 | ||
|
|
268d83fb4b | ||
|
|
5a7672677d | ||
|
|
8d6b7c4ba9 | ||
|
|
d6e1797f68 | ||
|
|
0a1ae7ce84 | ||
|
|
7fe4d8ace4 | ||
|
|
bd0fcd5049 | ||
|
|
3e7d3385ec | ||
|
|
76db9d674b | ||
|
|
767e3b74d1 | ||
|
|
5e3da99db3 | ||
|
|
65a73ca99f | ||
|
|
b9c809e648 | ||
|
|
22e6fcb113 | ||
|
|
cddc676f7d | ||
|
|
c02d75789e | ||
|
|
edae79c255 | ||
|
|
8704b63b46 | ||
|
|
66bff3ac6f | ||
|
|
6484795c3c | ||
|
|
69119aeaa6 | ||
|
|
cc3dd28137 | ||
|
|
2e425061c3 | ||
|
|
13f5072110 | ||
|
|
d93c10e88e | ||
|
|
2f44cd3a15 | ||
|
|
b19cfce682 | ||
|
|
ae8cd423d9 | ||
|
|
0a827f3763 | ||
|
|
75fdec775b | ||
|
|
1ad0e63e9b | ||
|
|
d645db721b | ||
|
|
9bcb18e2b6 | ||
|
|
efa8f1f5fa | ||
|
|
9080d7a277 | ||
|
|
aff5d977ea | ||
|
|
9b778c9627 | ||
|
|
a87bcdc1b9 | ||
|
|
9420fde5bf | ||
|
|
45c8fe1cf3 | ||
|
|
ef177a3f92 | ||
|
|
0e68f64007 | ||
|
|
fa53ddfb0b | ||
|
|
b61741b414 | ||
|
|
04b8c45987 | ||
|
|
a65e34b41c | ||
|
|
d0e095c979 | ||
|
|
684a3d8662 | ||
|
|
6a2a4c0f05 | ||
|
|
f535eea085 | ||
|
|
09e9e03767 | ||
|
|
8d20fd4492 | ||
|
|
cc3d2497b7 | ||
|
|
8dc45d526f | ||
|
|
0069775ed4 | ||
|
|
50aec499c7 | ||
|
|
2b107af73c | ||
|
|
b4570f56b4 | ||
|
|
d0b80b77f5 | ||
|
|
c9ab32598a | ||
|
|
2f4f445ff0 | ||
|
|
b303398cd4 | ||
|
|
ad9e9c1619 | ||
|
|
b38fe2d8b4 | ||
|
|
ad197c9aad | ||
|
|
e40ea49031 |
@@ -0,0 +1,37 @@
|
|||||||
|
name: Deploy to Azure Static Web Apps
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }}
|
||||||
|
VITE_SUPABASE_ANON_KEY: ${{ vars.VITE_SUPABASE_ANON_KEY }}
|
||||||
|
run: bunx --bun vite build
|
||||||
|
|
||||||
|
# No hace falta instalar el CLI globalmente, usamos bunx
|
||||||
|
- name: Deploy to Azure Static Web Apps
|
||||||
|
env:
|
||||||
|
AZURE_SWA_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOYMENT_TOKEN }}
|
||||||
|
run: |
|
||||||
|
bunx @azure/static-web-apps-cli deploy ./dist \
|
||||||
|
--env production \
|
||||||
|
--deployment-token "$AZURE_SWA_DEPLOYMENT_TOKEN"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Ignora los problemas de imports de eslint
|
||||||
@@ -8,3 +8,4 @@ count.txt
|
|||||||
.nitro
|
.nitro
|
||||||
.tanstack
|
.tanstack
|
||||||
.wrangler
|
.wrangler
|
||||||
|
diff.txt
|
||||||
+16
-1
@@ -1,3 +1,18 @@
|
|||||||
package-lock.json
|
# Dependencias (Cajas viejas que no tocas)
|
||||||
|
node_modules
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
package-lock.json
|
||||||
yarn.lock
|
yarn.lock
|
||||||
|
|
||||||
|
# Builds (Lo que genera la computadora, no tú)
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.vite
|
||||||
|
|
||||||
|
# Archivos temporales o de sistema
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Archivos autogenerados (importante si usas librerías que crean código)
|
||||||
|
src/routeTree.gen.ts
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"bradlc.vscode-tailwindcss"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+23
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
// 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": [
|
||||||
|
{
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Launch Chrome against localhost",
|
||||||
|
"url": "http://localhost:3000",
|
||||||
|
"webRoot": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "msedge",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Launch Edge against localhost",
|
||||||
|
"url": "http://localhost:3000",
|
||||||
|
"webRoot": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+22
-1
@@ -7,5 +7,26 @@
|
|||||||
},
|
},
|
||||||
"files.readonlyInclude": {
|
"files.readonlyInclude": {
|
||||||
"**/routeTree.gen.ts": true
|
"**/routeTree.gen.ts": true
|
||||||
}
|
},
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
"eslint.validate": [
|
||||||
|
"javascript",
|
||||||
|
"javascriptreact",
|
||||||
|
"typescript",
|
||||||
|
"typescriptreact"
|
||||||
|
],
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
FROM oven/bun:1 AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN bun install
|
||||||
|
RUN bunx --bun vite build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
@@ -29,10 +29,8 @@ bun --bun run test
|
|||||||
|
|
||||||
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
|
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
|
||||||
|
|
||||||
|
|
||||||
## Linting & Formatting
|
## Linting & Formatting
|
||||||
|
|
||||||
|
|
||||||
This project uses [eslint](https://eslint.org/) and [prettier](https://prettier.io/) for linting and formatting. Eslint is configured using [tanstack/eslint-config](https://tanstack.com/config/latest/docs/eslint). The following scripts are available:
|
This project uses [eslint](https://eslint.org/) and [prettier](https://prettier.io/) for linting and formatting. Eslint is configured using [tanstack/eslint-config](https://tanstack.com/config/latest/docs/eslint). The following scripts are available:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -41,7 +39,6 @@ bun --bun run format
|
|||||||
bun --bun run check
|
bun --bun run check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Shadcn
|
## Shadcn
|
||||||
|
|
||||||
Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
|
Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
|
||||||
@@ -50,9 +47,8 @@ Add components using the latest version of [Shadcn](https://ui.shadcn.com/).
|
|||||||
pnpm dlx shadcn@latest add button
|
pnpm dlx shadcn@latest add button
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Routing
|
## Routing
|
||||||
|
|
||||||
This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`.
|
This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`.
|
||||||
|
|
||||||
### Adding A Route
|
### Adding A Route
|
||||||
@@ -68,7 +64,7 @@ Now that you have two routes you can use a `Link` component to navigate between
|
|||||||
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
|
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from '@tanstack/react-router'
|
||||||
```
|
```
|
||||||
|
|
||||||
Then anywhere in your JSX you can use it like so:
|
Then anywhere in your JSX you can use it like so:
|
||||||
@@ -91,7 +87,7 @@ Here is an example layout that includes a header:
|
|||||||
import { Outlet, createRootRoute } from '@tanstack/react-router'
|
import { Outlet, createRootRoute } from '@tanstack/react-router'
|
||||||
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
||||||
|
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: () => (
|
component: () => (
|
||||||
@@ -113,7 +109,6 @@ The `<TanStackRouterDevtools />` component is not required so you can remove it
|
|||||||
|
|
||||||
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
|
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
|
||||||
|
|
||||||
|
|
||||||
## Data Fetching
|
## Data Fetching
|
||||||
|
|
||||||
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
|
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
|
||||||
@@ -123,26 +118,26 @@ For example:
|
|||||||
```tsx
|
```tsx
|
||||||
const peopleRoute = createRoute({
|
const peopleRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/people",
|
path: '/people',
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
const response = await fetch("https://swapi.dev/api/people");
|
const response = await fetch('https://swapi.dev/api/people')
|
||||||
return response.json() as Promise<{
|
return response.json() as Promise<{
|
||||||
results: {
|
results: {
|
||||||
name: string;
|
name: string
|
||||||
}[];
|
}[]
|
||||||
}>;
|
}>
|
||||||
},
|
},
|
||||||
component: () => {
|
component: () => {
|
||||||
const data = peopleRoute.useLoaderData();
|
const data = peopleRoute.useLoaderData()
|
||||||
return (
|
return (
|
||||||
<ul>
|
<ul>
|
||||||
{data.results.map((person) => (
|
{data.results.map((person) => (
|
||||||
<li key={person.name}>{person.name}</li>
|
<li key={person.name}>{person.name}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
)
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
|
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
|
||||||
@@ -160,29 +155,29 @@ bun install @tanstack/react-query @tanstack/react-query-devtools
|
|||||||
Next we'll need to create a query client and provider. We recommend putting those in `main.tsx`.
|
Next we'll need to create a query client and provider. We recommend putting those in `main.tsx`.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
|
|
||||||
if (!rootElement.innerHTML) {
|
if (!rootElement.innerHTML) {
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement)
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>,
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also add TanStack Query Devtools to the root route (optional).
|
You can also add TanStack Query Devtools to the root route (optional).
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
component: () => (
|
component: () => (
|
||||||
@@ -192,25 +187,25 @@ const rootRoute = createRootRoute({
|
|||||||
<TanStackRouterDevtools />
|
<TanStackRouterDevtools />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
});
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
Now you can use `useQuery` to fetch your data.
|
Now you can use `useQuery` to fetch your data.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
import "./App.css";
|
import './App.css'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["people"],
|
queryKey: ['people'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetch("https://swapi.dev/api/people")
|
fetch('https://swapi.dev/api/people')
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => data.results as { name: string }[]),
|
.then((data) => data.results as { name: string }[]),
|
||||||
initialData: [],
|
initialData: [],
|
||||||
});
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -220,10 +215,10 @@ function App() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App
|
||||||
```
|
```
|
||||||
|
|
||||||
You can find out everything you need to know on how to use React-Query in the [React-Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview).
|
You can find out everything you need to know on how to use React-Query in the [React-Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview).
|
||||||
@@ -241,24 +236,24 @@ bun install @tanstack/store
|
|||||||
Now let's create a simple counter in the `src/App.tsx` file as a demonstration.
|
Now let's create a simple counter in the `src/App.tsx` file as a demonstration.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useStore } from "@tanstack/react-store";
|
import { useStore } from '@tanstack/react-store'
|
||||||
import { Store } from "@tanstack/store";
|
import { Store } from '@tanstack/store'
|
||||||
import "./App.css";
|
import './App.css'
|
||||||
|
|
||||||
const countStore = new Store(0);
|
const countStore = new Store(0)
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const count = useStore(countStore);
|
const count = useStore(countStore)
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button onClick={() => countStore.setState((n) => n + 1)}>
|
<button onClick={() => countStore.setState((n) => n + 1)}>
|
||||||
Increment - {count}
|
Increment - {count}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App
|
||||||
```
|
```
|
||||||
|
|
||||||
One of the many nice features of TanStack Store is the ability to derive state from other state. That derived state will update when the base state updates.
|
One of the many nice features of TanStack Store is the ability to derive state from other state. That derived state will update when the base state updates.
|
||||||
@@ -266,21 +261,21 @@ One of the many nice features of TanStack Store is the ability to derive state f
|
|||||||
Let's check this out by doubling the count using derived state.
|
Let's check this out by doubling the count using derived state.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useStore } from "@tanstack/react-store";
|
import { useStore } from '@tanstack/react-store'
|
||||||
import { Store, Derived } from "@tanstack/store";
|
import { Store, Derived } from '@tanstack/store'
|
||||||
import "./App.css";
|
import './App.css'
|
||||||
|
|
||||||
const countStore = new Store(0);
|
const countStore = new Store(0)
|
||||||
|
|
||||||
const doubledStore = new Derived({
|
const doubledStore = new Derived({
|
||||||
fn: () => countStore.state * 2,
|
fn: () => countStore.state * 2,
|
||||||
deps: [countStore],
|
deps: [countStore],
|
||||||
});
|
})
|
||||||
doubledStore.mount();
|
doubledStore.mount()
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const count = useStore(countStore);
|
const count = useStore(countStore)
|
||||||
const doubledCount = useStore(doubledStore);
|
const doubledCount = useStore(doubledStore)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -289,10 +284,10 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
<div>Doubled - {doubledCount}</div>
|
<div>Doubled - {doubledCount}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App
|
||||||
```
|
```
|
||||||
|
|
||||||
We use the `Derived` class to create a new store that is derived from another store. The `Derived` class has a `mount` method that will start the derived store updating.
|
We use the `Derived` class to create a new store that is derived from another store. The `Derived` class has a `mount` method that will start the derived store updating.
|
||||||
@@ -308,3 +303,20 @@ Files prefixed with `demo` can be safely deleted. They are there to provide a st
|
|||||||
# Learn More
|
# Learn More
|
||||||
|
|
||||||
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
|
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
|
||||||
|
|
||||||
|
# Explicación de los scripts de package.json
|
||||||
|
|
||||||
|
| Script | Comando Ejecutado | Qué hace exactamente |
|
||||||
|
| :----------------- | :----------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| **`dev`** | `vite --port 3000` | Inicia el servidor local de desarrollo forzando el puerto **3000** (http://localhost:3000). |
|
||||||
|
| **`build`** | `vite build && tsc` | Genera la versión optimizada para producción (`dist`) y después verifica errores de tipos. |
|
||||||
|
| **`preview`** | `vite preview` | Simula un servidor de producción localmente usando los archivos generados en la carpeta `dist`. |
|
||||||
|
| **`test`** | `vitest run` | Ejecuta las pruebas unitarias (tests) una sola vez y termina (no se queda observando cambios). |
|
||||||
|
| **`lint`** | `eslint .` | Ejecuta ESLint en **todos** los archivos (`.`) explícitamente. Busca errores pero no los corrige. |
|
||||||
|
| **`lint:fix`** | `eslint . --fix` | Ejecuta ESLint en todos los archivos (`.`) y **corrige automáticamente** todo lo posible (orden, limpieza). |
|
||||||
|
| **`format`** | `prettier` | Comando base de Prettier. |
|
||||||
|
| **`format:check`** | `prettier --check .` | Verifica si el código cumple el formato. Si está mal, da error. (Ideal para CI/CD). |
|
||||||
|
| **`format:fix`** | `prettier --write .` | Formatea (reescribe) todos los archivos del proyecto para que se vean bien. |
|
||||||
|
| **`check`** | `prettier --write . && eslint --fix` | **Combo de limpieza total:** Primero formatea todo el código y luego repara errores de linting. |
|
||||||
|
| **`typecheck`** | `tsc --noEmit` | Revisa la lógica y tipos de TypeScript en todo el proyecto sin generar archivos de salida. |
|
||||||
|
| **`ci:verify`** | `prettier --check . && eslint . && tsc --noEmit` | **Modo Auditoría:** Verifica formato, linter y tipos en un solo paso. Si algo falla, detiene el proceso. Ideal para CI/CD (GitHub Actions). |
|
||||||
|
|||||||
+7
-1
@@ -17,5 +17,11 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide"
|
"iconLibrary": "lucide",
|
||||||
|
"registries": {
|
||||||
|
"@shadcn-studio": "https://shadcnstudio.com/r/{name}.json",
|
||||||
|
"@ss-components": "https://shadcnstudio.com/r/components/{name}.json",
|
||||||
|
"@ss-blocks": "https://shadcnstudio.com/r/blocks/{name}.json",
|
||||||
|
"@ss-themes": "https://shadcnstudio.com/r/themes/{name}.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+134
-2
@@ -1,5 +1,137 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
import { tanstackConfig } from '@tanstack/eslint-config'
|
import { tanstackConfig } from '@tanstack/eslint-config'
|
||||||
|
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||||
|
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import unusedImports from 'eslint-plugin-unused-imports'
|
||||||
|
|
||||||
export default [...tanstackConfig]
|
export default [
|
||||||
|
// 1. IGNORAR ARCHIVOS (Global ignores)
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'**/node_modules/**',
|
||||||
|
'**/dist/**',
|
||||||
|
'**/.vite/**',
|
||||||
|
'**/pnpm-lock.yaml',
|
||||||
|
'**/package-lock.json',
|
||||||
|
'**/bun.lock', // Agregado bun.lock
|
||||||
|
'src/routeTree.gen.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. CONFIGURACIÓN BASE
|
||||||
|
...tanstackConfig,
|
||||||
|
|
||||||
|
// 3. TUS REGLAS Y CONFIGURACIÓN "PRO"
|
||||||
|
{
|
||||||
|
// Opcional: Puedes ser explícito sobre dónde aplicar esto
|
||||||
|
files: ['**/*.{ts,tsx,js,jsx}'],
|
||||||
|
plugins: {
|
||||||
|
'jsx-a11y': jsxA11y,
|
||||||
|
'unused-imports': unusedImports,
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
},
|
||||||
|
// Configuración robusta del Resolver (La versión de Copilot)
|
||||||
|
settings: {
|
||||||
|
'import/resolver': {
|
||||||
|
typescript: {
|
||||||
|
alwaysTryTypes: true, // Busca definiciones @types aunque no importes el archivo
|
||||||
|
project: ['./tsconfig.json'],
|
||||||
|
},
|
||||||
|
node: {
|
||||||
|
extensions: ['.ts', '.tsx', '.js', '.jsx'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// --- REGLAS DE ACCESIBILIDAD (A11Y) ---
|
||||||
|
// Activamos las recomendadas manualmente
|
||||||
|
...jsxA11y.configs.recommended.rules,
|
||||||
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
// --- ORDEN DE IMPORTS ---
|
||||||
|
'sort-imports': 'off', // Apagamos el nativo
|
||||||
|
'import/order': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
groups: [
|
||||||
|
'builtin',
|
||||||
|
'external',
|
||||||
|
'internal',
|
||||||
|
'parent',
|
||||||
|
'sibling',
|
||||||
|
'index',
|
||||||
|
'object',
|
||||||
|
'type',
|
||||||
|
],
|
||||||
|
'newlines-between': 'always',
|
||||||
|
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// --- LIMPIEZA DE IMPORTS NO USADOS ---
|
||||||
|
'no-unused-vars': 'off', // Apagamos la regla base de ESLint para que no choque
|
||||||
|
'unused-imports/no-unused-imports': 'error', // Si no se usa, marca error (y --fix lo borra)
|
||||||
|
'unused-imports/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
vars: 'all',
|
||||||
|
varsIgnorePattern: '^_', // Ignora variables que empiecen con _
|
||||||
|
args: 'after-used',
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 4. PARCHE PARA ARCHIVOS DE CONFIGURACIÓN
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
'eslint.config.js',
|
||||||
|
'vite.config.ts',
|
||||||
|
'prettier.config.js',
|
||||||
|
'*.config.js',
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// --- LA LISTA NUCLEAR (Apagamos todo lo que pida tipos) ---
|
||||||
|
'@typescript-eslint/await-thenable': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'off',
|
||||||
|
'@typescript-eslint/no-misused-promises': 'off',
|
||||||
|
'@typescript-eslint/no-unnecessary-condition': 'off',
|
||||||
|
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-call': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||||
|
'@typescript-eslint/no-unsafe-return': 'off',
|
||||||
|
'@typescript-eslint/non-nullable-type-assertion-style': 'off',
|
||||||
|
'@typescript-eslint/prefer-nullish-coalescing': 'off',
|
||||||
|
'@typescript-eslint/prefer-optional-chain': 'off',
|
||||||
|
'@typescript-eslint/prefer-promise-reject-errors': 'off',
|
||||||
|
'@typescript-eslint/prefer-reduce-type-parameter': 'off',
|
||||||
|
'@typescript-eslint/prefer-string-starts-ends-with': 'off',
|
||||||
|
'@typescript-eslint/require-await': 'off', // <--- La culpable de este último error
|
||||||
|
'@typescript-eslint/restrict-plus-operands': 'off',
|
||||||
|
'@typescript-eslint/restrict-template-expressions': 'off',
|
||||||
|
'@typescript-eslint/strict-boolean-expressions': 'off',
|
||||||
|
'@typescript-eslint/unbound-method': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 5. OVERRIDE: desactivar reglas para tipos generados por supabase
|
||||||
|
{
|
||||||
|
files: ['src/types/supabase.ts'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/naming-convention': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 6. PRETTIER AL FINAL
|
||||||
|
eslintConfigPrettier,
|
||||||
|
]
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-6
@@ -4,14 +4,37 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 3000",
|
"dev": "vite --port 3000",
|
||||||
"build": "vite build && tsc",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"lint": "eslint",
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
"format": "prettier",
|
"format": "prettier",
|
||||||
"check": "prettier --write . && eslint --fix"
|
"format:check": "prettier --check .",
|
||||||
|
"format:fix": "prettier --write .",
|
||||||
|
"check": "prettier --write . && eslint --fix",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"ci:verify": "prettier --check . && eslint . && tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/react": "^0.3.2",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@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",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@stepperize/react": "^5.1.9",
|
||||||
|
"@supabase/supabase-js": "^2.90.1",
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"@tanstack/react-devtools": "^0.7.0",
|
"@tanstack/react-devtools": "^0.7.0",
|
||||||
"@tanstack/react-query": "^5.66.5",
|
"@tanstack/react-query": "^5.66.5",
|
||||||
@@ -19,26 +42,44 @@
|
|||||||
"@tanstack/react-router": "^1.132.0",
|
"@tanstack/react-router": "^1.132.0",
|
||||||
"@tanstack/react-router-devtools": "^1.132.0",
|
"@tanstack/react-router-devtools": "^1.132.0",
|
||||||
"@tanstack/router-plugin": "^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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.544.0",
|
"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": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.0.6",
|
"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": {
|
"devDependencies": {
|
||||||
"@tanstack/devtools-vite": "^0.3.11",
|
"@tanstack/devtools-vite": "^0.3.11",
|
||||||
"@tanstack/eslint-config": "^0.3.0",
|
"@tanstack/eslint-config": "^0.3.0",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
|
"@types/bun": "^1.3.6",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/react": "^19.2.0",
|
"@types/react": "^19.2.0",
|
||||||
"@types/react-dom": "^19.2.0",
|
"@types/react-dom": "^19.2.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
|
"eslint-plugin-import": "^2.32.0",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-unused-imports": "^4.3.0",
|
||||||
"jsdom": "^27.0.0",
|
"jsdom": "^27.0.0",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"supabase": "^2.72.2",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^7.1.7",
|
"vite": "^7.1.7",
|
||||||
"vitest": "^3.0.5",
|
"vitest": "^3.0.5",
|
||||||
|
|||||||
+8
-4
@@ -1,10 +1,14 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
|
|
||||||
/** @type {import('prettier').Config} */
|
/** @type {import('prettier').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
semi: false,
|
semi: false,
|
||||||
singleQuote: true,
|
singleQuote: true,
|
||||||
trailingComma: "all",
|
trailingComma: 'all',
|
||||||
};
|
tabWidth: 2,
|
||||||
|
plugins: ['prettier-plugin-tailwindcss'],
|
||||||
|
tailwindFunctions: ['clsx', 'cn', 'cva'],
|
||||||
|
endOfLine: 'lf',
|
||||||
|
}
|
||||||
|
|
||||||
export default config;
|
export default config
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
@@ -0,0 +1,757 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<locale xmlns="http://purl.org/net/xbiblio/csl" version="1.0" xml:lang="es-MX">
|
||||||
|
<info>
|
||||||
|
<translator>
|
||||||
|
<name>Juan Ignacio Flores Salgado</name>
|
||||||
|
<uri>https://www.mendeley.com/profiles/juan-ignacio-flores-salgado/</uri>
|
||||||
|
</translator>
|
||||||
|
<rights license="http://creativecommons.org/licenses/by-sa/3.0/">This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 License</rights>
|
||||||
|
<updated>2025-10-16T03:24:00+00:00</updated>
|
||||||
|
</info>
|
||||||
|
<style-options punctuation-in-quote="false"/>
|
||||||
|
<date form="text">
|
||||||
|
<date-part name="day" prefix="el " suffix=" de "/>
|
||||||
|
<date-part name="month" suffix=" de "/>
|
||||||
|
<date-part name="year"/>
|
||||||
|
</date>
|
||||||
|
<date form="numeric">
|
||||||
|
<date-part name="day" form="numeric-leading-zeros" suffix="/"/>
|
||||||
|
<date-part name="month" form="numeric-leading-zeros" suffix="/"/>
|
||||||
|
<date-part name="year"/>
|
||||||
|
</date>
|
||||||
|
<terms>
|
||||||
|
<!-- LONG GENERAL TERMS -->
|
||||||
|
<term name="accessed">consultado</term>
|
||||||
|
<term name="advance-online-publication">advance online publication</term>
|
||||||
|
<term name="album">album</term>
|
||||||
|
<term name="and">y</term>
|
||||||
|
<term name="and others">et al.</term>
|
||||||
|
<term name="anonymous">anónimo</term>
|
||||||
|
<term name="at">en</term>
|
||||||
|
<term name="audio-recording">audio recording</term>
|
||||||
|
<term name="available at">disponible en</term>
|
||||||
|
<term name="by">de</term>
|
||||||
|
<term name="circa">circa</term>
|
||||||
|
<term name="cited">citado</term>
|
||||||
|
<term name="et-al">et al.</term>
|
||||||
|
<term name="film">film</term>
|
||||||
|
<term name="forthcoming">en preparación</term>
|
||||||
|
<term name="from">a partir de</term>
|
||||||
|
<term name="henceforth">henceforth</term>
|
||||||
|
<term name="ibid">ibid.</term>
|
||||||
|
<term name="in">en</term>
|
||||||
|
<term name="in press">en imprenta</term>
|
||||||
|
<term name="internet">internet</term>
|
||||||
|
<term name="letter">carta</term>
|
||||||
|
<term name="loc-cit">loc. cit.</term> <!-- like ibid., the abbreviated form is the regular form -->
|
||||||
|
<term name="no date">sin fecha</term>
|
||||||
|
<term name="no-place">no place</term>
|
||||||
|
<term name="no-publisher">no publisher</term> <!-- sine nomine -->
|
||||||
|
<term name="on">on</term>
|
||||||
|
<term name="online">en línea</term>
|
||||||
|
<term name="op-cit">op. cit.</term> <!-- like ibid., the abbreviated form is the regular form -->
|
||||||
|
<term name="original-work-published">obra original publicada en</term>
|
||||||
|
<term name="personal-communication">comunicación personal</term>
|
||||||
|
<term name="podcast">podcast</term>
|
||||||
|
<term name="podcast-episode">podcast episode</term>
|
||||||
|
<term name="preprint">preprint</term>
|
||||||
|
<term name="presented at">presentado en</term>
|
||||||
|
<term name="radio-broadcast">radio broadcast</term>
|
||||||
|
<term name="radio-series">radio series</term>
|
||||||
|
<term name="radio-series-episode">radio series episode</term>
|
||||||
|
<term name="reference">
|
||||||
|
<single>referencia</single>
|
||||||
|
<multiple>referencias</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="retrieved">recuperado</term>
|
||||||
|
<term name="review-of">review of</term>
|
||||||
|
<term name="scale">escala</term>
|
||||||
|
<term name="special-issue">special issue</term>
|
||||||
|
<term name="special-section">special section</term>
|
||||||
|
<term name="television-broadcast">television broadcast</term>
|
||||||
|
<term name="television-series">television series</term>
|
||||||
|
<term name="television-series-episode">television series episode</term>
|
||||||
|
<term name="video">video</term>
|
||||||
|
<term name="working-paper">working paper</term>
|
||||||
|
|
||||||
|
<!-- SHORT GENERAL TERMS -->
|
||||||
|
<term name="anonymous" form="short">anón.</term>
|
||||||
|
<term name="circa" form="short">c.</term>
|
||||||
|
<term name="no date" form="short">s/f</term>
|
||||||
|
<term name="no-place" form="short">n.p.</term>
|
||||||
|
<term name="no-publisher" form="short">n.p.</term>
|
||||||
|
<term name="reference" form="short">
|
||||||
|
<single>ref.</single>
|
||||||
|
<multiple>refs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="review-of" form="short">rev. of</term>
|
||||||
|
|
||||||
|
<!-- SYMBOLIC GENERAL FORMS -->
|
||||||
|
|
||||||
|
<!-- LONG ITEM TYPE FORMS -->
|
||||||
|
<term name="article">preprint</term>
|
||||||
|
<term name="article-journal">journal article</term>
|
||||||
|
<term name="article-magazine">magazine article</term>
|
||||||
|
<term name="article-newspaper">newspaper article</term>
|
||||||
|
<term name="bill">bill</term>
|
||||||
|
<!-- book is in the list of locator terms -->
|
||||||
|
<term name="broadcast">broadcast</term>
|
||||||
|
<!-- chapter is in the list of locator terms -->
|
||||||
|
<term name="classic">classic</term>
|
||||||
|
<term name="collection">collection</term>
|
||||||
|
<term name="dataset">dataset</term>
|
||||||
|
<term name="document">document</term>
|
||||||
|
<term name="entry">entry</term>
|
||||||
|
<term name="entry-dictionary">dictionary entry</term>
|
||||||
|
<term name="entry-encyclopedia">encyclopedia entry</term>
|
||||||
|
<term name="event">event</term>
|
||||||
|
<!-- figure is in the list of locator terms -->
|
||||||
|
<term name="graphic">graphic</term>
|
||||||
|
<term name="hearing">hearing</term>
|
||||||
|
<term name="interview">entrevista</term>
|
||||||
|
<term name="legal_case">legal case</term>
|
||||||
|
<term name="legislation">legislation</term>
|
||||||
|
<term name="manuscript">manuscript</term>
|
||||||
|
<term name="map">map</term>
|
||||||
|
<term name="motion_picture">video recording</term>
|
||||||
|
<term name="musical_score">musical score</term>
|
||||||
|
<term name="pamphlet">pamphlet</term>
|
||||||
|
<term name="paper-conference">conference paper</term>
|
||||||
|
<term name="patent">patent</term>
|
||||||
|
<term name="performance">performance</term>
|
||||||
|
<term name="periodical">periodical</term>
|
||||||
|
<term name="personal_communication">comunicación personal</term>
|
||||||
|
<term name="post">post</term>
|
||||||
|
<term name="post-weblog">blog post</term>
|
||||||
|
<term name="regulation">regulation</term>
|
||||||
|
<term name="report">report</term>
|
||||||
|
<term name="review">review</term>
|
||||||
|
<term name="review-book">book review</term>
|
||||||
|
<term name="software">software</term>
|
||||||
|
<term name="song">audio recording</term>
|
||||||
|
<term name="speech">presentation</term>
|
||||||
|
<term name="standard">standard</term>
|
||||||
|
<term name="thesis">thesis</term>
|
||||||
|
<term name="treaty">treaty</term>
|
||||||
|
<term name="webpage">webpage</term>
|
||||||
|
|
||||||
|
<!-- SHORT ITEM TYPE FORMS -->
|
||||||
|
<term name="article-journal" form="short">journal art.</term>
|
||||||
|
<term name="article-magazine" form="short">mag. art.</term>
|
||||||
|
<term name="article-newspaper" form="short">newspaper art.</term>
|
||||||
|
<!-- book is in the list of locator terms -->
|
||||||
|
<!-- chapter is in the list of locator terms -->
|
||||||
|
<term name="document" form="short">doc.</term>
|
||||||
|
<!-- figure is in the list of locator terms -->
|
||||||
|
<term name="graphic" form="short">graph.</term>
|
||||||
|
<term name="interview" form="short">interv.</term>
|
||||||
|
<term name="manuscript" form="short">MS</term>
|
||||||
|
<term name="motion_picture" form="short">video rec.</term>
|
||||||
|
<term name="report" form="short">rep.</term>
|
||||||
|
<term name="review" form="short">rev.</term>
|
||||||
|
<term name="review-book" form="short">bk. rev.</term>
|
||||||
|
<term name="song" form="short">audio rec.</term>
|
||||||
|
|
||||||
|
<!-- LONG VERB ITEM TYPE FORMS -->
|
||||||
|
<!-- Only where applicable -->
|
||||||
|
<term name="hearing" form="verb">testimony of</term>
|
||||||
|
<term name="review" form="verb">review of</term>
|
||||||
|
<term name="review-book" form="verb">review of the book</term>
|
||||||
|
|
||||||
|
<!-- SHORT VERB ITEM TYPE FORMS -->
|
||||||
|
|
||||||
|
<!-- HISTORICAL ERA TERMS -->
|
||||||
|
<term name="ad">d. C.</term>
|
||||||
|
<term name="bc">a. C.</term>
|
||||||
|
<term name="bce">BCE</term>
|
||||||
|
<term name="ce">CE</term>
|
||||||
|
|
||||||
|
<!-- PUNCTUATION -->
|
||||||
|
<term name="open-quote">“</term>
|
||||||
|
<term name="close-quote">”</term>
|
||||||
|
<term name="open-inner-quote">‘</term>
|
||||||
|
<term name="close-inner-quote">’</term>
|
||||||
|
<term name="page-range-delimiter">–</term>
|
||||||
|
<term name="colon">:</term>
|
||||||
|
<term name="comma">,</term>
|
||||||
|
<term name="semicolon">;</term>
|
||||||
|
|
||||||
|
<!-- ORDINALS -->
|
||||||
|
<term name="ordinal">a</term>
|
||||||
|
<term name="ordinal-01" gender-form="feminine" match="whole-number">a</term>
|
||||||
|
<term name="ordinal-01" gender-form="masculine" match="whole-number">o</term>
|
||||||
|
|
||||||
|
<!-- LONG ORDINALS -->
|
||||||
|
<term name="long-ordinal-01">primera</term>
|
||||||
|
<term name="long-ordinal-02">segunda</term>
|
||||||
|
<term name="long-ordinal-03">tercera</term>
|
||||||
|
<term name="long-ordinal-04">cuarta</term>
|
||||||
|
<term name="long-ordinal-05">quinta</term>
|
||||||
|
<term name="long-ordinal-06">sexta</term>
|
||||||
|
<term name="long-ordinal-07">séptima</term>
|
||||||
|
<term name="long-ordinal-08">octava</term>
|
||||||
|
<term name="long-ordinal-09">novena</term>
|
||||||
|
<term name="long-ordinal-10">décima</term>
|
||||||
|
|
||||||
|
<!-- LONG LOCATOR FORMS -->
|
||||||
|
<term name="act">
|
||||||
|
<single>act</single>
|
||||||
|
<multiple>acts</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="appendix">
|
||||||
|
<single>appendix</single>
|
||||||
|
<multiple>appendices</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="article-locator">
|
||||||
|
<single>article</single>
|
||||||
|
<multiple>articles</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="book">
|
||||||
|
<single>libro</single>
|
||||||
|
<multiple>libros</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="canon">
|
||||||
|
<single>canon</single>
|
||||||
|
<multiple>canons</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="chapter">
|
||||||
|
<single>capítulo</single>
|
||||||
|
<multiple>capítulos</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="column">
|
||||||
|
<single>columna</single>
|
||||||
|
<multiple>columnas</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="elocation">
|
||||||
|
<single>location</single>
|
||||||
|
<multiple>locations</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="equation">
|
||||||
|
<single>equation</single>
|
||||||
|
<multiple>equations</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="figure">
|
||||||
|
<single>figura</single>
|
||||||
|
<multiple>figuras</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="folio">
|
||||||
|
<single>folio</single>
|
||||||
|
<multiple>folios</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="issue">
|
||||||
|
<single>número</single>
|
||||||
|
<multiple>números</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="line">
|
||||||
|
<single>línea</single>
|
||||||
|
<multiple>líneas</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="note">
|
||||||
|
<single>nota</single>
|
||||||
|
<multiple>notas</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="opus">
|
||||||
|
<single>opus</single>
|
||||||
|
<multiple>opera</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="page">
|
||||||
|
<single>página</single>
|
||||||
|
<multiple>páginas</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="paragraph">
|
||||||
|
<single>párrafo</single>
|
||||||
|
<multiple>párrafos</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="part">
|
||||||
|
<single>parte</single>
|
||||||
|
<multiple>partes</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="rule">
|
||||||
|
<single>rule</single>
|
||||||
|
<multiple>rules</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="scene">
|
||||||
|
<single>scene</single>
|
||||||
|
<multiple>scenes</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="section">
|
||||||
|
<single>sección</single>
|
||||||
|
<multiple>secciones</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="sub-verbo">
|
||||||
|
<single>sub voce</single>
|
||||||
|
<multiple>sub vocibus</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="supplement">
|
||||||
|
<single>supplement</single>
|
||||||
|
<multiple>supplements</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="table">
|
||||||
|
<single>table</single>
|
||||||
|
<multiple>tables</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="timestamp"> <!-- generally blank -->
|
||||||
|
<single/>
|
||||||
|
<multiple/>
|
||||||
|
</term>
|
||||||
|
<term name="title-locator">
|
||||||
|
<single>title</single>
|
||||||
|
<multiple>titles</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="verse">
|
||||||
|
<single>verso</single>
|
||||||
|
<multiple>versos</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="volume">
|
||||||
|
<single>volumen</single>
|
||||||
|
<multiple>volúmenes</multiple>
|
||||||
|
</term>
|
||||||
|
|
||||||
|
<!-- SHORT LOCATOR FORMS -->
|
||||||
|
<term name="appendix" form="short">
|
||||||
|
<single>app.</single>
|
||||||
|
<multiple>apps.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="article-locator" form="short">
|
||||||
|
<single>art.</single>
|
||||||
|
<multiple>arts.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="book" form="short">
|
||||||
|
<single>lib.</single>
|
||||||
|
<multiple>libs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="chapter" form="short">
|
||||||
|
<single>cap.</single>
|
||||||
|
<multiple>caps.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="column" form="short">
|
||||||
|
<single>col.</single>
|
||||||
|
<multiple>cols.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="elocation" form="short">
|
||||||
|
<single>loc.</single>
|
||||||
|
<multiple>locs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="equation" form="short">
|
||||||
|
<single>eq.</single>
|
||||||
|
<multiple>eqs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="figure" form="short">
|
||||||
|
<single>fig.</single>
|
||||||
|
<multiple>figs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="folio" form="short">
|
||||||
|
<single>f.</single>
|
||||||
|
<multiple>ff.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="issue" form="short">
|
||||||
|
<single>núm.</single>
|
||||||
|
<multiple>núms.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="line" form="short">
|
||||||
|
<single>l.</single>
|
||||||
|
<multiple>ls.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="note" form="short">
|
||||||
|
<single>n.</single>
|
||||||
|
<multiple>nn.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="opus" form="short">
|
||||||
|
<single>op.</single>
|
||||||
|
<multiple>opp.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="page" form="short">
|
||||||
|
<single>p.</single>
|
||||||
|
<multiple>pp.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="paragraph" form="short">
|
||||||
|
<single>párr.</single>
|
||||||
|
<multiple>párrs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="part" form="short">
|
||||||
|
<single>pt.</single>
|
||||||
|
<multiple>pts.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="rule" form="short">
|
||||||
|
<single>r.</single>
|
||||||
|
<multiple>rr.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="scene" form="short">
|
||||||
|
<single>sc.</single>
|
||||||
|
<multiple>scs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="section" form="short">
|
||||||
|
<single>sec.</single>
|
||||||
|
<multiple>secs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="sub-verbo" form="short">
|
||||||
|
<single>s. v.</single>
|
||||||
|
<multiple>s. vv.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="supplement" form="short">
|
||||||
|
<single>supp.</single>
|
||||||
|
<multiple>supps.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="table" form="short">
|
||||||
|
<single>tbl.</single>
|
||||||
|
<multiple>tbls.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="timestamp" form="short"> <!-- generally blank -->
|
||||||
|
<single/>
|
||||||
|
<multiple/>
|
||||||
|
</term>
|
||||||
|
<term name="title-locator" form="short">
|
||||||
|
<single>tit.</single>
|
||||||
|
<multiple>tits.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="verse" form="short">
|
||||||
|
<single>v.</single>
|
||||||
|
<multiple>vv.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="volume" form="short">
|
||||||
|
<single>vol.</single>
|
||||||
|
<multiple>vols.</multiple>
|
||||||
|
</term>
|
||||||
|
|
||||||
|
<!-- SYMBOLIC LOCATOR FORMS -->
|
||||||
|
<term name="paragraph" form="symbol">
|
||||||
|
<single>¶</single>
|
||||||
|
<multiple>¶</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="section" form="symbol">
|
||||||
|
<single>§</single>
|
||||||
|
<multiple>§</multiple>
|
||||||
|
</term>
|
||||||
|
|
||||||
|
<!-- LONG NUMBER VARIABLE FORMS -->
|
||||||
|
<term name="chapter-number">
|
||||||
|
<single>chapter</single>
|
||||||
|
<multiple>chapters</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="citation-number">
|
||||||
|
<single>citation</single>
|
||||||
|
<multiple>citations</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="collection-number">
|
||||||
|
<single>número</single>
|
||||||
|
<multiple>números</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="edition">
|
||||||
|
<single>edición</single>
|
||||||
|
<multiple>ediciones</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="first-reference-note-number">
|
||||||
|
<single>reference</single>
|
||||||
|
<multiple>references</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="number">
|
||||||
|
<single>number</single>
|
||||||
|
<multiple>numbers</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="number-of-pages">
|
||||||
|
<single>página</single>
|
||||||
|
<multiple>páginas</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="number-of-volumes">
|
||||||
|
<single>volume</single>
|
||||||
|
<multiple>volumes</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="page-first">
|
||||||
|
<single>page</single>
|
||||||
|
<multiple>pages</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="printing">
|
||||||
|
<single>printing</single>
|
||||||
|
<multiple>printings</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="version">versión</term>
|
||||||
|
|
||||||
|
<!-- SHORT NUMBER VARIABLE FORMS -->
|
||||||
|
<term name="chapter-number" form="short">
|
||||||
|
<single>chap.</single>
|
||||||
|
<multiple>chaps.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="citation-number" form="short">
|
||||||
|
<single>cit.</single>
|
||||||
|
<multiple>cits.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="collection-number" form="short">
|
||||||
|
<single>núm.</single>
|
||||||
|
<multiple>núms.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="edition" form="short">
|
||||||
|
<single>ed.</single>
|
||||||
|
<multiple>eds.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="first-reference-note-number" form="short">
|
||||||
|
<single>ref.</single>
|
||||||
|
<multiple>refs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="number" form="short">
|
||||||
|
<single>no.</single>
|
||||||
|
<multiple>nos.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="number-of-pages" form="short">
|
||||||
|
<single>p.</single>
|
||||||
|
<multiple>pp.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="number-of-volumes" form="short">
|
||||||
|
<single>vol.</single>
|
||||||
|
<multiple>vols.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="page-first" form="short">
|
||||||
|
<single>p.</single>
|
||||||
|
<multiple>pp.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="printing" form="short">
|
||||||
|
<single>print.</single>
|
||||||
|
<multiple>prints.</multiple>
|
||||||
|
</term>
|
||||||
|
|
||||||
|
<!-- LONG ROLE FORMS -->
|
||||||
|
<term name="author"/> <!-- generally blank -->
|
||||||
|
<term name="chair">
|
||||||
|
<single>chair</single>
|
||||||
|
<multiple>chairs</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="collection-editor">
|
||||||
|
<single>ed.</single>
|
||||||
|
<multiple>eds.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="compiler">
|
||||||
|
<single>compiler</single>
|
||||||
|
<multiple>compilers</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="composer"/> <!-- generally blank -->
|
||||||
|
<term name="container-author"/> <!-- generally blank -->
|
||||||
|
<term name="contributor">
|
||||||
|
<single>contributor</single>
|
||||||
|
<multiple>contributors</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="curator">
|
||||||
|
<single>curator</single>
|
||||||
|
<multiple>curators</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="director">
|
||||||
|
<single>director</single>
|
||||||
|
<multiple>directores</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="editor">
|
||||||
|
<single>editor</single>
|
||||||
|
<multiple>editores</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="editor-translator">
|
||||||
|
<single>editor y traductor</single>
|
||||||
|
<multiple>editores y traductores</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="editortranslator">
|
||||||
|
<single>editor y traductor</single>
|
||||||
|
<multiple>editores y traductores</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="editorial-director">
|
||||||
|
<single>coordinador</single>
|
||||||
|
<multiple>coordinadores</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="executive-producer">
|
||||||
|
<single>executive producer</single>
|
||||||
|
<multiple>executive producers</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="guest">
|
||||||
|
<single>guest</single>
|
||||||
|
<multiple>guests</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="host">
|
||||||
|
<single>host</single>
|
||||||
|
<multiple>hosts</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="illustrator">
|
||||||
|
<single>ilustrador</single>
|
||||||
|
<multiple>ilustradores</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="interviewer"/> <!-- generally blank -->
|
||||||
|
<term name="narrator">
|
||||||
|
<single>narrator</single>
|
||||||
|
<multiple>narrators</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="organizer">
|
||||||
|
<single>organizer</single>
|
||||||
|
<multiple>organizers</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="original-author"/> <!-- generally blank -->
|
||||||
|
<term name="performer">
|
||||||
|
<single>performer</single>
|
||||||
|
<multiple>performers</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="producer">
|
||||||
|
<single>producer</single>
|
||||||
|
<multiple>producers</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="recipient"/> <!-- generally blank -->
|
||||||
|
<term name="reviewed-author"/> <!-- generally blank -->
|
||||||
|
<term name="script-writer">
|
||||||
|
<single>writer</single>
|
||||||
|
<multiple>writers</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="series-creator">
|
||||||
|
<single>series creator</single>
|
||||||
|
<multiple>series creators</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="translator">
|
||||||
|
<single>traductor</single>
|
||||||
|
<multiple>traductores</multiple>
|
||||||
|
</term>
|
||||||
|
|
||||||
|
<!-- SHORT ROLE FORMS -->
|
||||||
|
<term name="compiler" form="short">
|
||||||
|
<single>comp.</single>
|
||||||
|
<multiple>comps.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="contributor" form="short">
|
||||||
|
<single>contrib.</single>
|
||||||
|
<multiple>contribs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="curator" form="short">
|
||||||
|
<single>cur.</single>
|
||||||
|
<multiple>curs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="director" form="short">
|
||||||
|
<single>dir.</single>
|
||||||
|
<multiple>dirs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="editor" form="short">
|
||||||
|
<single>ed.</single>
|
||||||
|
<multiple>eds.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="editor-translator" form="short">
|
||||||
|
<single>ed. y trad.</single>
|
||||||
|
<multiple>eds. y trads.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="editortranslator" form="short">
|
||||||
|
<single>ed. y trad.</single>
|
||||||
|
<multiple>eds. y trads.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="editorial-director" form="short">
|
||||||
|
<single>coord.</single>
|
||||||
|
<multiple>coords.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="executive-producer" form="short">
|
||||||
|
<single>exec. prod.</single>
|
||||||
|
<multiple>exec. prods.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="illustrator" form="short">
|
||||||
|
<single>ilust.</single>
|
||||||
|
<multiple>ilusts.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="narrator" form="short">
|
||||||
|
<single>narr.</single>
|
||||||
|
<multiple>narrs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="organizer" form="short">
|
||||||
|
<single>org.</single>
|
||||||
|
<multiple>orgs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="performer" form="short">
|
||||||
|
<single>perf.</single>
|
||||||
|
<multiple>perfs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="producer" form="short">
|
||||||
|
<single>prod.</single>
|
||||||
|
<multiple>prods.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="script-writer" form="short">
|
||||||
|
<single>writ.</single>
|
||||||
|
<multiple>writs.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="series-creator" form="short">
|
||||||
|
<single>cre.</single>
|
||||||
|
<multiple>cres.</multiple>
|
||||||
|
</term>
|
||||||
|
<term name="translator" form="short">
|
||||||
|
<single>trad.</single>
|
||||||
|
<multiple>trads.</multiple>
|
||||||
|
</term>
|
||||||
|
|
||||||
|
<!-- VERB ROLE FORMS -->
|
||||||
|
<term name="chair" form="verb">chaired by</term>
|
||||||
|
<term name="collection-editor" form="verb">edited by</term>
|
||||||
|
<term name="compiler" form="verb">compiled by</term>
|
||||||
|
<term name="container-author" form="verb">de</term>
|
||||||
|
<term name="contributor" form="verb">with</term>
|
||||||
|
<term name="curator" form="verb">curated by</term>
|
||||||
|
<term name="director" form="verb">dirigido por</term>
|
||||||
|
<term name="editor" form="verb">editado por</term>
|
||||||
|
<term name="editor-translator" form="verb">editado y traducido por</term>
|
||||||
|
<term name="editortranslator" form="verb">editado y traducido por</term>
|
||||||
|
<term name="editorial-director" form="verb">coordinado por</term>
|
||||||
|
<term name="executive-producer" form="verb">executive produced by</term>
|
||||||
|
<term name="guest" form="verb">with guest</term>
|
||||||
|
<term name="host" form="verb">hosted by</term>
|
||||||
|
<term name="illustrator" form="verb">ilustrado por</term>
|
||||||
|
<term name="interviewer" form="verb">entrevistado por</term>
|
||||||
|
<term name="narrator" form="verb">narrated by</term>
|
||||||
|
<term name="organizer" form="verb">organized by</term>
|
||||||
|
<term name="performer" form="verb">performed by</term>
|
||||||
|
<term name="producer" form="verb">produced by</term>
|
||||||
|
<term name="recipient" form="verb">a</term>
|
||||||
|
<term name="reviewed-author" form="verb">por</term>
|
||||||
|
<term name="script-writer" form="verb">written by</term>
|
||||||
|
<term name="series-creator" form="verb">created by</term>
|
||||||
|
<term name="translator" form="verb">traducido por</term>
|
||||||
|
|
||||||
|
<!-- SHORT VERB ROLE FORMS -->
|
||||||
|
<term name="collection-editor" form="verb-short">ed. by</term>
|
||||||
|
<term name="compiler" form="verb-short">comp. by</term>
|
||||||
|
<term name="contributor" form="verb-short">w.</term>
|
||||||
|
<term name="curator" form="verb-short">cur. by</term>
|
||||||
|
<term name="director" form="verb-short">dir.</term>
|
||||||
|
<term name="editor" form="verb-short">ed.</term>
|
||||||
|
<term name="editor-translator" form="verb-short">ed. y trad.</term>
|
||||||
|
<term name="editortranslator" form="verb-short">ed. y trad.</term>
|
||||||
|
<term name="editorial-director" form="verb-short">coord.</term>
|
||||||
|
<term name="executive-producer" form="verb-short">exec. prod. by</term>
|
||||||
|
<term name="guest" form="verb-short">w. guest</term>
|
||||||
|
<term name="host" form="verb-short">hosted by</term>
|
||||||
|
<term name="illustrator" form="verb-short">ilust.</term>
|
||||||
|
<term name="narrator" form="verb-short">narr. by</term>
|
||||||
|
<term name="organizer" form="verb-short">org. by</term>
|
||||||
|
<term name="performer" form="verb-short">perf. by</term>
|
||||||
|
<term name="producer" form="verb-short">prod. by</term>
|
||||||
|
<term name="script-writer" form="verb-short">writ. by</term>
|
||||||
|
<term name="series-creator" form="verb-short">cre. by</term>
|
||||||
|
<term name="translator" form="verb-short">trad.</term>
|
||||||
|
|
||||||
|
<!-- LONG MONTH FORMS -->
|
||||||
|
<term name="month-01">enero</term>
|
||||||
|
<term name="month-02">febrero</term>
|
||||||
|
<term name="month-03">marzo</term>
|
||||||
|
<term name="month-04">abril</term>
|
||||||
|
<term name="month-05">mayo</term>
|
||||||
|
<term name="month-06">junio</term>
|
||||||
|
<term name="month-07">julio</term>
|
||||||
|
<term name="month-08">agosto</term>
|
||||||
|
<term name="month-09">septiembre</term>
|
||||||
|
<term name="month-10">octubre</term>
|
||||||
|
<term name="month-11">noviembre</term>
|
||||||
|
<term name="month-12">diciembre</term>
|
||||||
|
|
||||||
|
<!-- SHORT MONTH FORMS -->
|
||||||
|
<term name="month-01" form="short">ene.</term>
|
||||||
|
<term name="month-02" form="short">feb.</term>
|
||||||
|
<term name="month-03" form="short">mar.</term>
|
||||||
|
<term name="month-04" form="short">abr.</term>
|
||||||
|
<term name="month-05" form="short">may</term>
|
||||||
|
<term name="month-06" form="short">jun.</term>
|
||||||
|
<term name="month-07" form="short">jul.</term>
|
||||||
|
<term name="month-08" form="short">ago.</term>
|
||||||
|
<term name="month-09" form="short">sep.</term>
|
||||||
|
<term name="month-10" form="short">oct.</term>
|
||||||
|
<term name="month-11" form="short">nov.</term>
|
||||||
|
<term name="month-12" form="short">dic.</term>
|
||||||
|
|
||||||
|
<!-- SEASONS -->
|
||||||
|
<term name="season-01">primavera</term>
|
||||||
|
<term name="season-02">verano</term>
|
||||||
|
<term name="season-03">otoño</term>
|
||||||
|
<term name="season-04">invierno</term>
|
||||||
|
</terms>
|
||||||
|
</locale>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,519 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<style xmlns="http://purl.org/net/xbiblio/csl" class="in-text" version="1.0" demote-non-dropping-particle="sort-only">
|
||||||
|
<info>
|
||||||
|
<title>IEEE Reference Guide version 11.29.2023</title>
|
||||||
|
<title-short>Institute of Electrical and Electronics Engineers</title-short>
|
||||||
|
<id>http://www.zotero.org/styles/ieee</id>
|
||||||
|
<link href="http://www.zotero.org/styles/ieee" rel="self"/>
|
||||||
|
<link href="https://journals.ieeeauthorcenter.ieee.org/your-role-in-article-production/ieee-editorial-style-manual/" rel="documentation"/>
|
||||||
|
<author>
|
||||||
|
<name>Michael Berkowitz</name>
|
||||||
|
<email>mberkowi@gmu.edu</email>
|
||||||
|
</author>
|
||||||
|
<contributor>
|
||||||
|
<name>Julian Onions</name>
|
||||||
|
<email>julian.onions@gmail.com</email>
|
||||||
|
</contributor>
|
||||||
|
<contributor>
|
||||||
|
<name>Rintze Zelle</name>
|
||||||
|
<uri>http://twitter.com/rintzezelle</uri>
|
||||||
|
</contributor>
|
||||||
|
<contributor>
|
||||||
|
<name>Stephen Frank</name>
|
||||||
|
<uri>http://www.zotero.org/sfrank</uri>
|
||||||
|
</contributor>
|
||||||
|
<contributor>
|
||||||
|
<name>Sebastian Karcher</name>
|
||||||
|
</contributor>
|
||||||
|
<contributor>
|
||||||
|
<name>Giuseppe Silano</name>
|
||||||
|
<email>g.silano89@gmail.com</email>
|
||||||
|
<uri>http://giuseppesilano.net</uri>
|
||||||
|
</contributor>
|
||||||
|
<contributor>
|
||||||
|
<name>Patrick O'Brien</name>
|
||||||
|
</contributor>
|
||||||
|
<contributor>
|
||||||
|
<name>Brenton M. Wiernik</name>
|
||||||
|
</contributor>
|
||||||
|
<contributor>
|
||||||
|
<name>Oliver Couch</name>
|
||||||
|
<email>oliver.couch@gmail.com</email>
|
||||||
|
</contributor>
|
||||||
|
<contributor>
|
||||||
|
<name>Andrew Dunning</name>
|
||||||
|
<uri>https://orcid.org/0000-0003-0464-5036</uri>
|
||||||
|
</contributor>
|
||||||
|
<category citation-format="numeric"/>
|
||||||
|
<category field="engineering"/>
|
||||||
|
<category field="generic-base"/>
|
||||||
|
<summary>IEEE style as per the 2023 guidelines.</summary>
|
||||||
|
<updated>2024-03-27T11:41:27+00:00</updated>
|
||||||
|
<rights license="http://creativecommons.org/licenses/by-sa/3.0/">This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 License</rights>
|
||||||
|
</info>
|
||||||
|
<locale xml:lang="en">
|
||||||
|
<date form="text">
|
||||||
|
<date-part name="month" form="short" suffix=" "/>
|
||||||
|
<date-part name="day" form="numeric-leading-zeros" suffix=", "/>
|
||||||
|
<date-part name="year"/>
|
||||||
|
</date>
|
||||||
|
<terms>
|
||||||
|
<term name="chapter" form="short">ch.</term>
|
||||||
|
<term name="chapter-number" form="short">ch.</term>
|
||||||
|
<term name="presented at">presented at the</term>
|
||||||
|
<term name="available at">available</term>
|
||||||
|
<!-- always use three-letter abbreviations for months -->
|
||||||
|
<term name="month-06" form="short">Jun.</term>
|
||||||
|
<term name="month-07" form="short">Jul.</term>
|
||||||
|
<term name="month-09" form="short">Sep.</term>
|
||||||
|
</terms>
|
||||||
|
</locale>
|
||||||
|
<!-- Macros -->
|
||||||
|
<macro name="status">
|
||||||
|
<choose>
|
||||||
|
<if variable="page issue volume" match="none">
|
||||||
|
<text variable="status" text-case="capitalize-first" suffix="" font-weight="bold"/>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="edition">
|
||||||
|
<choose>
|
||||||
|
<if type="bill book chapter graphic legal_case legislation motion_picture paper-conference report song" match="any">
|
||||||
|
<choose>
|
||||||
|
<if is-numeric="edition">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<number variable="edition" form="ordinal"/>
|
||||||
|
<text term="edition" form="short"/>
|
||||||
|
</group>
|
||||||
|
</if>
|
||||||
|
<else>
|
||||||
|
<text variable="edition" text-case="capitalize-first" suffix="."/>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="issued">
|
||||||
|
<choose>
|
||||||
|
<if type="article-journal report" match="any">
|
||||||
|
<date variable="issued">
|
||||||
|
<date-part name="month" form="short" suffix=" "/>
|
||||||
|
<date-part name="year" form="long"/>
|
||||||
|
</date>
|
||||||
|
</if>
|
||||||
|
<else-if type="bill book chapter graphic legal_case legislation song thesis" match="any">
|
||||||
|
<date variable="issued">
|
||||||
|
<date-part name="year" form="long"/>
|
||||||
|
</date>
|
||||||
|
</else-if>
|
||||||
|
<else-if type="paper-conference" match="any">
|
||||||
|
<date variable="issued">
|
||||||
|
<date-part name="month" form="short"/>
|
||||||
|
<date-part name="year" prefix=" "/>
|
||||||
|
</date>
|
||||||
|
</else-if>
|
||||||
|
<else-if type="motion_picture" match="any">
|
||||||
|
<date variable="issued" form="text" prefix="(" suffix=")"/>
|
||||||
|
</else-if>
|
||||||
|
<else>
|
||||||
|
<date variable="issued" form="text"/>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="author">
|
||||||
|
<names variable="author">
|
||||||
|
<name and="text" et-al-min="7" et-al-use-first="1" initialize-with=". "/>
|
||||||
|
<label form="short" prefix=", " text-case="capitalize-first"/>
|
||||||
|
<et-al font-style="italic"/>
|
||||||
|
<substitute>
|
||||||
|
<names variable="editor"/>
|
||||||
|
<names variable="translator"/>
|
||||||
|
<text macro="director"/>
|
||||||
|
</substitute>
|
||||||
|
</names>
|
||||||
|
</macro>
|
||||||
|
<macro name="editor">
|
||||||
|
<names variable="editor">
|
||||||
|
<name initialize-with=". " delimiter=", " and="text"/>
|
||||||
|
<label form="short" prefix=", " text-case="capitalize-first"/>
|
||||||
|
</names>
|
||||||
|
</macro>
|
||||||
|
<macro name="director">
|
||||||
|
<names variable="director">
|
||||||
|
<name and="text" et-al-min="7" et-al-use-first="1" initialize-with=". "/>
|
||||||
|
<et-al font-style="italic"/>
|
||||||
|
</names>
|
||||||
|
</macro>
|
||||||
|
<macro name="locators">
|
||||||
|
<group delimiter=", ">
|
||||||
|
<text macro="edition"/>
|
||||||
|
<group delimiter=" ">
|
||||||
|
<text term="volume" form="short"/>
|
||||||
|
<number variable="volume" form="numeric"/>
|
||||||
|
</group>
|
||||||
|
<group delimiter=" ">
|
||||||
|
<number variable="number-of-volumes" form="numeric"/>
|
||||||
|
<text term="volume" form="short" plural="true"/>
|
||||||
|
</group>
|
||||||
|
<group delimiter=" ">
|
||||||
|
<text term="issue" form="short"/>
|
||||||
|
<number variable="issue" form="numeric"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<macro name="title">
|
||||||
|
<choose>
|
||||||
|
<if type="bill book graphic legal_case legislation motion_picture song standard software" match="any">
|
||||||
|
<text variable="title" font-style="italic"/>
|
||||||
|
</if>
|
||||||
|
<else>
|
||||||
|
<text variable="title" quotes="true"/>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="publisher">
|
||||||
|
<choose>
|
||||||
|
<if type="bill book chapter graphic legal_case legislation motion_picture paper-conference song" match="any">
|
||||||
|
<group delimiter=": ">
|
||||||
|
<text variable="publisher-place"/>
|
||||||
|
<text variable="publisher"/>
|
||||||
|
</group>
|
||||||
|
</if>
|
||||||
|
<else>
|
||||||
|
<group delimiter=", ">
|
||||||
|
<text variable="publisher"/>
|
||||||
|
<text variable="publisher-place"/>
|
||||||
|
</group>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="event">
|
||||||
|
<choose>
|
||||||
|
<!-- Published Conference Paper -->
|
||||||
|
<if type="paper-conference speech" match="any">
|
||||||
|
<choose>
|
||||||
|
<if variable="container-title" match="any">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<text term="in"/>
|
||||||
|
<text variable="container-title" font-style="italic"/>
|
||||||
|
</group>
|
||||||
|
</if>
|
||||||
|
<!-- Unpublished Conference Paper -->
|
||||||
|
<else>
|
||||||
|
<group delimiter=" ">
|
||||||
|
<text term="presented at"/>
|
||||||
|
<text variable="event"/>
|
||||||
|
</group>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="access">
|
||||||
|
<choose>
|
||||||
|
<if type="webpage post post-weblog" match="any">
|
||||||
|
<!-- https://url.com/ (accessed Mon. DD, YYYY). -->
|
||||||
|
<choose>
|
||||||
|
<if variable="URL">
|
||||||
|
<group delimiter=". " prefix=" ">
|
||||||
|
<group delimiter=": ">
|
||||||
|
<text term="accessed" text-case="capitalize-first"/>
|
||||||
|
<date variable="accessed" form="text"/>
|
||||||
|
</group>
|
||||||
|
<text term="online" prefix="[" suffix="]" text-case="capitalize-first"/>
|
||||||
|
<group delimiter=": ">
|
||||||
|
<text term="available at" text-case="capitalize-first"/>
|
||||||
|
<text variable="URL"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
</if>
|
||||||
|
<else-if match="any" variable="DOI">
|
||||||
|
<!-- doi: 10.1000/xyz123. -->
|
||||||
|
<text variable="DOI" prefix=" doi: " suffix="."/>
|
||||||
|
</else-if>
|
||||||
|
<else-if variable="URL">
|
||||||
|
<!-- Accessed: Mon. DD, YYYY. [Medium]. Available: https://URL.com/ -->
|
||||||
|
<group delimiter=". " prefix=" " suffix=". ">
|
||||||
|
<!-- Accessed: Mon. DD, YYYY. -->
|
||||||
|
<group delimiter=": ">
|
||||||
|
<text term="accessed" text-case="capitalize-first"/>
|
||||||
|
<date variable="accessed" form="text"/>
|
||||||
|
</group>
|
||||||
|
<!-- [Online Video]. -->
|
||||||
|
<group prefix="[" suffix="]" delimiter=" ">
|
||||||
|
<choose>
|
||||||
|
<if variable="medium" match="any">
|
||||||
|
<text variable="medium" text-case="capitalize-first"/>
|
||||||
|
</if>
|
||||||
|
<else>
|
||||||
|
<text term="online" text-case="capitalize-first"/>
|
||||||
|
<choose>
|
||||||
|
<if type="motion_picture">
|
||||||
|
<text term="video" text-case="capitalize-first"/>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<!-- Available: https://URL.com/ -->
|
||||||
|
<group delimiter=": " prefix=" ">
|
||||||
|
<text term="available at" text-case="capitalize-first"/>
|
||||||
|
<text variable="URL"/>
|
||||||
|
</group>
|
||||||
|
</else-if>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="page">
|
||||||
|
<choose>
|
||||||
|
<if type="article-journal" variable="number" match="all">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<text value="Art."/>
|
||||||
|
<text term="issue" form="short"/>
|
||||||
|
<text variable="number"/>
|
||||||
|
</group>
|
||||||
|
</if>
|
||||||
|
<else>
|
||||||
|
<group delimiter=" ">
|
||||||
|
<label variable="page" form="short"/>
|
||||||
|
<text variable="page"/>
|
||||||
|
</group>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="citation-locator">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<choose>
|
||||||
|
<if locator="page">
|
||||||
|
<label variable="locator" form="short"/>
|
||||||
|
</if>
|
||||||
|
<else>
|
||||||
|
<label variable="locator" form="short" text-case="capitalize-first"/>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
<text variable="locator"/>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<macro name="geographic-location">
|
||||||
|
<group delimiter=", " suffix=".">
|
||||||
|
<choose>
|
||||||
|
<if variable="publisher-place">
|
||||||
|
<text variable="publisher-place" text-case="title"/>
|
||||||
|
</if>
|
||||||
|
<else-if variable="event-place">
|
||||||
|
<text variable="event-place" text-case="title"/>
|
||||||
|
</else-if>
|
||||||
|
</choose>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<!-- Series -->
|
||||||
|
<macro name="collection">
|
||||||
|
<choose>
|
||||||
|
<if variable="collection-title" match="any">
|
||||||
|
<text term="in" suffix=" "/>
|
||||||
|
<group delimiter=", " suffix=". ">
|
||||||
|
<text variable="collection-title"/>
|
||||||
|
<text variable="collection-number" prefix="no. "/>
|
||||||
|
<text variable="volume" prefix="vol. "/>
|
||||||
|
</group>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<!-- Citation -->
|
||||||
|
<citation>
|
||||||
|
<sort>
|
||||||
|
<key variable="citation-number"/>
|
||||||
|
</sort>
|
||||||
|
<layout delimiter=", ">
|
||||||
|
<group prefix="[" suffix="]" delimiter=", ">
|
||||||
|
<text variable="citation-number"/>
|
||||||
|
<text macro="citation-locator"/>
|
||||||
|
</group>
|
||||||
|
</layout>
|
||||||
|
</citation>
|
||||||
|
<!-- Bibliography -->
|
||||||
|
<bibliography entry-spacing="0" second-field-align="flush">
|
||||||
|
<layout>
|
||||||
|
<!-- Citation Number -->
|
||||||
|
<text variable="citation-number" prefix="[" suffix="]"/>
|
||||||
|
<!-- Author(s) -->
|
||||||
|
<text macro="author" suffix=", "/>
|
||||||
|
<!-- Rest of Citation -->
|
||||||
|
<choose>
|
||||||
|
<!-- Specific Formats -->
|
||||||
|
<if type="article-journal">
|
||||||
|
<group delimiter=", ">
|
||||||
|
<text macro="title"/>
|
||||||
|
<text variable="container-title" font-style="italic" form="short"/>
|
||||||
|
<text macro="locators"/>
|
||||||
|
<text macro="page"/>
|
||||||
|
<text macro="issued"/>
|
||||||
|
<text macro="status"/>
|
||||||
|
</group>
|
||||||
|
<choose>
|
||||||
|
<if variable="URL DOI" match="none">
|
||||||
|
<text value="."/>
|
||||||
|
</if>
|
||||||
|
<else>
|
||||||
|
<text value=","/>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
<text macro="access"/>
|
||||||
|
</if>
|
||||||
|
<else-if type="paper-conference speech" match="any">
|
||||||
|
<group delimiter=", " suffix=", ">
|
||||||
|
<text macro="title"/>
|
||||||
|
<text macro="event"/>
|
||||||
|
<text macro="editor"/>
|
||||||
|
</group>
|
||||||
|
<text macro="collection"/>
|
||||||
|
<group delimiter=", " suffix=".">
|
||||||
|
<text macro="publisher"/>
|
||||||
|
<text macro="issued"/>
|
||||||
|
<text macro="page"/>
|
||||||
|
<text macro="status"/>
|
||||||
|
</group>
|
||||||
|
<text macro="access"/>
|
||||||
|
</else-if>
|
||||||
|
<else-if type="chapter">
|
||||||
|
<group delimiter=", " suffix=".">
|
||||||
|
<text macro="title"/>
|
||||||
|
<group delimiter=" ">
|
||||||
|
<text term="in" suffix=" "/>
|
||||||
|
<text variable="container-title" font-style="italic"/>
|
||||||
|
</group>
|
||||||
|
<text macro="locators"/>
|
||||||
|
<text macro="editor"/>
|
||||||
|
<text macro="collection"/>
|
||||||
|
<text macro="publisher"/>
|
||||||
|
<text macro="issued"/>
|
||||||
|
<group delimiter=" ">
|
||||||
|
<label variable="chapter-number" form="short"/>
|
||||||
|
<text variable="chapter-number"/>
|
||||||
|
</group>
|
||||||
|
<text macro="page"/>
|
||||||
|
</group>
|
||||||
|
<text macro="access"/>
|
||||||
|
</else-if>
|
||||||
|
<else-if type="report">
|
||||||
|
<group delimiter=", " suffix=".">
|
||||||
|
<text macro="title"/>
|
||||||
|
<text macro="publisher"/>
|
||||||
|
<group delimiter=" ">
|
||||||
|
<text variable="genre"/>
|
||||||
|
<text variable="number"/>
|
||||||
|
</group>
|
||||||
|
<text macro="issued"/>
|
||||||
|
</group>
|
||||||
|
<text macro="access"/>
|
||||||
|
</else-if>
|
||||||
|
<else-if type="thesis">
|
||||||
|
<group delimiter=", " suffix=".">
|
||||||
|
<text macro="title"/>
|
||||||
|
<text variable="genre"/>
|
||||||
|
<text macro="publisher"/>
|
||||||
|
<text macro="issued"/>
|
||||||
|
</group>
|
||||||
|
<text macro="access"/>
|
||||||
|
</else-if>
|
||||||
|
<else-if type="software">
|
||||||
|
<group delimiter=". " suffix=".">
|
||||||
|
<text macro="title"/>
|
||||||
|
<text macro="issued" prefix="(" suffix=")"/>
|
||||||
|
<text variable="genre"/>
|
||||||
|
<text macro="publisher"/>
|
||||||
|
</group>
|
||||||
|
<text macro="access"/>
|
||||||
|
</else-if>
|
||||||
|
<else-if type="article">
|
||||||
|
<group delimiter=", " suffix=".">
|
||||||
|
<text macro="title"/>
|
||||||
|
<text macro="issued"/>
|
||||||
|
<group delimiter=": ">
|
||||||
|
<text macro="publisher" font-style="italic"/>
|
||||||
|
<text variable="number"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<text macro="access"/>
|
||||||
|
</else-if>
|
||||||
|
<else-if type="webpage post-weblog post" match="any">
|
||||||
|
<group delimiter=", " suffix=".">
|
||||||
|
<text macro="title"/>
|
||||||
|
<text variable="container-title"/>
|
||||||
|
</group>
|
||||||
|
<text macro="access"/>
|
||||||
|
</else-if>
|
||||||
|
<else-if type="patent">
|
||||||
|
<group delimiter=", ">
|
||||||
|
<text macro="title"/>
|
||||||
|
<text variable="number"/>
|
||||||
|
<text macro="issued"/>
|
||||||
|
</group>
|
||||||
|
<text macro="access"/>
|
||||||
|
</else-if>
|
||||||
|
<!-- Online Video -->
|
||||||
|
<else-if type="motion_picture">
|
||||||
|
<text macro="geographic-location" suffix=". "/>
|
||||||
|
<group delimiter=", " suffix=".">
|
||||||
|
<text macro="title"/>
|
||||||
|
<text macro="issued"/>
|
||||||
|
</group>
|
||||||
|
<text macro="access"/>
|
||||||
|
</else-if>
|
||||||
|
<else-if type="standard">
|
||||||
|
<group delimiter=", " suffix=".">
|
||||||
|
<text macro="title"/>
|
||||||
|
<group delimiter=" ">
|
||||||
|
<text variable="genre"/>
|
||||||
|
<text variable="number"/>
|
||||||
|
</group>
|
||||||
|
<text macro="geographic-location"/>
|
||||||
|
<text macro="issued"/>
|
||||||
|
</group>
|
||||||
|
<text macro="access"/>
|
||||||
|
</else-if>
|
||||||
|
<!-- Generic/Fallback Formats -->
|
||||||
|
<else-if type="bill book graphic legal_case legislation report song" match="any">
|
||||||
|
<group delimiter=", " suffix=". ">
|
||||||
|
<text macro="title"/>
|
||||||
|
<text macro="locators"/>
|
||||||
|
</group>
|
||||||
|
<text macro="collection"/>
|
||||||
|
<group delimiter=", " suffix=".">
|
||||||
|
<text macro="publisher"/>
|
||||||
|
<text macro="issued"/>
|
||||||
|
<text macro="page"/>
|
||||||
|
</group>
|
||||||
|
<text macro="access"/>
|
||||||
|
</else-if>
|
||||||
|
<else-if type="article-magazine article-newspaper broadcast interview manuscript map patent personal_communication song speech thesis webpage" match="any">
|
||||||
|
<group delimiter=", " suffix=".">
|
||||||
|
<text macro="title"/>
|
||||||
|
<text variable="container-title" font-style="italic"/>
|
||||||
|
<text macro="locators"/>
|
||||||
|
<text macro="publisher"/>
|
||||||
|
<text macro="page"/>
|
||||||
|
<text macro="issued"/>
|
||||||
|
</group>
|
||||||
|
<text macro="access"/>
|
||||||
|
</else-if>
|
||||||
|
<else>
|
||||||
|
<group delimiter=", " suffix=". ">
|
||||||
|
<text macro="title"/>
|
||||||
|
<text variable="container-title" font-style="italic"/>
|
||||||
|
<text macro="locators"/>
|
||||||
|
</group>
|
||||||
|
<text macro="collection"/>
|
||||||
|
<group delimiter=", " suffix=".">
|
||||||
|
<text macro="publisher"/>
|
||||||
|
<text macro="page"/>
|
||||||
|
<text macro="issued"/>
|
||||||
|
</group>
|
||||||
|
<text macro="access"/>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</layout>
|
||||||
|
</bibliography>
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<style xmlns="http://purl.org/net/xbiblio/csl" class="in-text" delimiter-precedes-last="always" demote-non-dropping-particle="sort-only" initialize-with="" initialize-with-hyphen="false" name-as-sort-order="all" name-delimiter=", " names-delimiter=", " page-range-format="minimal" sort-separator=" " version="1.0">
|
||||||
|
<!-- This file was generated by the Style Variant Builder <https://github.com/citation-style-language/style-variant-builder>. To contribute changes, modify the template and regenerate variants. -->
|
||||||
|
<info>
|
||||||
|
<title>NLM/Vancouver: Citing Medicine 2nd edition (citation-sequence)</title>
|
||||||
|
<title-short>National Library of Medicine, ANSI/NISO Z39.29-2005 (R2010), ICMJE Recommendations/URMs (C-S)</title-short>
|
||||||
|
<id>http://www.zotero.org/styles/nlm-citation-sequence</id>
|
||||||
|
<link href="http://www.zotero.org/styles/nlm-citation-sequence" rel="self"/>
|
||||||
|
<link href="https://www.nlm.nih.gov/citingmedicine" rel="documentation"/>
|
||||||
|
<link href="https://www.nlm.nih.gov/bsd/uniform_requirements.html" rel="documentation"/>
|
||||||
|
<link href="https://www.icmje.org/recommendations/" rel="documentation"/>
|
||||||
|
<author>
|
||||||
|
<name>Michael Berkowitz</name>
|
||||||
|
<email>mberkowi@gmu.edu</email>
|
||||||
|
</author>
|
||||||
|
<author>
|
||||||
|
<name>Andrew Dunning</name>
|
||||||
|
<uri>https://orcid.org/0000-0003-0464-5036</uri>
|
||||||
|
</author>
|
||||||
|
<contributor>
|
||||||
|
<name>Petr Hlustik</name>
|
||||||
|
<uri>https://orcid.org/0000-0002-1951-0671</uri>
|
||||||
|
</contributor>
|
||||||
|
<contributor>
|
||||||
|
<name>Sebastian Karcher</name>
|
||||||
|
<uri>https://orcid.org/0000-0001-8249-7388</uri>
|
||||||
|
</contributor>
|
||||||
|
<contributor>
|
||||||
|
<name>Charles Parnot</name>
|
||||||
|
<uri>https://orcid.org/0000-0002-7346-5883</uri>
|
||||||
|
</contributor>
|
||||||
|
<contributor>
|
||||||
|
<name>Sean Takats</name>
|
||||||
|
<uri>https://orcid.org/0000-0002-7851-5069</uri>
|
||||||
|
</contributor>
|
||||||
|
<category citation-format="numeric"/>
|
||||||
|
<category field="generic-base"/>
|
||||||
|
<category field="medicine"/>
|
||||||
|
<category field="science"/>
|
||||||
|
<summary>Citing Medicine: The NLM Style Guide for Authors, Editors, and Publishers, 2nd edition (2015), based on ANSI/NISO Z39.29-2005 (R2010); citation-sequence system.</summary>
|
||||||
|
<updated>2026-02-18T15:24:08+00:00</updated>
|
||||||
|
<rights license="http://creativecommons.org/licenses/by-sa/3.0/">This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 License</rights>
|
||||||
|
</info>
|
||||||
|
<locale xml:lang="en">
|
||||||
|
<date delimiter=" " form="text">
|
||||||
|
<date-part name="year"/>
|
||||||
|
<date-part form="short" name="month" strip-periods="true"/>
|
||||||
|
<date-part name="day"/>
|
||||||
|
</date>
|
||||||
|
<terms>
|
||||||
|
<term name="available at">available from</term>
|
||||||
|
<term name="collection-editor">
|
||||||
|
<single>editor</single>
|
||||||
|
<multiple>editors</multiple>
|
||||||
|
</term>
|
||||||
|
<term form="short" name="month-06">Jun.</term>
|
||||||
|
<term form="short" name="month-07">Jul.</term>
|
||||||
|
<term form="short" name="month-09">Sep.</term>
|
||||||
|
<term name="presented at">presented at</term>
|
||||||
|
<term form="short" name="section">
|
||||||
|
<single>sect.</single>
|
||||||
|
<multiple>sects.</multiple>
|
||||||
|
</term>
|
||||||
|
<term form="short" name="supplement">
|
||||||
|
<single>suppl.</single>
|
||||||
|
<multiple>suppls.</multiple>
|
||||||
|
</term>
|
||||||
|
</terms>
|
||||||
|
</locale>
|
||||||
|
<locale xml:lang="fr">
|
||||||
|
<date delimiter=" " form="text">
|
||||||
|
<date-part name="day"/>
|
||||||
|
<date-part form="short" name="month" strip-periods="true"/>
|
||||||
|
<date-part name="year"/>
|
||||||
|
</date>
|
||||||
|
</locale>
|
||||||
|
<!-- Variable labels -->
|
||||||
|
<macro name="label-collection-number">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<choose>
|
||||||
|
<if is-numeric="collection-number">
|
||||||
|
<label form="short" variable="collection-number"/>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
<text variable="collection-number"/>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<macro name="label-edition">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<choose>
|
||||||
|
<if is-numeric="edition">
|
||||||
|
<number form="ordinal" variable="edition"/>
|
||||||
|
<label form="short" variable="edition"/>
|
||||||
|
</if>
|
||||||
|
<else>
|
||||||
|
<text variable="edition"/>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<macro name="label-number-of-pages">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<text variable="number-of-pages"/>
|
||||||
|
<choose>
|
||||||
|
<if is-numeric="number-of-pages">
|
||||||
|
<label form="short" plural="never" variable="number-of-pages"/>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<macro name="label-page">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<label form="short" plural="never" variable="page"/>
|
||||||
|
<text variable="page"/>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<macro name="label-part-number-capitalized">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<choose>
|
||||||
|
<if is-numeric="part-number">
|
||||||
|
<!-- TODO: Replace with `part-number` label when CSL provides one -->
|
||||||
|
<text form="short" term="part" text-case="capitalize-first"/>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
<text variable="part-number"/>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<macro name="label-supplement-number">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<choose>
|
||||||
|
<if is-numeric="supplement-number">
|
||||||
|
<!-- TODO: Replace with `supplement-number` label when CSL provides one -->
|
||||||
|
<text form="short" strip-periods="true" term="supplement" text-case="capitalize-first"/>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
<text text-case="capitalize-first" variable="supplement-number"/>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<macro name="label-volume-capitalized">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<choose>
|
||||||
|
<if is-numeric="volume">
|
||||||
|
<label form="short" text-case="capitalize-first" variable="volume"/>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
<text variable="volume"/>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<macro name="author">
|
||||||
|
<names variable="author">
|
||||||
|
<label prefix=", "/>
|
||||||
|
<substitute>
|
||||||
|
<names variable="editor-translator"/>
|
||||||
|
<names variable="editor translator"/>
|
||||||
|
<names variable="editor"/>
|
||||||
|
<names variable="collection-editor"/>
|
||||||
|
</substitute>
|
||||||
|
</names>
|
||||||
|
</macro>
|
||||||
|
<macro name="title">
|
||||||
|
<choose>
|
||||||
|
<if type="webpage" variable="container-title">
|
||||||
|
<!-- `webpage` listed under `container-title` (Citing Medicine, ch. 25) -->
|
||||||
|
<text variable="container-title"/>
|
||||||
|
</if>
|
||||||
|
<else>
|
||||||
|
<text variable="title"/>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="content-type">
|
||||||
|
<text variable="genre"/>
|
||||||
|
</macro>
|
||||||
|
<macro name="type-of-medium">
|
||||||
|
<choose>
|
||||||
|
<if variable="medium">
|
||||||
|
<text text-case="capitalize-first" variable="medium"/>
|
||||||
|
</if>
|
||||||
|
<else-if match="any" type="chapter entry-dictionary entry-encyclopedia paper-conference"/>
|
||||||
|
<else-if variable="URL">
|
||||||
|
<text term="internet" text-case="capitalize-first"/>
|
||||||
|
</else-if>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="container-preposition">
|
||||||
|
<choose>
|
||||||
|
<if match="any" type="chapter paper-conference entry-dictionary entry-encyclopedia">
|
||||||
|
<text term="in" text-case="capitalize-first"/>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="secondary-authors">
|
||||||
|
<names variable="editor">
|
||||||
|
<label prefix=", "/>
|
||||||
|
</names>
|
||||||
|
</macro>
|
||||||
|
<macro name="container-title">
|
||||||
|
<group delimiter=", ">
|
||||||
|
<choose>
|
||||||
|
<if type="webpage"/>
|
||||||
|
<else-if variable="container-title">
|
||||||
|
<group delimiter=". ">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<choose>
|
||||||
|
<if match="any" type="article-journal review review-book">
|
||||||
|
<text form="short" strip-periods="true" variable="container-title"/>
|
||||||
|
</if>
|
||||||
|
<else>
|
||||||
|
<text variable="container-title"/>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
<choose>
|
||||||
|
<if type="article-journal" variable="DOI"/>
|
||||||
|
<else-if type="article-journal" variable="PMID"/>
|
||||||
|
<else-if type="article-journal" variable="PMCID"/>
|
||||||
|
<else-if variable="URL">
|
||||||
|
<text prefix="[" suffix="]" term="internet" text-case="capitalize-first"/>
|
||||||
|
</else-if>
|
||||||
|
</choose>
|
||||||
|
</group>
|
||||||
|
<text macro="label-edition"/>
|
||||||
|
</group>
|
||||||
|
</else-if>
|
||||||
|
<!-- TODO: add `event-name` and `event-place` -->
|
||||||
|
<else-if match="any" type="bill legislation">
|
||||||
|
<group delimiter=". ">
|
||||||
|
<text variable="container-title"/>
|
||||||
|
<group delimiter=" ">
|
||||||
|
<text form="short" term="section" text-case="capitalize-first"/>
|
||||||
|
<text variable="section"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<text variable="number"/>
|
||||||
|
</else-if>
|
||||||
|
<else-if type="speech">
|
||||||
|
<group delimiter=": ">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<text text-case="capitalize-first" variable="genre"/>
|
||||||
|
<text term="presented at"/>
|
||||||
|
</group>
|
||||||
|
<text variable="event"/>
|
||||||
|
</group>
|
||||||
|
</else-if>
|
||||||
|
<else>
|
||||||
|
<group delimiter=", ">
|
||||||
|
<text macro="label-volume-capitalized"/>
|
||||||
|
<text variable="volume-title"/>
|
||||||
|
</group>
|
||||||
|
<group delimiter=", ">
|
||||||
|
<text macro="label-part-number-capitalized"/>
|
||||||
|
<text variable="part-title"/>
|
||||||
|
</group>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<macro name="place-of-publication">
|
||||||
|
<choose>
|
||||||
|
<if type="thesis">
|
||||||
|
<text prefix="[" suffix="]" variable="publisher-place"/>
|
||||||
|
</if>
|
||||||
|
<else-if type="speech"/>
|
||||||
|
<else>
|
||||||
|
<text variable="publisher-place"/>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="publisher">
|
||||||
|
<choose>
|
||||||
|
<!-- discard publisher for serial publications -->
|
||||||
|
<if match="none" type="article-journal article-magazine article-newspaper periodical post-weblog review review-book">
|
||||||
|
<group delimiter=": ">
|
||||||
|
<text macro="place-of-publication"/>
|
||||||
|
<text variable="publisher"/>
|
||||||
|
</group>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="date">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<choose>
|
||||||
|
<if match="any" type="article-journal article-magazine article-newspaper periodical post-weblog review review-book">
|
||||||
|
<group delimiter=":">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<date form="text" variable="issued"/>
|
||||||
|
<choose>
|
||||||
|
<if type="article-journal" variable="DOI"/>
|
||||||
|
<else-if type="article-journal" variable="PMID"/>
|
||||||
|
<else-if type="article-journal" variable="PMCID"/>
|
||||||
|
<else>
|
||||||
|
<text macro="date-of-citation"/>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</group>
|
||||||
|
<choose>
|
||||||
|
<if type="article-newspaper">
|
||||||
|
<text variable="page"/>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
</group>
|
||||||
|
</if>
|
||||||
|
<else-if match="any" type="bill legislation">
|
||||||
|
<date form="text" variable="issued"/>
|
||||||
|
</else-if>
|
||||||
|
<else-if type="report">
|
||||||
|
<date date-parts="year-month" form="text" variable="issued"/>
|
||||||
|
<text macro="date-of-citation"/>
|
||||||
|
</else-if>
|
||||||
|
<else-if type="patent">
|
||||||
|
<group delimiter=", ">
|
||||||
|
<text variable="number"/>
|
||||||
|
<date date-parts="year" form="numeric" variable="issued"/>
|
||||||
|
</group>
|
||||||
|
<text macro="date-of-citation"/>
|
||||||
|
</else-if>
|
||||||
|
<else-if type="speech">
|
||||||
|
<group delimiter="; ">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<date form="text" variable="issued"/>
|
||||||
|
<text macro="date-of-citation"/>
|
||||||
|
</group>
|
||||||
|
<text variable="event-place"/>
|
||||||
|
</group>
|
||||||
|
</else-if>
|
||||||
|
<else>
|
||||||
|
<date date-parts="year" form="numeric" variable="issued"/>
|
||||||
|
<text macro="date-of-citation"/>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<macro name="identifier-serial">
|
||||||
|
<choose>
|
||||||
|
<if match="any" type="article-journal article-magazine periodical post-weblog review review-book">
|
||||||
|
<group delimiter=":">
|
||||||
|
<group>
|
||||||
|
<text variable="collection-title"/>
|
||||||
|
<text variable="volume"/>
|
||||||
|
<group delimiter=" " prefix="(" suffix=")">
|
||||||
|
<text variable="issue"/>
|
||||||
|
<text macro="label-supplement-number"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<text macro="location-pagination-serial"/>
|
||||||
|
</group>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="date-of-citation">
|
||||||
|
<choose>
|
||||||
|
<if variable="URL">
|
||||||
|
<group delimiter=" " prefix="[" suffix="]">
|
||||||
|
<text term="cited"/>
|
||||||
|
<date form="text" variable="accessed"/>
|
||||||
|
</group>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="location-pagination-monographic">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<choose>
|
||||||
|
<if match="any" type="article-journal article-magazine article-newspaper review review-book"/>
|
||||||
|
<else-if type="book">
|
||||||
|
<text macro="label-number-of-pages"/>
|
||||||
|
</else-if>
|
||||||
|
<else>
|
||||||
|
<text macro="label-page"/>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<macro name="location-pagination-serial">
|
||||||
|
<choose>
|
||||||
|
<if variable="number">
|
||||||
|
<text variable="number"/>
|
||||||
|
</if>
|
||||||
|
<else>
|
||||||
|
<text variable="page"/>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="webpage-part">
|
||||||
|
<choose>
|
||||||
|
<if type="webpage" variable="container-title">
|
||||||
|
<text variable="title"/>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="series">
|
||||||
|
<choose>
|
||||||
|
<if match="any" type="article-journal article-magazine article-newspaper periodical post-weblog review review-book"/>
|
||||||
|
<else-if variable="collection-title">
|
||||||
|
<group delimiter=". " prefix="(" suffix=")">
|
||||||
|
<names variable="collection-editor">
|
||||||
|
<label prefix=", "/>
|
||||||
|
</names>
|
||||||
|
<group delimiter="; ">
|
||||||
|
<text variable="collection-title"/>
|
||||||
|
<text macro="label-collection-number"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</else-if>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="report-number">
|
||||||
|
<choose>
|
||||||
|
<if type="report">
|
||||||
|
<group delimiter=": ">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<text term="report" text-case="capitalize-first"/>
|
||||||
|
<label form="short" text-case="capitalize-first" variable="number"/>
|
||||||
|
</group>
|
||||||
|
<text variable="number"/>
|
||||||
|
</group>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
</macro>
|
||||||
|
<macro name="availability">
|
||||||
|
<group delimiter=". ">
|
||||||
|
<group delimiter=": ">
|
||||||
|
<text text-case="capitalize-first" value="located at"/>
|
||||||
|
<group delimiter="; ">
|
||||||
|
<group delimiter=", ">
|
||||||
|
<text variable="archive_collection"/>
|
||||||
|
<text variable="archive"/>
|
||||||
|
<text variable="archive-place"/>
|
||||||
|
</group>
|
||||||
|
<text variable="archive_location"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group delimiter=" ">
|
||||||
|
<choose>
|
||||||
|
<if type="article-journal" variable="DOI"/>
|
||||||
|
<else-if type="article-journal" variable="PMID"/>
|
||||||
|
<else-if type="article-journal" variable="PMCID"/>
|
||||||
|
<else>
|
||||||
|
<group delimiter=": ">
|
||||||
|
<text term="available at" text-case="capitalize-first"/>
|
||||||
|
<text variable="URL"/>
|
||||||
|
</group>
|
||||||
|
</else>
|
||||||
|
</choose>
|
||||||
|
<text prefix="doi:" variable="DOI"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<macro name="notes">
|
||||||
|
<group delimiter=". " suffix=".">
|
||||||
|
<group delimiter="; ">
|
||||||
|
<group delimiter=": ">
|
||||||
|
<text value="PubMed PMID"/>
|
||||||
|
<text variable="PMID"/>
|
||||||
|
</group>
|
||||||
|
<group delimiter=": ">
|
||||||
|
<text value="PubMed Central PMCID"/>
|
||||||
|
<text variable="PMCID"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<text variable="references"/>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<citation collapse="citation-number">
|
||||||
|
<sort>
|
||||||
|
<key variable="citation-number"/>
|
||||||
|
</sort>
|
||||||
|
<layout delimiter="," prefix="(" suffix=")">
|
||||||
|
<text variable="citation-number"/>
|
||||||
|
</layout>
|
||||||
|
</citation>
|
||||||
|
<macro name="bibliography">
|
||||||
|
<group delimiter=" ">
|
||||||
|
<group delimiter=". " suffix=".">
|
||||||
|
<text macro="author"/>
|
||||||
|
<group delimiter=" ">
|
||||||
|
<text macro="title"/>
|
||||||
|
<text macro="content-type" prefix="[" suffix="]"/>
|
||||||
|
<choose>
|
||||||
|
<if type="webpage" variable="container-title">
|
||||||
|
<text macro="type-of-medium" prefix="[" suffix="]"/>
|
||||||
|
</if>
|
||||||
|
<else-if match="none" variable="container-title">
|
||||||
|
<text macro="type-of-medium" prefix="[" suffix="]"/>
|
||||||
|
</else-if>
|
||||||
|
</choose>
|
||||||
|
</group>
|
||||||
|
<choose>
|
||||||
|
<if match="none" variable="container-title">
|
||||||
|
<text macro="label-edition"/>
|
||||||
|
</if>
|
||||||
|
</choose>
|
||||||
|
<group delimiter=": ">
|
||||||
|
<text macro="container-preposition"/>
|
||||||
|
<group delimiter=". ">
|
||||||
|
<text macro="secondary-authors"/>
|
||||||
|
<text macro="container-title"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group delimiter="; ">
|
||||||
|
<text macro="publisher"/>
|
||||||
|
<group delimiter=";">
|
||||||
|
<text macro="date"/>
|
||||||
|
<text macro="identifier-serial"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<text macro="location-pagination-monographic"/>
|
||||||
|
<text macro="webpage-part"/>
|
||||||
|
<text macro="series"/>
|
||||||
|
<text macro="report-number"/>
|
||||||
|
</group>
|
||||||
|
<text macro="availability"/>
|
||||||
|
<text macro="notes"/>
|
||||||
|
</group>
|
||||||
|
</macro>
|
||||||
|
<bibliography et-al-min="7" et-al-use-first="6" second-field-align="flush">
|
||||||
|
<layout>
|
||||||
|
<text suffix="." variable="citation-number"/>
|
||||||
|
<text macro="bibliography"/>
|
||||||
|
</layout>
|
||||||
|
</bibliography>
|
||||||
|
</style>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 250 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 |
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface CircularProgressProps {
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CircularProgress({
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
className,
|
||||||
|
}: CircularProgressProps) {
|
||||||
|
// Configuración interna del SVG (Coordenadas 100x100)
|
||||||
|
const center = 50
|
||||||
|
const strokeWidth = 8 // Grosor de la línea
|
||||||
|
const radius = 40 // Radio (dejamos margen para el borde)
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
|
||||||
|
// Cálculo del porcentaje inverso (para que se llene correctamente)
|
||||||
|
const percentage = (current / total) * 100
|
||||||
|
const strokeDashoffset = circumference - (percentage / 100) * circumference
|
||||||
|
|
||||||
|
return (
|
||||||
|
// CAMBIO CLAVE 1: 'size-24' (96px) da mucho más aire que 'size-16'
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative flex size-20 items-center justify-center',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* CAMBIO CLAVE 2: Contenedor de texto con inset-0 para centrado perfecto */}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="mb-1 text-sm leading-none font-medium text-slate-500">
|
||||||
|
Paso
|
||||||
|
</span>
|
||||||
|
<span className="text-base leading-none font-bold text-slate-900">
|
||||||
|
{current}{' '}
|
||||||
|
<span className="text-base font-normal text-slate-400">
|
||||||
|
/ {total}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SVG con viewBox para escalar automáticamente */}
|
||||||
|
<svg className="size-full -rotate-90" viewBox="0 0 100 100">
|
||||||
|
{/* Círculo de Fondo (Gris claro) */}
|
||||||
|
<circle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="transparent"
|
||||||
|
className="text-slate-100"
|
||||||
|
/>
|
||||||
|
{/* Círculo de Progreso (Verde/Color principal) */}
|
||||||
|
<circle
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
r={radius}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="transparent"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="text-primary transition-all duration-500 ease-out"
|
||||||
|
// Nota: usa text-primary para tomar el color de tu tema, o pon text-green-500
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+11
-15
@@ -1,53 +1,49 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Home, Menu, Network, X } from 'lucide-react'
|
import { Home, Menu, Network, X } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="p-4 flex items-center bg-gray-800 text-white shadow-lg">
|
{/* Cambiar el color de fondo por bg-background/40 y agregar backdrop-blur para efecto glassmorphism */}
|
||||||
|
<header className="sticky top-0 z-50 flex w-full items-center bg-gray-800 p-4 text-white shadow-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
|
className="rounded-lg p-2 transition-colors hover:bg-gray-700"
|
||||||
aria-label="Open menu"
|
aria-label="Open menu"
|
||||||
>
|
>
|
||||||
<Menu size={24} />
|
<Menu size={24} />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="ml-4 text-xl font-semibold">
|
<h1 className="ml-4 text-xl font-semibold">
|
||||||
<Link to="/">
|
<Link to="/">
|
||||||
<img
|
<img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" />
|
||||||
src="/tanstack-word-logo-white.svg"
|
|
||||||
alt="TanStack Logo"
|
|
||||||
className="h-10"
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={`fixed top-0 left-0 h-full w-80 bg-gray-900 text-white shadow-2xl z-50 transform transition-transform duration-300 ease-in-out flex flex-col ${
|
className={`fixed top-0 left-0 z-50 flex h-full w-80 transform flex-col bg-gray-900 text-white shadow-2xl transition-transform duration-300 ease-in-out ${
|
||||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
<div className="flex items-center justify-between border-b border-gray-700 p-4">
|
||||||
<h2 className="text-xl font-bold">Navigation</h2>
|
<h2 className="text-xl font-bold">Navigation</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
className="rounded-lg p-2 transition-colors hover:bg-gray-800"
|
||||||
aria-label="Close menu"
|
aria-label="Close menu"
|
||||||
>
|
>
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 p-4 overflow-y-auto">
|
<nav className="flex-1 overflow-y-auto p-4">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
className="mb-2 flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-gray-800"
|
||||||
activeProps={{
|
activeProps={{
|
||||||
className:
|
className:
|
||||||
'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
|
'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
|
||||||
@@ -62,7 +58,7 @@ export default function Header() {
|
|||||||
<Link
|
<Link
|
||||||
to="/demo/tanstack-query"
|
to="/demo/tanstack-query"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
|
className="mb-2 flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-gray-800"
|
||||||
activeProps={{
|
activeProps={{
|
||||||
className:
|
className:
|
||||||
'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
|
'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2',
|
||||||
|
|||||||
@@ -0,0 +1,869 @@
|
|||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import { usePlanAsignaturas } from '@/data'
|
||||||
|
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
|
||||||
|
import { columnParsers } from '@/lib/asignaturaColumnParsers'
|
||||||
|
|
||||||
|
export interface BibliografiaEntry {
|
||||||
|
id: string
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { planId } = useParams({
|
||||||
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
|
})
|
||||||
|
const { data: asignaturaApi } = useSubject(asignaturaId)
|
||||||
|
const { data: asignaturasApi, isLoading: loadingAsig } =
|
||||||
|
usePlanAsignaturas(planId)
|
||||||
|
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
|
||||||
|
const updateAsignatura = useUpdateAsignatura()
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const asignaturaSeriada = useMemo(() => {
|
||||||
|
if (!asignaturaApi?.prerrequisito_asignatura_id || !asignaturasApi)
|
||||||
|
return null
|
||||||
|
return asignaturasApi.find(
|
||||||
|
(asig) => asig.id === asignaturaApi.prerrequisito_asignatura_id,
|
||||||
|
)
|
||||||
|
}, [asignaturaApi, asignaturasApi])
|
||||||
|
const requisitosFormateados = useMemo(() => {
|
||||||
|
if (!asignaturaSeriada) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'Pre-requisito',
|
||||||
|
code: asignaturaSeriada.codigo,
|
||||||
|
name: asignaturaSeriada.nombre,
|
||||||
|
id: asignaturaSeriada.id, // Guardamos el ID para el select
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}, [asignaturaSeriada])
|
||||||
|
|
||||||
|
const handleUpdatePrerrequisito = (newId: string | null) => {
|
||||||
|
updateAsignatura.mutate({
|
||||||
|
asignaturaId,
|
||||||
|
patch: {
|
||||||
|
prerrequisito_asignatura_id: newId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/* ---------- sincronizar API ---------- */
|
||||||
|
useEffect(() => {
|
||||||
|
if (asignaturaApi) setAsignatura(asignaturaApi)
|
||||||
|
}, [asignaturaApi, requisitosFormateados])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DatosGenerales
|
||||||
|
pre={requisitosFormateados}
|
||||||
|
availableSubjects={asignaturasApi}
|
||||||
|
onPersistDato={handlePersistDatoGeneral}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DatosGenerales({
|
||||||
|
onPersistDato,
|
||||||
|
pre,
|
||||||
|
availableSubjects,
|
||||||
|
}: {
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
asignaturaId={asignaturaId}
|
||||||
|
title="Requisitos y Seriación"
|
||||||
|
type="requirements"
|
||||||
|
initialContent={pre}
|
||||||
|
// Pasamos las materias del plan para el Select (excluyendo la actual)
|
||||||
|
availableSubjects={
|
||||||
|
availableSubjects?.filter(
|
||||||
|
(a) =>
|
||||||
|
a.id !== asignaturaId &&
|
||||||
|
a.numero_ciclo < data?.numero_ciclo &&
|
||||||
|
a.numero_ciclo,
|
||||||
|
) || []
|
||||||
|
}
|
||||||
|
onPersist={({ value }) => {
|
||||||
|
updateAsignatura.mutate({
|
||||||
|
asignaturaId,
|
||||||
|
patch: {
|
||||||
|
prerrequisito_asignatura_id: value, // value ya viene como ID o null desde handleSave
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tarjeta de Evaluación */}
|
||||||
|
<InfoCard
|
||||||
|
asignaturaId={asignaturaId}
|
||||||
|
title="Sistema de Evaluación"
|
||||||
|
type="evaluation"
|
||||||
|
initialContent={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
|
||||||
|
availableSubjects?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoCard({
|
||||||
|
asignaturaId,
|
||||||
|
clave,
|
||||||
|
title,
|
||||||
|
initialContent,
|
||||||
|
placeholder,
|
||||||
|
description,
|
||||||
|
required,
|
||||||
|
type = 'text',
|
||||||
|
onPersist,
|
||||||
|
onClickEditButton,
|
||||||
|
containerRef,
|
||||||
|
forceEditToken,
|
||||||
|
highlightToken,
|
||||||
|
availableSubjects,
|
||||||
|
}: InfoCardProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [isHighlighted, setIsHighlighted] = useState(false)
|
||||||
|
const [data, setData] = useState(initialContent)
|
||||||
|
const [tempText, setTempText] = useState(initialContent)
|
||||||
|
|
||||||
|
const [evalRows, setEvalRows] = useState<Array<CriterioEvaluacionRowDraft>>(
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { planId } = useParams({
|
||||||
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
if (type === 'evaluation') {
|
||||||
|
const cleaned: Array<CriterioEvaluacionRow> = []
|
||||||
|
for (const r of evalRows) {
|
||||||
|
const criterio = String(r.criterio).trim()
|
||||||
|
const porcentajeStr = String(r.porcentaje).trim()
|
||||||
|
if (!criterio) continue
|
||||||
|
if (!porcentajeStr) continue
|
||||||
|
|
||||||
|
const n = Number(porcentajeStr)
|
||||||
|
if (!Number.isFinite(n)) continue
|
||||||
|
const porcentaje = Math.trunc(n)
|
||||||
|
if (porcentaje < 1 || porcentaje > 100) continue
|
||||||
|
|
||||||
|
cleaned.push({ criterio, porcentaje })
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(cleaned)
|
||||||
|
setEvalRows(
|
||||||
|
cleaned.map((x) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
criterio: x.criterio,
|
||||||
|
porcentaje: String(x.porcentaje),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
setIsEditing(false)
|
||||||
|
|
||||||
|
void onPersist?.({ type, clave, value: cleaned })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (type === 'requirements') {
|
||||||
|
// Si tempText es un array y tiene elementos, tomamos el ID del primero
|
||||||
|
// Si es "none" o está vacío, mandamos null (para limpiar la seriación)
|
||||||
|
const prerequisiteId =
|
||||||
|
Array.isArray(tempText) && tempText.length > 0 ? tempText[0].id : null
|
||||||
|
|
||||||
|
setData(tempText) // Actualiza la vista local
|
||||||
|
setIsEditing(false)
|
||||||
|
|
||||||
|
// Mandamos el ID específico a la base de datos
|
||||||
|
void onPersist?.({
|
||||||
|
type,
|
||||||
|
clave: 'prerrequisito_asignatura_id', // Forzamos la columna correcta
|
||||||
|
value: prerequisiteId,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(tempText)
|
||||||
|
setIsEditing(false)
|
||||||
|
|
||||||
|
if (type === 'text') {
|
||||||
|
void onPersist?.({ type, clave, value: String(tempText ?? '') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIARequest = (campoClave: string) => {
|
||||||
|
let targetClave = campoClave
|
||||||
|
if (type === 'evaluation' && !targetClave) {
|
||||||
|
targetClave = 'criterios_de_evaluacion'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetClave === 'contenido') {
|
||||||
|
targetClave = 'contenido_tematico'
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate({
|
||||||
|
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
|
||||||
|
params: { planId, asignaturaId: asignaturaId! },
|
||||||
|
state: {
|
||||||
|
activeTab: 'ia',
|
||||||
|
prefillCampo: targetClave,
|
||||||
|
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">
|
||||||
|
{type !== 'requirements' && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
|
||||||
|
onClick={() => handleIARequest(clave)}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Mejorar con IA</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-slate-400"
|
||||||
|
onClick={() => {
|
||||||
|
const startEditing = () => setIsEditing(true)
|
||||||
|
|
||||||
|
if (onClickEditButton) {
|
||||||
|
onClickEditButton({ startEditing })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startEditing()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Editar campo</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Condicionales de edición según el tipo */}
|
||||||
|
{type === 'requirements' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-xs font-medium text-slate-500">
|
||||||
|
Materia de Seriación
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={
|
||||||
|
Array.isArray(tempText) && tempText.length > 0
|
||||||
|
? tempText[0].id
|
||||||
|
: 'none'
|
||||||
|
}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const selected = availableSubjects?.find(
|
||||||
|
(s) => s.id === val,
|
||||||
|
)
|
||||||
|
if (val === 'none' || !selected) {
|
||||||
|
setTempText([])
|
||||||
|
} else {
|
||||||
|
setTempText([
|
||||||
|
{
|
||||||
|
id: selected.id,
|
||||||
|
type: 'Pre-requisito',
|
||||||
|
code: selected.codigo,
|
||||||
|
name: selected.nombre,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<div className="flex-1 truncate text-left">
|
||||||
|
<SelectValue placeholder="Selecciona una materia">
|
||||||
|
{Array.isArray(tempText) && tempText.length > 0
|
||||||
|
? `${tempText[0].code} - ${tempText[0].name}`
|
||||||
|
: undefined}
|
||||||
|
</SelectValue>
|
||||||
|
</div>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">
|
||||||
|
Ninguna (Sin seriación)
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
{availableSubjects?.map((asig) => (
|
||||||
|
<SelectItem
|
||||||
|
key={asig.id}
|
||||||
|
value={asig.id}
|
||||||
|
className="max-w-[300px] sm:max-w-[500px]"
|
||||||
|
>
|
||||||
|
<span className="text-primary font-bold">
|
||||||
|
[C{asig.numero_ciclo}]
|
||||||
|
</span>{' '}
|
||||||
|
<span className="inline-block truncate">
|
||||||
|
{asig.codigo} - {asig.nombre}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : type === 'evaluation' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{evalRows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.id}
|
||||||
|
className="grid grid-cols-[2fr_1fr_1ch_32px] items-center gap-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={row.criterio}
|
||||||
|
placeholder="Criterio"
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextCriterio = e.target.value
|
||||||
|
setEvalRows((prev) =>
|
||||||
|
prev.map((r) =>
|
||||||
|
r.id === row.id
|
||||||
|
? { ...r, criterio: nextCriterio }
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={row.porcentaje}
|
||||||
|
placeholder="%"
|
||||||
|
type="number"
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value
|
||||||
|
if (raw !== '' && !/^\d+$/.test(raw)) return
|
||||||
|
|
||||||
|
setEvalRows((prev) => {
|
||||||
|
const next = prev.map((r) =>
|
||||||
|
r.id === row.id ? { ...r, porcentaje: raw } : r,
|
||||||
|
)
|
||||||
|
const total = next.reduce(
|
||||||
|
(acc, r) => acc + (Number(r.porcentaje) || 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return total > 100 ? prev : next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="text-sm text-slate-600">%</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-red-600 hover:bg-red-50"
|
||||||
|
onClick={() =>
|
||||||
|
setEvalRows((prev) =>
|
||||||
|
prev.filter((r) => r.id !== row.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Minus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`text-sm ${evaluationTotal === 100 ? 'text-muted-foreground' : 'text-destructive font-semibold'}`}
|
||||||
|
>
|
||||||
|
Total: {evaluationTotal}/100
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-emerald-700 hover:bg-emerald-50"
|
||||||
|
onClick={() =>
|
||||||
|
setEvalRows((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
criterio: '',
|
||||||
|
porcentaje: '',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Agregar renglón
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
value={tempText}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => setTempText(e.target.value)}
|
||||||
|
className="min-h-30 text-sm leading-relaxed"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Botones de acción comunes */}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(false)
|
||||||
|
// Lógica de reset si es necesario...
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#00a878] hover:bg-[#008f66]"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={type === 'evaluation' && evaluationTotal > 100}
|
||||||
|
>
|
||||||
|
Guardar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Modo Visualización */
|
||||||
|
<div className="text-sm leading-relaxed text-slate-600">
|
||||||
|
{type === 'text' &&
|
||||||
|
(data ? (
|
||||||
|
<p className="whitespace-pre-wrap">{data}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-slate-400 italic">Sin información.</p>
|
||||||
|
))}
|
||||||
|
{type === 'requirements' && <RequirementsView items={data} />}
|
||||||
|
{type === 'evaluation' && <EvaluationView items={data} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||||
|
|
||||||
|
/* 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
|
||||||
|
tipo_fuente?: 'MANUAL' | 'BIBLIOTECA'
|
||||||
|
biblioteca_item_id?: string | null
|
||||||
|
fuenteBibliotecaId?: string
|
||||||
|
fuenteBiblioteca?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BibliographyItem() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { planId, asignaturaId } = useParams({
|
||||||
|
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- 1. Única fuente de verdad: La Query ---
|
||||||
|
const { data: bibliografia = [], isLoading } =
|
||||||
|
useSubjectBibliografia(asignaturaId)
|
||||||
|
|
||||||
|
// --- 2. Mutaciones ---
|
||||||
|
const { mutate: crearBibliografia } = useCreateBibliografia()
|
||||||
|
const { mutate: actualizarBibliografia } = useUpdateBibliografia(asignaturaId)
|
||||||
|
const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId)
|
||||||
|
|
||||||
|
// --- 3. Estados de UI (Solo para diálogos y edición) ---
|
||||||
|
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
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',
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- 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="animate-in fade-in space-y-8 pb-8 duration-500">
|
||||||
|
<div className="flex flex-col gap-4 border-b pb-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||||
|
Bibliografía
|
||||||
|
</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}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<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
|
||||||
|
// 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>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: `/planes/${planId}/asignaturas/${asignaturaId}/bibliografia/nueva`,
|
||||||
|
resetScroll: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="shadow-md"
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Agregar Bibliografía
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-8">
|
||||||
|
{/* BASICA */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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}
|
||||||
|
isEditing={editingId === entry.id}
|
||||||
|
onEdit={() => setEditingId(entry.id)}
|
||||||
|
onStopEditing={() => setEditingId(null)}
|
||||||
|
onUpdateCita={handleUpdateCita}
|
||||||
|
onDelete={() => setDeleteId(entry.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* COMPLEMENTARIA */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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}
|
||||||
|
isEditing={editingId === entry.id}
|
||||||
|
onEdit={() => setEditingId(entry.id)}
|
||||||
|
onStopEditing={() => setEditingId(null)}
|
||||||
|
onUpdateCita={handleUpdateCita}
|
||||||
|
onDelete={() => setDeleteId(entry.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>¿Eliminar referencia?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
La referencia será quitada del plan de estudios.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||||
|
<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)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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(
|
||||||
|
'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]"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div onClick={onEdit} className="cursor-pointer">
|
||||||
|
<p className="text-sm leading-relaxed text-slate-700">
|
||||||
|
{entry.cita}
|
||||||
|
</p>
|
||||||
|
{entry.fuenteBiblioteca && (
|
||||||
|
<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 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 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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<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-xs text-slate-500">{res.autor}</p>
|
||||||
|
</div>
|
||||||
|
<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
@@ -0,0 +1,133 @@
|
|||||||
|
import { FileCheck, Download, RefreshCw, Loader2 } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
|
||||||
|
interface DocumentoSEPTabProps {
|
||||||
|
pdfUrl: string | null
|
||||||
|
isLoading: boolean
|
||||||
|
onDownloadPdf: () => void
|
||||||
|
onDownloadWord: () => void
|
||||||
|
onRegenerate: () => void
|
||||||
|
isRegenerating: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentoSEPTab({
|
||||||
|
pdfUrl,
|
||||||
|
isLoading,
|
||||||
|
onDownloadPdf,
|
||||||
|
onDownloadWord,
|
||||||
|
onRegenerate,
|
||||||
|
isRegenerating,
|
||||||
|
}: DocumentoSEPTabProps) {
|
||||||
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
||||||
|
|
||||||
|
const handleRegenerate = () => {
|
||||||
|
setShowConfirmDialog(false)
|
||||||
|
onRegenerate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-foreground flex items-center gap-2 text-2xl font-semibold">
|
||||||
|
<FileCheck className="text-accent h-6 w-6" />
|
||||||
|
Documento SEP
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
Previsualización del documento oficial generado
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertDialog
|
||||||
|
open={showConfirmDialog}
|
||||||
|
onOpenChange={setShowConfirmDialog}
|
||||||
|
>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
disabled={isRegenerating}
|
||||||
|
>
|
||||||
|
{isRegenerating ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isRegenerating ? 'Generando...' : 'Regenerar'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>¿Regenerar documento SEP?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Se generará una nueva versión del documento con la información
|
||||||
|
actual.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleRegenerate}>
|
||||||
|
Regenerar
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{pdfUrl && !isLoading && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" className="gap-2" onClick={onDownloadWord}>
|
||||||
|
<Download className="h-4 w-4" /> Descargar Word
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={onDownloadPdf}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" /> Descargar PDF
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PDF Preview */}
|
||||||
|
<Card className="h-200 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
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,
|
||||||
|
} 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 { useSubjectHistorial } from '@/data/hooks/useSubjects'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Aplicamos filtros y agrupamiento sobre los datos transformados
|
||||||
|
const filteredHistorial = historialTransformado.filter((cambio) =>
|
||||||
|
filtros.has(cambio.tipo),
|
||||||
|
)
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
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="animate-fade-in space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<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-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="mr-2 h-4 w-4" />
|
||||||
|
Filtrar ({filtros.size})
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
{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>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<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) => (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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="border-border card-interactive hover:border-primary/50 flex-1 cursor-pointer shadow-none transition-colors"
|
||||||
|
onClick={() => openCompareModal(cambio)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ')
|
||||||
|
openCompareModal(cambio)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{format(cambio.fecha, 'HH:mm')}
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
|||||||
|
import { Check, Loader2, BookOpen, Clock, ListChecks } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import type { IASugerencia } from '@/types/asignatura'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
useUpdateAsignatura,
|
||||||
|
useSubject,
|
||||||
|
useUpdateSubjectRecommendation,
|
||||||
|
} from '@/data'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface ImprovementCardProps {
|
||||||
|
sug: IASugerencia
|
||||||
|
asignaturaId: string
|
||||||
|
onApplied: (campoKey: string) => void
|
||||||
|
isSelected?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImprovementCard({
|
||||||
|
sug,
|
||||||
|
asignaturaId,
|
||||||
|
onApplied,
|
||||||
|
}: ImprovementCardProps) {
|
||||||
|
const { data: asignatura } = useSubject(asignaturaId)
|
||||||
|
const updateAsignatura = useUpdateAsignatura()
|
||||||
|
const updateRecommendation = useUpdateSubjectRecommendation()
|
||||||
|
|
||||||
|
const [isApplying, setIsApplying] = useState(false)
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
if (!asignatura) return
|
||||||
|
|
||||||
|
setIsApplying(true)
|
||||||
|
try {
|
||||||
|
// 1. Identificar a qué columna debe ir el guardado
|
||||||
|
let patchData = {}
|
||||||
|
|
||||||
|
if (sug.campoKey === 'contenido_tematico') {
|
||||||
|
// Se guarda directamente en la columna contenido_tematico
|
||||||
|
patchData = { contenido_tematico: sug.valorSugerido }
|
||||||
|
} else if (sug.campoKey === 'criterios_de_evaluacion') {
|
||||||
|
// Se guarda directamente en la columna criterios_de_evaluacion
|
||||||
|
patchData = { criterios_de_evaluacion: sug.valorSugerido }
|
||||||
|
} else {
|
||||||
|
// Otros campos (ciclo, fines, etc.) se siguen guardando en el JSON de la columna 'datos'
|
||||||
|
patchData = {
|
||||||
|
datos: {
|
||||||
|
...asignatura.datos,
|
||||||
|
[sug.campoKey]: sug.valorSugerido,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Ejecutar la actualización con la estructura correcta
|
||||||
|
await updateAsignatura.mutateAsync({
|
||||||
|
asignaturaId: asignaturaId as any,
|
||||||
|
patch: patchData as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Marcar la recomendación como aplicada
|
||||||
|
await updateRecommendation.mutateAsync({
|
||||||
|
mensajeId: sug.messageId,
|
||||||
|
campoAfectado: sug.campoKey,
|
||||||
|
})
|
||||||
|
console.log(sug.campoKey)
|
||||||
|
|
||||||
|
onApplied(sug.campoKey)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al aplicar mejora:', error)
|
||||||
|
} finally {
|
||||||
|
setIsApplying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FUNCIÓN PARA RENDERIZAR EL CONTENIDO DE FORMA SEGURA ---
|
||||||
|
const renderContenido = (valor: any) => {
|
||||||
|
// Si no es un array, es texto simple
|
||||||
|
if (!Array.isArray(valor)) {
|
||||||
|
return <p className="italic">"{String(valor)}"</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CASO 1: CONTENIDO TEMÁTICO (Detectamos si el primer objeto tiene 'unidad') ---
|
||||||
|
if (valor[0]?.hasOwnProperty('unidad')) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{valor.map((u: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-card border-primary/20 rounded-md border p-2 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="border-border/50 text-primary mb-1 flex items-center gap-2 border-b pb-1 text-[11px] font-bold">
|
||||||
|
<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="text-muted-foreground flex items-start justify-between gap-2 text-[10px]"
|
||||||
|
>
|
||||||
|
<span className="leading-tight">• {t.nombre}</span>
|
||||||
|
<span className="text-muted-foreground/70 flex shrink-0 items-center gap-0.5 font-mono">
|
||||||
|
<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="text-muted-foreground/70 mb-1 flex items-center gap-2 text-[10px] font-bold uppercase">
|
||||||
|
<ListChecks size={12} /> Desglose de evaluación
|
||||||
|
</div>
|
||||||
|
{valor.map((c: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="bg-card border-border flex items-center justify-between gap-3 rounded-md border p-2 shadow-sm"
|
||||||
|
>
|
||||||
|
<span className="text-foreground text-[11px] leading-tight">
|
||||||
|
{c.criterio}
|
||||||
|
</span>
|
||||||
|
<div className="border-accent/30 bg-accent/10 text-accent flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-bold">
|
||||||
|
{c.porcentaje}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Opcional: Suma total para verificar que de 100% */}
|
||||||
|
<div className="text-muted-foreground/70 pt-1 text-right text-[9px] font-medium">
|
||||||
|
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="bg-card border-border flex flex-col rounded-xl border p-3 opacity-80 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-4">
|
||||||
|
<span className="text-foreground text-sm font-bold">
|
||||||
|
{sug.campoNombre}
|
||||||
|
</span>
|
||||||
|
<div className="border-border bg-muted/50 text-muted-foreground flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium">
|
||||||
|
<Check size={14} />
|
||||||
|
Aplicado
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-primary/20 bg-primary/5 text-muted-foreground rounded-lg border p-3 text-xs leading-relaxed">
|
||||||
|
{renderContenido(sug.valorSugerido)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ESTADO PENDIENTE ---
|
||||||
|
return (
|
||||||
|
<div className="bg-card border-primary/20 hover:border-primary/40 group flex flex-col rounded-xl border p-3 shadow-sm transition-all">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-4">
|
||||||
|
<span className="border-primary/20 bg-primary/10 text-primary max-w-[150px] truncate rounded-lg border px-2.5 py-1 text-[10px] font-bold tracking-wider uppercase">
|
||||||
|
{sug.campoNombre}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={isApplying || !asignatura}
|
||||||
|
className="h-8 w-auto px-4 text-xs font-semibold shadow-sm"
|
||||||
|
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(
|
||||||
|
'border-border/60 bg-muted/30 text-muted-foreground rounded-lg border border-dashed p-3 text-xs leading-relaxed',
|
||||||
|
!Array.isArray(sug.valorSugerido) && 'line-clamp-4 italic',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderContenido(sug.valorSugerido)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function EditTemaDialog({
|
||||||
|
children,
|
||||||
|
temaId,
|
||||||
|
defaultValue,
|
||||||
|
horas,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
temaId: string
|
||||||
|
defaultValue: string
|
||||||
|
horas: number
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [value, setValue] = useState(defaultValue)
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
console.log('Guardar tema', temaId, value)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<div onClick={() => setOpen(true)}>{children}</div>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Editar tema</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Horas asignadas: {horas}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>Guardar</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Pencil } from 'lucide-react'
|
||||||
|
import { EditTemaDialog } from './EditTemaDialog'
|
||||||
|
|
||||||
|
export function TemaItem({
|
||||||
|
id,
|
||||||
|
titulo,
|
||||||
|
horas,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
titulo: string
|
||||||
|
horas: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<EditTemaDialog
|
||||||
|
temaId={id}
|
||||||
|
defaultValue={titulo}
|
||||||
|
horas={horas}
|
||||||
|
>
|
||||||
|
<button className="w-full flex items-center justify-between rounded-md border px-4 py-2 text-left hover:bg-gray-50">
|
||||||
|
<span>{titulo}</span>
|
||||||
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
|
<span>{horas} hrs</span>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</EditTemaDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { TemaItem } from './TemaItem'
|
||||||
|
|
||||||
|
export function UnidadCard({
|
||||||
|
numero,
|
||||||
|
titulo,
|
||||||
|
temas,
|
||||||
|
}: {
|
||||||
|
numero: number
|
||||||
|
titulo: string
|
||||||
|
temas: {
|
||||||
|
id: string
|
||||||
|
titulo: string
|
||||||
|
horas: number
|
||||||
|
}[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge>Unidad {numero}</Badge>
|
||||||
|
<h3 className="font-semibold">{titulo}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{temas.map((tema) => (
|
||||||
|
<TemaItem key={tema.id} {...tema} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onConfirm: () => void
|
||||||
|
titulo?: string
|
||||||
|
descripcion?: string
|
||||||
|
}
|
||||||
|
export const AlertaConflicto = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
titulo,
|
||||||
|
descripcion,
|
||||||
|
}: Props) => {
|
||||||
|
// Intentamos parsear el mensaje si viene como JSON para la lista de materias
|
||||||
|
let contenido
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(descripcion as any)
|
||||||
|
contenido = (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-slate-600">{data.main}</p>
|
||||||
|
<div className="flex flex-wrap gap-2 py-2">
|
||||||
|
{data.materias.map((m: string, i: number) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="animate-in fade-in zoom-in-95 inline-flex items-center rounded-md border border-red-100 bg-red-50 px-2.5 py-1 text-xs font-medium text-red-700 duration-300"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="mr-1.5 h-3 w-3 shrink-0" />
|
||||||
|
{m}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs font-semibold text-slate-500">
|
||||||
|
¿Deseas ignorar la regla y moverla de todos modos (Esto eliminará la
|
||||||
|
seriación)?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
contenido = <p className="text-sm text-slate-600">{descripcion}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog.Root open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialog.Portal>
|
||||||
|
<AlertDialog.Overlay className="fixed inset-0 z-[200] bg-slate-950/40 backdrop-blur-[2px]" />
|
||||||
|
<AlertDialog.Content className="fixed top-1/2 left-1/2 z-[201] w-[95vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-slate-100 bg-white p-6 shadow-2xl">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 text-red-600">
|
||||||
|
<AlertTriangle className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<AlertDialog.Title className="text-xl font-bold tracking-tight text-slate-900">
|
||||||
|
{titulo}
|
||||||
|
</AlertDialog.Title>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog.Description asChild>{contenido}</AlertDialog.Description>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col-reverse justify-end gap-3 sm:flex-row">
|
||||||
|
<AlertDialog.Cancel asChild>
|
||||||
|
<Button variant="ghost">Cancelar</Button>
|
||||||
|
{/* Radix automáticamente llamará a onOpenChange(false) al hacer clic aquí */}
|
||||||
|
</AlertDialog.Cancel>
|
||||||
|
|
||||||
|
<AlertDialog.Action asChild>
|
||||||
|
<Button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="bg-red-600 font-bold text-white shadow-lg shadow-red-200 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Mover de todos modos
|
||||||
|
</Button>
|
||||||
|
</AlertDialog.Action>
|
||||||
|
</div>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Portal>
|
||||||
|
</AlertDialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||||
|
|
||||||
|
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { useSubject } from '@/data'
|
||||||
|
|
||||||
|
export function PasoBasicosClonadoInterno({
|
||||||
|
wizard,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
wizard: NewSubjectWizardState
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
|
}) {
|
||||||
|
const sourceId = wizard.clonInterno?.asignaturaOrigenId ?? null
|
||||||
|
const { data: source, isLoading, isError } = useSubject(sourceId)
|
||||||
|
|
||||||
|
const lastAppliedRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!source) return
|
||||||
|
if (lastAppliedRef.current === source.id) return
|
||||||
|
|
||||||
|
lastAppliedRef.current = source.id
|
||||||
|
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: {
|
||||||
|
...w.datosBasicos,
|
||||||
|
nombre: source.nombre,
|
||||||
|
codigo: source.codigo ?? '',
|
||||||
|
tipo: (source.tipo as any) ?? null,
|
||||||
|
creditos: source.creditos,
|
||||||
|
horasAcademicas: (source as any).horas_academicas ?? null,
|
||||||
|
horasIndependientes: (source as any).horas_independientes ?? null,
|
||||||
|
estructuraId: (source.estructura_id ??
|
||||||
|
w.datosBasicos.estructuraId) as any,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}, [onChange, source])
|
||||||
|
|
||||||
|
if (!sourceId) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Datos básicos</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-muted-foreground text-sm">
|
||||||
|
Selecciona una asignatura fuente para continuar.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Datos básicos</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-muted-foreground text-sm">
|
||||||
|
Cargando información de la asignatura fuente…
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !source) {
|
||||||
|
return (
|
||||||
|
<Card className="border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive flex items-center gap-2 text-base">
|
||||||
|
<Icons.AlertTriangle className="h-5 w-5" />
|
||||||
|
No se pudo cargar la fuente
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-muted-foreground text-sm">
|
||||||
|
Intenta seleccionar otra asignatura.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PasoBasicosForm
|
||||||
|
wizard={wizard}
|
||||||
|
onChange={onChange}
|
||||||
|
estructuraFuenteId={source.estructura_id ?? null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
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,
|
||||||
|
estructuraFuenteId,
|
||||||
|
}: {
|
||||||
|
wizard: NewSubjectWizardState
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
|
estructuraFuenteId?: string | null
|
||||||
|
}) {
|
||||||
|
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>
|
||||||
|
{estructuraFuenteId &&
|
||||||
|
wizard.datosBasicos.estructuraId &&
|
||||||
|
wizard.datosBasicos.estructuraId !== estructuraFuenteId ? (
|
||||||
|
<div className="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-2 rounded-md border p-2 text-xs">
|
||||||
|
<Icons.AlertTriangle className="mt-0.5 h-4 w-4 flex-none" />
|
||||||
|
<span>
|
||||||
|
Es posible que se pierdan datos generales al seleccionar otra
|
||||||
|
estructura.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { useDebounce } from 'use-debounce'
|
||||||
|
|
||||||
|
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||||
|
import type { Database } from '@/types/supabase'
|
||||||
|
|
||||||
|
import Pagination03 from '@/components/shadcn-studio/pagination/pagination-03'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { supabaseBrowser, useCatalogosPlanes, usePlanes } from '@/data'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type SourceSubjectRow = {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
codigo: string | null
|
||||||
|
creditos: number
|
||||||
|
tipo: any
|
||||||
|
plan_estudio_id: string
|
||||||
|
estructura_id: string | null
|
||||||
|
rank?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL = '__all__'
|
||||||
|
|
||||||
|
type SearchAsignaturasRow =
|
||||||
|
Database['public']['Functions']['search_asignaturas']['Returns'][number]
|
||||||
|
|
||||||
|
export function PasoFuenteClonadoInterno({
|
||||||
|
wizard,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
wizard: NewSubjectWizardState
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
|
}) {
|
||||||
|
const pageSize = 20
|
||||||
|
|
||||||
|
const facultadId = wizard.clonInterno?.facultadId ?? null
|
||||||
|
const carreraId = wizard.clonInterno?.carreraId ?? null
|
||||||
|
const planOrigenId = wizard.clonInterno?.planOrigenId ?? null
|
||||||
|
const search = wizard.clonInterno?.search ?? ''
|
||||||
|
const page = Math.max(1, wizard.clonInterno?.page ?? 1)
|
||||||
|
|
||||||
|
const [debouncedSearch] = useDebounce(search, 350)
|
||||||
|
|
||||||
|
const { data: catalogos } = useCatalogosPlanes()
|
||||||
|
|
||||||
|
const carrerasOptions = useMemo(() => {
|
||||||
|
const raw = catalogos?.carreras ?? []
|
||||||
|
return facultadId ? raw.filter((c) => c.facultad_id === facultadId) : raw
|
||||||
|
}, [catalogos?.carreras, facultadId])
|
||||||
|
|
||||||
|
const planesQuery = usePlanes({
|
||||||
|
search: '',
|
||||||
|
facultadId: facultadId ?? 'todas',
|
||||||
|
carreraId: carreraId ?? 'todas',
|
||||||
|
estadoId: 'todos',
|
||||||
|
limit: 500,
|
||||||
|
offset: 0,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
const needPlansForFilter = Boolean((facultadId || carreraId) && !planOrigenId)
|
||||||
|
const plansForFilter = planesQuery.data?.data ?? []
|
||||||
|
|
||||||
|
const { data: subjectsPaged, isLoading: subjectsLoading } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
'asignaturas',
|
||||||
|
'clone-source',
|
||||||
|
{
|
||||||
|
facultadId,
|
||||||
|
carreraId,
|
||||||
|
planOrigenId,
|
||||||
|
search: debouncedSearch,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
planIdsKey: needPlansForFilter
|
||||||
|
? plansForFilter.map((p) => p.id).join(',')
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
enabled: !needPlansForFilter || !planesQuery.isLoading,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: async () => {
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
const from = (page - 1) * pageSize
|
||||||
|
const term = debouncedSearch.trim()
|
||||||
|
|
||||||
|
// Una sola llamada limpia. RPC maneja tanto búsqueda vacía como llena.
|
||||||
|
const { data, error } = await supabase.rpc('search_asignaturas', {
|
||||||
|
p_search: term,
|
||||||
|
p_facultad_id: facultadId ?? undefined, // Corrección TS 2322
|
||||||
|
p_carrera_id: carreraId ?? undefined, // Corrección TS 2322
|
||||||
|
p_plan_estudio_id: planOrigenId ?? undefined, // Corrección TS 2322
|
||||||
|
p_limit: pageSize,
|
||||||
|
p_offset: from,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) throw new Error(error.message)
|
||||||
|
|
||||||
|
// Extraemos el conteo total de la primera fila.
|
||||||
|
// ESLint feliz: "data" ya no puede ser null a este punto.
|
||||||
|
const count = data.length > 0 ? Number(data[0].total_count) : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
// ESLint feliz: map directo sobre data
|
||||||
|
data: data.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
nombre: r.nombre,
|
||||||
|
codigo: r.codigo,
|
||||||
|
creditos: Number(r.creditos),
|
||||||
|
tipo: r.tipo,
|
||||||
|
plan_estudio_id: r.plan_estudio_id,
|
||||||
|
estructura_id: null,
|
||||||
|
rank: r.rank,
|
||||||
|
})),
|
||||||
|
count,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const subjects = subjectsPaged?.data ?? []
|
||||||
|
const total = subjectsPaged?.count ?? 0
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// clamp page if results shrink
|
||||||
|
if (page > pageCount) {
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
clonInterno: { ...(w.clonInterno ?? {}), page: pageCount },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [onChange, page, pageCount])
|
||||||
|
|
||||||
|
const patchClonInterno = (
|
||||||
|
patch: Partial<NonNullable<NewSubjectWizardState['clonInterno']>>,
|
||||||
|
) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
clonInterno: { ...(w.clonInterno ?? {}), ...patch },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const hasAnyFilter = Boolean(
|
||||||
|
facultadId || carreraId || planOrigenId || search.trim().length,
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearDisabled = !hasAnyFilter
|
||||||
|
|
||||||
|
const selectedId = wizard.clonInterno?.asignaturaOrigenId ?? null
|
||||||
|
|
||||||
|
const resetSelection = () => patchClonInterno({ asignaturaOrigenId: null })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<Card className="gap-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Fuente</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label>Facultad</Label>
|
||||||
|
<Select
|
||||||
|
value={facultadId ?? ALL}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const next = val === ALL ? null : val
|
||||||
|
patchClonInterno({
|
||||||
|
facultadId: next,
|
||||||
|
carreraId: null,
|
||||||
|
planOrigenId: null,
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
resetSelection()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full min-w-0 [&>span]:block! [&>span]:truncate!">
|
||||||
|
<SelectValue placeholder="Todas" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>Todas</SelectItem>
|
||||||
|
{(catalogos?.facultades ?? []).map((f) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>
|
||||||
|
{f.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label>Carrera</Label>
|
||||||
|
<Select
|
||||||
|
value={carreraId ?? ALL}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const next = val === ALL ? null : val
|
||||||
|
patchClonInterno({
|
||||||
|
carreraId: next,
|
||||||
|
planOrigenId: null,
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
resetSelection()
|
||||||
|
}}
|
||||||
|
disabled={!facultadId}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full min-w-0 [&>span]:block! [&>span]:truncate!">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={facultadId ? 'Todas' : 'Selecciona facultad'}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>Todas</SelectItem>
|
||||||
|
{carrerasOptions.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label>Plan</Label>
|
||||||
|
<Select
|
||||||
|
value={planOrigenId ?? ALL}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const next = val === ALL ? null : val
|
||||||
|
patchClonInterno({ planOrigenId: next, page: 1 })
|
||||||
|
resetSelection()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||||
|
disabled={!carreraId && !facultadId}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Todos" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>Todos</SelectItem>
|
||||||
|
{(planesQuery.data?.data ?? []).map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label>Buscar</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Nombre o código..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchClonInterno({ search: e.target.value, page: 1 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
patchClonInterno({
|
||||||
|
facultadId: null,
|
||||||
|
carreraId: null,
|
||||||
|
planOrigenId: null,
|
||||||
|
search: '',
|
||||||
|
page: 1,
|
||||||
|
asignaturaOrigenId: null,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
disabled={clearDisabled}
|
||||||
|
>
|
||||||
|
<Icons.X className="mr-2 h-4 w-4" />
|
||||||
|
Limpiar filtros
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Selecciona una asignatura fuente (solo una).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid max-h-80 gap-2 overflow-y-auto px-1">
|
||||||
|
{subjectsLoading ? (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
Cargando asignaturas…
|
||||||
|
</div>
|
||||||
|
) : subjects.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
No hay asignaturas con esos filtros.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
subjects.map((m) => {
|
||||||
|
const active = String(selectedId) === String(m.id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={m.id}
|
||||||
|
className={cn(
|
||||||
|
'hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 text-left',
|
||||||
|
active && 'border-primary bg-primary/5 ring-primary ring-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="sr-only"
|
||||||
|
type="radio"
|
||||||
|
name="asignaturaFuente"
|
||||||
|
checked={active}
|
||||||
|
onChange={() =>
|
||||||
|
patchClonInterno({ asignaturaOrigenId: m.id, page })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">{m.nombre}</div>
|
||||||
|
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
{(m.codigo ? m.codigo : '—') +
|
||||||
|
' • ' +
|
||||||
|
String(m.creditos) +
|
||||||
|
' créditos'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{active ? (
|
||||||
|
<Icons.CheckCircle2 className="text-primary h-5 w-5 flex-none" />
|
||||||
|
) : (
|
||||||
|
<span className="h-5 w-5 flex-none" aria-hidden />
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pageCount > 1 ? (
|
||||||
|
<Pagination03
|
||||||
|
page={page}
|
||||||
|
pageCount={pageCount}
|
||||||
|
onPageChange={(nextPage) => patchClonInterno({ page: nextPage })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
|
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
|
||||||
|
export function PasoMetodoCardGroup({
|
||||||
|
wizard,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
wizard: NewSubjectWizardState
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
|
}) {
|
||||||
|
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): NewSubjectWizardState => ({
|
||||||
|
...w,
|
||||||
|
tipoOrigen: 'MANUAL',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icons.Pencil className="text-primary h-5 w-5" /> Manual
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Asignatura vacía con estructura base.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
|
||||||
|
onClick={() =>
|
||||||
|
onChange(
|
||||||
|
(w): NewSubjectWizardState => ({
|
||||||
|
...w,
|
||||||
|
tipoOrigen: 'IA',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icons.Sparkles className="text-primary h-5 w-5" /> Con IA
|
||||||
|
</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): NewSubjectWizardState => ({ ...w, tipoOrigen: 'CLONADO' }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icons.Copy className="text-primary h-5 w-5" /> Clonado
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>De otra asignatura o archivo Word.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{(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): 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>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
|
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
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>
|
||||||
|
<CardTitle>Resumen de creación</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Verifica los datos antes de crear la asignatura.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<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 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
export function VistaSinPermisos({ onClose }: { onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader className="flex-none border-b p-6">
|
||||||
|
<DialogTitle>Nueva Asignatura</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 p-6">
|
||||||
|
<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={onClose}>
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
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,
|
||||||
|
subjects_get,
|
||||||
|
} from '@/data'
|
||||||
|
|
||||||
|
export function WizardControls({
|
||||||
|
wizard,
|
||||||
|
setWizard,
|
||||||
|
errorMessage,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
disablePrev,
|
||||||
|
disableNext,
|
||||||
|
disableCreate,
|
||||||
|
isLastStep,
|
||||||
|
}: {
|
||||||
|
wizard: NewSubjectWizardState
|
||||||
|
setWizard: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
||||||
|
errorMessage?: string | null
|
||||||
|
onPrev: () => void
|
||||||
|
onNext: () => void
|
||||||
|
disablePrev: boolean
|
||||||
|
disableNext: boolean
|
||||||
|
disableCreate: boolean
|
||||||
|
isLastStep: boolean
|
||||||
|
}) {
|
||||||
|
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 === 'CLONADO_INTERNO') {
|
||||||
|
if (!wizard.plan_estudio_id) {
|
||||||
|
throw new Error('Plan de estudio inválido.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const asignaturaOrigenId = wizard.clonInterno?.asignaturaOrigenId
|
||||||
|
if (!asignaturaOrigenId) {
|
||||||
|
throw new Error('Selecciona una asignatura fuente.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wizard.datosBasicos.estructuraId) {
|
||||||
|
throw new Error('Estructura inválida.')
|
||||||
|
}
|
||||||
|
if (!wizard.datosBasicos.nombre.trim()) {
|
||||||
|
throw new Error('Nombre inválido.')
|
||||||
|
}
|
||||||
|
if (wizard.datosBasicos.tipo == null) {
|
||||||
|
throw new Error('Tipo inválido.')
|
||||||
|
}
|
||||||
|
if (wizard.datosBasicos.creditos == null) {
|
||||||
|
throw new Error('Créditos inválidos.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fuente = await subjects_get(asignaturaOrigenId as any)
|
||||||
|
|
||||||
|
const supabase = supabaseBrowser()
|
||||||
|
|
||||||
|
const codigo = (wizard.datosBasicos.codigo ?? '').trim()
|
||||||
|
|
||||||
|
const payload: TablesInsert<'asignaturas'> = {
|
||||||
|
plan_estudio_id: wizard.plan_estudio_id,
|
||||||
|
estructura_id: wizard.datosBasicos.estructuraId,
|
||||||
|
codigo: codigo ? codigo : null,
|
||||||
|
nombre: wizard.datosBasicos.nombre,
|
||||||
|
tipo: wizard.datosBasicos.tipo,
|
||||||
|
creditos: wizard.datosBasicos.creditos,
|
||||||
|
datos: (fuente as any).datos,
|
||||||
|
contenido_tematico: (fuente as any).contenido_tematico,
|
||||||
|
criterios_de_evaluacion: (fuente as any).criterios_de_evaluacion,
|
||||||
|
tipo_origen: 'CLONADO_INTERNO',
|
||||||
|
meta_origen: {
|
||||||
|
...(fuente as any).meta_origen,
|
||||||
|
asignatura_origen_id: fuente.id,
|
||||||
|
plan_origen_id: (fuente as any).plan_estudio_id,
|
||||||
|
},
|
||||||
|
horas_academicas:
|
||||||
|
wizard.datosBasicos.horasAcademicas ??
|
||||||
|
(fuente as any).horas_academicas ??
|
||||||
|
null,
|
||||||
|
horas_independientes:
|
||||||
|
wizard.datosBasicos.horasIndependientes ??
|
||||||
|
(fuente as any).horas_independientes ??
|
||||||
|
null,
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('payload:', payload)
|
||||||
|
|
||||||
|
const { data: inserted, error: insertError } = await supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.insert(payload)
|
||||||
|
.select('id,plan_estudio_id')
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (insertError) throw new Error(insertError.message)
|
||||||
|
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: qk.planAsignaturas(wizard.plan_estudio_id),
|
||||||
|
})
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: qk.planHistorial(wizard.plan_estudio_id),
|
||||||
|
})
|
||||||
|
|
||||||
|
navigate({
|
||||||
|
to: `/planes/${inserted.plan_estudio_id}/asignaturas/${inserted.id}`,
|
||||||
|
state: { showConfetti: true },
|
||||||
|
resetScroll: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
||||||
|
if (!wizard.plan_estudio_id) {
|
||||||
|
throw new Error('Plan de estudio inválido.')
|
||||||
|
}
|
||||||
|
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 grow items-center justify-between">
|
||||||
|
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
|
||||||
|
Anterior
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
|
import { StepWithTooltip } from './StepWithTooltip'
|
||||||
|
|
||||||
|
import { CircularProgress } from '@/components/CircularProgress'
|
||||||
|
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
export function WizardHeader({
|
||||||
|
title,
|
||||||
|
Wizard,
|
||||||
|
methods,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
Wizard: any
|
||||||
|
methods: any
|
||||||
|
}) {
|
||||||
|
const currentIndex = Wizard.utils.getIndex(methods.current.id) + 1
|
||||||
|
const totalSteps = Wizard.steps.length
|
||||||
|
const nextStep = Wizard.steps[currentIndex]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="z-10 flex-none border-b bg-white">
|
||||||
|
<div className="flex items-center justify-between p-6 pb-4">
|
||||||
|
<DialogHeader className="p-0">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{methods.onClose && (
|
||||||
|
<button
|
||||||
|
onClick={methods.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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<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={methods.current.title}
|
||||||
|
desc={methods.current.description}
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
{nextStep ? (
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Siguiente: {nextStep.title}
|
||||||
|
</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">
|
||||||
|
{Wizard.steps.map((step: any) => (
|
||||||
|
<Wizard.Stepper.Step
|
||||||
|
key={step.id}
|
||||||
|
of={step.id}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Wizard.Stepper.Title>
|
||||||
|
<StepWithTooltip title={step.title} desc={step.description} />
|
||||||
|
</Wizard.Stepper.Title>
|
||||||
|
</Wizard.Stepper.Step>
|
||||||
|
))}
|
||||||
|
</Wizard.Stepper.Navigation>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// import { supabase } from '@/lib/supabase'
|
||||||
|
import { LoginInput } from '../ui/LoginInput'
|
||||||
|
import { SubmitButton } from '../ui/SubmitButton'
|
||||||
|
|
||||||
|
export function ExternalLoginForm() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
/* await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
})*/
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
submit()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoginInput
|
||||||
|
label="Correo electrónico"
|
||||||
|
value={email}
|
||||||
|
onChange={setEmail}
|
||||||
|
/>
|
||||||
|
<LoginInput
|
||||||
|
label="Contraseña"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
/>
|
||||||
|
<SubmitButton />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// import { supabase } from '@/lib/supabase'
|
||||||
|
import { LoginInput } from '../ui/LoginInput'
|
||||||
|
import { SubmitButton } from '../ui/SubmitButton'
|
||||||
|
|
||||||
|
export function InternalLoginForm() {
|
||||||
|
const [clave, setClave] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
/* await supabase.auth.signInWithPassword({
|
||||||
|
email: `${clave}@ulsa.mx`,
|
||||||
|
password,
|
||||||
|
})*/
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
submit()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoginInput label="Clave ULSA" value={clave} onChange={setClave} />
|
||||||
|
<LoginInput
|
||||||
|
label="Contraseña"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
/>
|
||||||
|
<SubmitButton />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { ExternalLoginForm } from './ExternalLoginForm.tsx'
|
||||||
|
import { InternalLoginForm } from './InternalLoginForm.tsx'
|
||||||
|
import { LoginTabs } from './LoginTabs.tsx'
|
||||||
|
|
||||||
|
export function LoginCard() {
|
||||||
|
const [type, setType] = useState<'internal' | 'external'>('internal')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-xl">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<img
|
||||||
|
src="/4_IMAGOTIPO_LASALLE_MEXICO_COLOR_RGB_2020.png"
|
||||||
|
alt="La Salle México"
|
||||||
|
className="mb-6 h-20 w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="mb-1 text-center text-2xl font-semibold">
|
||||||
|
Iniciar sesión
|
||||||
|
</h1>
|
||||||
|
<p className="mb-6 text-center text-sm text-gray-500">
|
||||||
|
Accede al Sistema de Planes de Estudio
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<LoginTabs value={type} onChange={setType} />
|
||||||
|
|
||||||
|
{type === 'internal' ? <InternalLoginForm /> : <ExternalLoginForm />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
interface Props {
|
||||||
|
value: 'internal' | 'external'
|
||||||
|
onChange: (v: 'internal' | 'external') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginTabs({ value, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex bg-gray-100 rounded-lg p-1 mb-6">
|
||||||
|
{[
|
||||||
|
{ key: 'internal', label: 'Interno' },
|
||||||
|
{ key: 'external', label: 'Externo' },
|
||||||
|
].map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => onChange(tab.key as any)}
|
||||||
|
className={`flex-1 py-2 rounded-md text-sm font-medium transition
|
||||||
|
${
|
||||||
|
value === tab.key
|
||||||
|
? 'bg-white shadow text-gray-900'
|
||||||
|
: 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
interface DashboardHeaderProps {
|
||||||
|
nombre: string
|
||||||
|
rol: string
|
||||||
|
facultad: string
|
||||||
|
saludo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardHeader({
|
||||||
|
nombre,
|
||||||
|
rol,
|
||||||
|
facultad,
|
||||||
|
saludo = 'Buenas noches,',
|
||||||
|
}: DashboardHeaderProps) {
|
||||||
|
// Generamos la URL de DiceBear dinámicamente con el nombre
|
||||||
|
const dicebearUrl = `https://api.dicebear.com/9.x/initials/svg?seed=${encodeURIComponent(nombre)}`
|
||||||
|
|
||||||
|
// Calculamos iniciales de respaldo por si falla la imagen
|
||||||
|
const initials = nombre
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n[0])
|
||||||
|
.slice(0, 2)
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col justify-between gap-4 rounded-xl border p-6 shadow-md md:flex-row md:items-center">
|
||||||
|
<div className="flex flex-row items-center gap-4">
|
||||||
|
{/* 1. Avatar de DiceBear usando el componente de Shadcn */}
|
||||||
|
<Avatar className="border-background h-12 w-12 border-2 shadow-sm">
|
||||||
|
<AvatarImage src={dicebearUrl} alt={nombre} />
|
||||||
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{/* Saludo con texto secundario */}
|
||||||
|
<p className="text-muted-foreground text-sm">{saludo}</p>
|
||||||
|
|
||||||
|
{/* Nombre destacado */}
|
||||||
|
<h2 className="text-foreground text-lg font-bold tracking-tight">
|
||||||
|
{nombre}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
{/* 2. El "Banner" (Badge) para el puesto */}
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="rounded-md px-2 py-0 text-xs font-semibold"
|
||||||
|
>
|
||||||
|
{rol}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* Departamento */}
|
||||||
|
<span className="text-muted-foreground text-xs font-medium">
|
||||||
|
{facultad}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row flex-wrap gap-6 sm:flex-nowrap">
|
||||||
|
<div className="bg-muted flex flex-row items-center gap-3 rounded-lg px-4 py-2">
|
||||||
|
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||||
|
Icono
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p>4</p>
|
||||||
|
<p>Planes activos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted flex flex-row items-center gap-3 rounded-lg px-4 py-2">
|
||||||
|
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-sm">
|
||||||
|
Icono
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p>3</p>
|
||||||
|
<p>Revisiones pendientes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Sidebar } from './Sidebar'
|
||||||
|
import { Header } from './Header'
|
||||||
|
import { StatsGrid } from '../plans/StatsGrid'
|
||||||
|
|
||||||
|
export function AppLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex bg-gray-100">
|
||||||
|
{/* <Sidebar /> */}
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
{/* Separación Header → Stats */}
|
||||||
|
<section className="mt-4 bg-white border-b">
|
||||||
|
<div className="px-6 py-6">
|
||||||
|
<StatsGrid />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main className="flex-1 p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export function Header() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 bg-white border-b shadow-[0_1px_2px_rgba(0,0,0,0.05)] z-10 relative">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-emerald-600 flex items-center justify-center text-white">
|
||||||
|
🎓
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900">
|
||||||
|
Gestión Curricular
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Sistema de Planes de Estudio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { LayoutGrid, BookOpen } from 'lucide-react'
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
return (
|
||||||
|
<aside className="w-64 bg-white border-r px-4 py-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-6">
|
||||||
|
Planes de Estudio
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<nav className="space-y-2">
|
||||||
|
<NavItem icon={LayoutGrid} label="Dashboard" active />
|
||||||
|
<NavItem icon={BookOpen} label="Planes" />
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavItem({ icon: Icon, label, active }: any) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer
|
||||||
|
${
|
||||||
|
active
|
||||||
|
? 'bg-gray-100 text-gray-900'
|
||||||
|
: 'text-gray-500 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { SearchIcon } from 'lucide-react'
|
||||||
|
import { useId } from 'react'
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BarraBusqueda: React.FC<Props> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Buscar…',
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const id = useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={['relative', className].filter(Boolean).join(' ')}>
|
||||||
|
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 left-0 flex items-center justify-center pl-3 peer-disabled:opacity-50">
|
||||||
|
<SearchIcon className="size-4" />
|
||||||
|
<span className="sr-only">Buscar</span>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="search"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="peer px-9 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none [&::-webkit-search-results-button]:appearance-none [&::-webkit-search-results-decoration]:appearance-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BarraBusqueda
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { CheckIcon, ChevronDown } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/command'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type Option = { value: string; label: string }
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
options: Array<Option>
|
||||||
|
value: string | null
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Filtro: React.FC<Props> = ({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Seleccionar…',
|
||||||
|
className,
|
||||||
|
ariaLabel,
|
||||||
|
disabled,
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const label = value
|
||||||
|
? (options.find((o) => o.value === value)?.label ?? placeholder)
|
||||||
|
: placeholder
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className={cn('w-full min-w-0 justify-between', className)}
|
||||||
|
aria-label={ariaLabel ?? 'Filtro combobox'}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
<ChevronDown className="shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{label}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<PopoverContent className="p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Buscar…" className="h-9" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>Sin resultados.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<CommandItem
|
||||||
|
key={opt.value}
|
||||||
|
value={opt.value}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
onChange(currentValue)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
'ml-auto',
|
||||||
|
value === opt.value ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Filtro
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { ArrowRight } from 'lucide-react'
|
||||||
|
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface PlanEstudiosCardProps {
|
||||||
|
Icono: LucideIcon
|
||||||
|
nombrePrograma: string
|
||||||
|
nivel: string
|
||||||
|
ciclos: string | number
|
||||||
|
facultad: string
|
||||||
|
estado: string
|
||||||
|
claseColorEstado?: string
|
||||||
|
colorEstadoHex?: string
|
||||||
|
colorFacultad: string
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlanEstudiosCard({
|
||||||
|
Icono,
|
||||||
|
nombrePrograma,
|
||||||
|
nivel,
|
||||||
|
ciclos,
|
||||||
|
facultad,
|
||||||
|
estado,
|
||||||
|
claseColorEstado = '',
|
||||||
|
colorEstadoHex,
|
||||||
|
colorFacultad,
|
||||||
|
onClick,
|
||||||
|
}: PlanEstudiosCardProps) {
|
||||||
|
const badgeStyle = colorEstadoHex
|
||||||
|
? ({
|
||||||
|
backgroundColor: colorEstadoHex,
|
||||||
|
borderColor: colorEstadoHex,
|
||||||
|
} as const)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'group relative flex h-full cursor-pointer flex-col justify-between overflow-hidden transition-all hover:shadow-lg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex grow flex-col">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
{/* Círculo del ícono con el color de la facultad */}
|
||||||
|
<div
|
||||||
|
className="mb-2 w-fit rounded-full p-2.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `color-mix(in srgb, ${colorFacultad}, transparent 85%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icono size={24} style={{ color: colorFacultad }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Título del Programa */}
|
||||||
|
<h4 className="line-clamp-2 text-lg leading-tight font-bold tracking-tight">
|
||||||
|
{nombrePrograma}
|
||||||
|
</h4>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="text-muted-foreground space-y-1 text-sm">
|
||||||
|
<p className="text-foreground font-medium">
|
||||||
|
{nivel} • {ciclos}
|
||||||
|
</p>
|
||||||
|
<p>{facultad}</p>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardFooter className="flex items-center justify-between pt-0 pb-6">
|
||||||
|
<Badge
|
||||||
|
style={badgeStyle}
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-semibold',
|
||||||
|
!colorEstadoHex && claseColorEstado,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-white [text-shadow:1px_1px_0_#000,-1px_-1px_0_#000,1px_-1px_0_#000,-1px_1px_0_#000,0_1px_0_#000,0_-1px_0_#000,1px_0_0_#000,-1px_0_0_#000]">
|
||||||
|
{estado}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* Flecha animada */}
|
||||||
|
<div
|
||||||
|
className="rounded-full p-1 transition-transform duration-300 group-hover:translate-x-1"
|
||||||
|
style={{ color: colorFacultad }}
|
||||||
|
>
|
||||||
|
<ArrowRight size={20} />
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
if (dbMessageId) {
|
||||||
|
updateAppliedStatus.mutate({
|
||||||
|
conversacionId: dbMessageId,
|
||||||
|
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={`bg-card rounded-2xl border p-5 shadow-sm transition-all ${
|
||||||
|
isApplied ? 'border-primary/30 bg-primary/5' : 'border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="text-foreground text-sm font-bold">{sug.label}</h3>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleApply(sug.key, sug.newValue)}
|
||||||
|
disabled={isApplied || !!isUpdating}
|
||||||
|
variant={isApplied ? 'secondary' : 'default'}
|
||||||
|
className="h-8 rounded-full px-4 text-xs transition-all"
|
||||||
|
>
|
||||||
|
{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-primary/20 bg-primary/10 text-foreground'
|
||||||
|
: 'border-border bg-muted/50 text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sug.newValue}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import type {
|
||||||
|
EstructuraPlanRow,
|
||||||
|
FacultadRow,
|
||||||
|
NivelPlanEstudio,
|
||||||
|
TipoCiclo,
|
||||||
|
} from '@/data/types/domain'
|
||||||
|
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
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,
|
||||||
|
}: {
|
||||||
|
wizard: NewPlanWizardState
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||||
|
}) {
|
||||||
|
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> */}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="nombrePlan"
|
||||||
|
placeholder="Ej. Ingeniería en Sistemas (2026)"
|
||||||
|
value={wizard.datosBasicos.nombrePlan}
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label htmlFor="facultad">Facultad</Label>
|
||||||
|
<Select
|
||||||
|
value={wizard.datosBasicos.facultad.id}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
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.facultad.id
|
||||||
|
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||||
|
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Ej. Facultad de Ingeniería" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{facultadesList.map((f: FacultadRow) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>
|
||||||
|
{f.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label htmlFor="carrera">Carrera</Label>
|
||||||
|
<Select
|
||||||
|
value={wizard.datosBasicos.carrera.id}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onChange(
|
||||||
|
(w): NewPlanWizardState => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: {
|
||||||
|
...w.datosBasicos,
|
||||||
|
carrera: {
|
||||||
|
id: value,
|
||||||
|
nombre:
|
||||||
|
filteredCarreras.find((c) => c.id === value)?.nombre ||
|
||||||
|
'',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!wizard.datosBasicos.facultad.id}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="carrera"
|
||||||
|
className={cn(
|
||||||
|
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||||
|
!wizard.datosBasicos.carrera.id
|
||||||
|
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||||
|
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Ej. Ingeniería en Cibernética y Sistemas Computacionales" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{filteredCarreras.map((c: any) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.nombre}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label htmlFor="nivel">Nivel</Label>
|
||||||
|
<Select
|
||||||
|
value={wizard.datosBasicos.nivel}
|
||||||
|
onValueChange={(value: NivelPlanEstudio) =>
|
||||||
|
onChange(
|
||||||
|
(w): NewPlanWizardState => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: { ...w.datosBasicos, nivel: value },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="nivel"
|
||||||
|
className={cn(
|
||||||
|
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||||
|
!wizard.datosBasicos.nivel
|
||||||
|
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||||
|
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Ej. Licenciatura" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{NIVELES.map((n) => (
|
||||||
|
<SelectItem key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
|
||||||
|
<Select
|
||||||
|
value={wizard.datosBasicos.tipoCiclo}
|
||||||
|
onValueChange={(value: TipoCiclo) =>
|
||||||
|
onChange(
|
||||||
|
(w): NewPlanWizardState => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: {
|
||||||
|
...w.datosBasicos,
|
||||||
|
tipoCiclo: value as any,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="tipoCiclo"
|
||||||
|
className={cn(
|
||||||
|
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
||||||
|
!wizard.datosBasicos.tipoCiclo
|
||||||
|
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
|
||||||
|
: 'font-medium not-italic', // Tiene Valor (Medium)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Ej. Semestre" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TIPOS_CICLO.map((t) => (
|
||||||
|
<SelectItem key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<Label htmlFor="numCiclos">Número de ciclos</Label>
|
||||||
|
<Input
|
||||||
|
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): 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" />
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<TemplateSelectorCard
|
||||||
|
cardTitle="Plantilla de plan de estudios"
|
||||||
|
cardDescription="Selecciona el Word para tu nuevo plan."
|
||||||
|
templatesData={PLANTILLAS_ANEXO_1}
|
||||||
|
selectedTemplateId={wizard.datosBasicos.plantillaPlanId || ''}
|
||||||
|
selectedVersion={wizard.datosBasicos.plantillaPlanVersion || ''}
|
||||||
|
onChange={({ templateId, version }) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: {
|
||||||
|
...w.datosBasicos,
|
||||||
|
plantillaPlanId: templateId,
|
||||||
|
plantillaPlanVersion: version,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TemplateSelectorCard
|
||||||
|
cardTitle="Plantilla de mapa curricular"
|
||||||
|
cardDescription="Selecciona el Excel para tu mapa curricular."
|
||||||
|
templatesData={PLANTILLAS_ANEXO_2}
|
||||||
|
selectedTemplateId={wizard.datosBasicos.plantillaMapaId || ''}
|
||||||
|
selectedVersion={wizard.datosBasicos.plantillaMapaVersion || ''}
|
||||||
|
onChange={({ templateId, version }) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: {
|
||||||
|
...w.datosBasicos,
|
||||||
|
plantillaMapaId: templateId,
|
||||||
|
plantillaMapaVersion: version,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type TemplateData = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
versions: Array<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default data (kept for backward compatibility if caller doesn't pass templates)
|
||||||
|
const DEFAULT_TEMPLATES_DATA: Array<TemplateData> = [
|
||||||
|
{
|
||||||
|
id: 'sep-2025',
|
||||||
|
name: 'Licenciatura RVOE SEP',
|
||||||
|
versions: ['v2025.2 (Vigente)', 'v2025.1', 'v2024.Final'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'interno-mix',
|
||||||
|
name: 'Estándar Institucional Mixto',
|
||||||
|
versions: ['v2.0', 'v1.5', 'v1.0-beta'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conacyt',
|
||||||
|
name: 'Formato Posgrado CONAHCYT',
|
||||||
|
versions: ['v3.0 (2025)', 'v2.8'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cardTitle?: string
|
||||||
|
cardDescription?: string
|
||||||
|
templatesData?: Array<TemplateData>
|
||||||
|
// Controlled selection (optional). If not provided, component manages its own state
|
||||||
|
selectedTemplateId?: string
|
||||||
|
selectedVersion?: string
|
||||||
|
onChange?: (sel: { templateId: string; version: string }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplateSelectorCard({
|
||||||
|
cardTitle = 'Configuración del Documento',
|
||||||
|
cardDescription = 'Selecciona la base para tu nuevo plan.',
|
||||||
|
templatesData = DEFAULT_TEMPLATES_DATA,
|
||||||
|
selectedTemplateId,
|
||||||
|
selectedVersion,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
const [internalTemplate, setInternalTemplate] = useState<string>('')
|
||||||
|
const [internalVersion, setInternalVersion] = useState<string>('')
|
||||||
|
|
||||||
|
const selectedTemplate = selectedTemplateId ?? internalTemplate
|
||||||
|
const version = selectedVersion ?? internalVersion
|
||||||
|
|
||||||
|
// Buscamos las versiones de la plantilla seleccionada
|
||||||
|
const currentTemplateData = useMemo(
|
||||||
|
() => templatesData.find((t) => t.id === selectedTemplate),
|
||||||
|
[templatesData, selectedTemplate],
|
||||||
|
)
|
||||||
|
const availableVersions = currentTemplateData?.versions || []
|
||||||
|
|
||||||
|
const handleTemplateChange = (value: string) => {
|
||||||
|
const template = templatesData.find((t) => t.id === value)
|
||||||
|
const firstVersion = template?.versions[0] ?? ''
|
||||||
|
if (onChange) {
|
||||||
|
onChange({ templateId: value, version: firstVersion })
|
||||||
|
} else {
|
||||||
|
setInternalTemplate(value)
|
||||||
|
setInternalVersion(firstVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVersionChange = (value: string) => {
|
||||||
|
if (onChange) {
|
||||||
|
onChange({ templateId: selectedTemplate, version: value })
|
||||||
|
} else {
|
||||||
|
setInternalVersion(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-lg gap-2 overflow-hidden">
|
||||||
|
<CardHeader className="px-4 pb-2 sm:px-6 sm:pb-4">
|
||||||
|
<CardTitle className="text-lg">{cardTitle}</CardTitle>
|
||||||
|
<CardDescription>{cardDescription}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
{/* SELECT 1: PRIMARIO (Llamativo) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="template-select"
|
||||||
|
className="text-foreground text-base font-semibold"
|
||||||
|
>
|
||||||
|
Plantilla
|
||||||
|
</Label>
|
||||||
|
<Select value={selectedTemplate} onValueChange={handleTemplateChange}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="template-select"
|
||||||
|
className="bg-background border-primary/40 focus:ring-primary/20 focus:border-primary flex h-11 w-full min-w-0 items-center justify-between gap-2 text-base shadow-sm [&>span]:block! [&>span]:truncate! [&>span]:text-left"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Selecciona una plantilla..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{templatesData.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id} className="font-medium">
|
||||||
|
{t.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SELECT 2: SECUNDARIO (Sutil) */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label
|
||||||
|
htmlFor="version-select"
|
||||||
|
className={cn(
|
||||||
|
'text-xs tracking-wider uppercase transition-colors',
|
||||||
|
!selectedTemplate
|
||||||
|
? 'text-muted-foreground/50'
|
||||||
|
: 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Versión
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={version}
|
||||||
|
onValueChange={handleVersionChange}
|
||||||
|
disabled={!selectedTemplate}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="version-select"
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 min-w-0 items-center justify-between gap-2 text-sm transition-all duration-300',
|
||||||
|
/* AQUÍ ESTÁ EL CAMBIO DE ANCHO: */
|
||||||
|
'w-full max-w-full sm:w-55',
|
||||||
|
|
||||||
|
/* Las correcciones vitales para truncado que ya teníamos: */
|
||||||
|
'min-w-0 [&>span]:block! [&>span]:truncate! [&>span]:text-left',
|
||||||
|
'[&>span]:block [&>span]:min-w-0 [&>span]:truncate [&>span]:text-left',
|
||||||
|
|
||||||
|
!selectedTemplate
|
||||||
|
? 'bg-muted/50 cursor-not-allowed border-transparent opacity-50'
|
||||||
|
: 'bg-muted/20 border-border hover:bg-background hover:border-primary/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
!selectedTemplate
|
||||||
|
? '— Esperando plantilla —'
|
||||||
|
: 'Selecciona versión'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableVersions.map((v) => (
|
||||||
|
<SelectItem
|
||||||
|
key={v}
|
||||||
|
value={v}
|
||||||
|
className="text-muted-foreground focus:text-foreground text-sm"
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
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>>(persistentFiles ?? [])
|
||||||
|
const onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange)
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
const prevFilesLengthRef = useRef(files.length)
|
||||||
|
|
||||||
|
const addFiles = useCallback(
|
||||||
|
(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()
|
||||||
|
setIsDragging(false)
|
||||||
|
const droppedFiles = Array.from(e.dataTransfer.files)
|
||||||
|
addFiles(droppedFiles)
|
||||||
|
},
|
||||||
|
[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((previousFiles) => {
|
||||||
|
console.log(
|
||||||
|
'previous files',
|
||||||
|
previousFiles.map((f) => f.file.name),
|
||||||
|
)
|
||||||
|
const remainingFiles = previousFiles.filter(
|
||||||
|
(uploadedFile) => uploadedFile.id !== fileId,
|
||||||
|
)
|
||||||
|
return remainingFiles
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Mantener la referencia actualizada de la función callback externa para evitar loops en useEffect
|
||||||
|
useEffect(() => {
|
||||||
|
onFilesChangeRef.current = onFilesChange
|
||||||
|
}, [onFilesChange])
|
||||||
|
|
||||||
|
// Notificar al componente padre cuando cambia la lista de archivos
|
||||||
|
useEffect(() => {
|
||||||
|
if (onFilesChangeRef.current) onFilesChangeRef.current(files)
|
||||||
|
}, [files])
|
||||||
|
|
||||||
|
// 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':
|
||||||
|
return <FileText className="text-destructive h-4 w-4" />
|
||||||
|
case 'doc':
|
||||||
|
case 'docx':
|
||||||
|
return <FileText className="text-info h-4 w-4" />
|
||||||
|
default:
|
||||||
|
return <File className="text-muted-foreground h-4 w-4" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
'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
|
||||||
|
type="file"
|
||||||
|
accept={acceptedTypes}
|
||||||
|
multiple
|
||||||
|
onChange={handleFileInput}
|
||||||
|
className="hidden"
|
||||||
|
id="file-upload"
|
||||||
|
disabled={files.length >= maxFiles}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="file-upload"
|
||||||
|
className="cursor-pointer"
|
||||||
|
aria-label="Seleccionar archivos"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-12 w-12 items-center justify-center rounded-xl transition-colors',
|
||||||
|
isDragging
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-accent text-accent-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-foreground text-sm font-medium">{title}</p>
|
||||||
|
{/* <p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
{description}
|
||||||
|
</p> */}
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Formatos:{' '}
|
||||||
|
{acceptedTypes
|
||||||
|
.replace(/\./g, '')
|
||||||
|
.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>
|
||||||
|
|
||||||
|
{/* 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"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import { FileDropzone } from './FileDropZone'
|
||||||
|
import ReferenciasParaIA from './ReferenciasParaIA'
|
||||||
|
|
||||||
|
import type { UploadedFile } from './FileDropZone'
|
||||||
|
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
CARRERAS,
|
||||||
|
FACULTADES,
|
||||||
|
PLANES_EXISTENTES,
|
||||||
|
} from '@/features/planes/nuevo/catalogs'
|
||||||
|
|
||||||
|
export function PasoDetallesPanel({
|
||||||
|
wizard,
|
||||||
|
onChange,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
wizard: NewPlanWizardState
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||||
|
isLoading: boolean
|
||||||
|
}) {
|
||||||
|
if (wizard.tipoOrigen === 'MANUAL') {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Creación manual</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Se creará un plan en blanco con estructura mínima.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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="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)),
|
||||||
|
descripcionEnfoqueAcademico: e.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<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="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)),
|
||||||
|
instruccionesAdicionalesIA: e.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ReferenciasParaIA
|
||||||
|
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
|
||||||
|
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
|
||||||
|
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
|
||||||
|
onToggleArchivo={(id, checked) =>
|
||||||
|
onChange((w): NewPlanWizardState => {
|
||||||
|
const prev = w.iaConfig?.archivosReferencia || []
|
||||||
|
const next = checked
|
||||||
|
? [...prev, id]
|
||||||
|
: prev.filter((x) => x !== id)
|
||||||
|
return {
|
||||||
|
...w,
|
||||||
|
iaConfig: {
|
||||||
|
...(w.iaConfig || ({} as any)),
|
||||||
|
archivosReferencia: next,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onToggleRepositorio={(id, checked) =>
|
||||||
|
onChange((w): NewPlanWizardState => {
|
||||||
|
const prev = w.iaConfig?.repositoriosReferencia || []
|
||||||
|
const next = checked
|
||||||
|
? [...prev, id]
|
||||||
|
: prev.filter((x) => x !== id)
|
||||||
|
return {
|
||||||
|
...w,
|
||||||
|
iaConfig: {
|
||||||
|
...(w.iaConfig || ({} as any)),
|
||||||
|
repositoriosReferencia: next,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onFilesChange={(files: Array<UploadedFile>) =>
|
||||||
|
onChange(
|
||||||
|
(w): NewPlanWizardState => ({
|
||||||
|
...w,
|
||||||
|
iaConfig: {
|
||||||
|
...(w.iaConfig || ({} as any)),
|
||||||
|
archivosAdjuntos: files,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="clonFacultad">Facultad</Label>
|
||||||
|
<select
|
||||||
|
id="clonFacultad"
|
||||||
|
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
|
aria-label="Facultad"
|
||||||
|
value={wizard.datosBasicos.facultadId}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: {
|
||||||
|
...w.datosBasicos,
|
||||||
|
facultadId: e.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Todas</option>
|
||||||
|
{FACULTADES.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>
|
||||||
|
{f.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="clonCarrera">Carrera</Label>
|
||||||
|
<select
|
||||||
|
id="clonCarrera"
|
||||||
|
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
|
aria-label="Carrera"
|
||||||
|
value={wizard.datosBasicos.carreraId}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
datosBasicos: {
|
||||||
|
...w.datosBasicos,
|
||||||
|
carreraId: e.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Todas</option>
|
||||||
|
{CARRERAS.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.nombre}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="buscarPlan">Buscar</Label>
|
||||||
|
<Input
|
||||||
|
id="buscarPlan"
|
||||||
|
placeholder="Nombre del plan…"
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const term = e.target.value.toLowerCase()
|
||||||
|
void term
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{PLANES_EXISTENTES.filter(
|
||||||
|
(p) =>
|
||||||
|
(!wizard.datosBasicos.facultadId ||
|
||||||
|
p.facultadId === wizard.datosBasicos.facultadId) &&
|
||||||
|
(!wizard.datosBasicos.carreraId ||
|
||||||
|
p.carreraId === wizard.datosBasicos.carreraId),
|
||||||
|
).map((p) => (
|
||||||
|
<Card
|
||||||
|
key={p.id}
|
||||||
|
className={
|
||||||
|
p.id === wizard.clonInterno?.planOrigenId
|
||||||
|
? 'ring-ring ring-2'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
onChange((w) => ({ ...w, clonInterno: { planOrigenId: p.id } }))
|
||||||
|
}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span>{p.nombre}</span>
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{p.estado} · {p.anio}
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>ID: {p.id}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label htmlFor="word">Word del plan (obligatorio)</Label>
|
||||||
|
{/* <input
|
||||||
|
id="word"
|
||||||
|
type="file"
|
||||||
|
accept=".doc,.docx"
|
||||||
|
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
clonTradicional: {
|
||||||
|
...(w.clonTradicional || ({} as any)),
|
||||||
|
archivoWordPlanId: e.target.files?.[0]
|
||||||
|
? `file_${e.target.files[0].name}`
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
<FileDropzone
|
||||||
|
acceptedTypes=".doc,.docx"
|
||||||
|
maxFiles={1}
|
||||||
|
onFilesChange={(files) => {
|
||||||
|
const f = files[0] || null
|
||||||
|
onChange((w) => ({
|
||||||
|
...w,
|
||||||
|
clonTradicional: {
|
||||||
|
...(w.clonTradicional || ({} as any)),
|
||||||
|
archivoWordPlanId: f,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="mapa">Excel del mapa curricular</Label>
|
||||||
|
<input
|
||||||
|
id="mapa"
|
||||||
|
type="file"
|
||||||
|
accept=".xls,.xlsx"
|
||||||
|
title="Subir mapa curricular"
|
||||||
|
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
onChange((w) => {
|
||||||
|
const file = e.target.files?.[0] || null
|
||||||
|
const next = 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',
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
return {
|
||||||
|
...w,
|
||||||
|
clonTradicional: {
|
||||||
|
...(w.clonTradicional || ({} as any)),
|
||||||
|
archivoMapaExcelId: next,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="asignaturas">Excel/listado de asignaturas</Label>
|
||||||
|
<input
|
||||||
|
id="asignaturas"
|
||||||
|
type="file"
|
||||||
|
accept=".xls,.xlsx,.csv"
|
||||||
|
title="Subir listado de asignaturas"
|
||||||
|
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
onChange((w) => {
|
||||||
|
const file = e.target.files?.[0] || null
|
||||||
|
const next = 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',
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
return {
|
||||||
|
...w,
|
||||||
|
clonTradicional: {
|
||||||
|
...(w.clonTradicional || ({} as any)),
|
||||||
|
archivoAsignaturasExcelId: next,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
Sube al menos Word y uno de los Excel para continuar.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Selecciona un modo</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Elige una opción en el paso anterior para continuar.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { FileText, FolderOpen, Upload } from 'lucide-react'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
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<UploadedFile>) => void
|
||||||
|
}) => {
|
||||||
|
const [busquedaArchivos, setBusquedaArchivos] = useState('')
|
||||||
|
const [busquedaRepositorios, setBusquedaRepositorios] = useState('')
|
||||||
|
|
||||||
|
const cleanText = (text: string) => {
|
||||||
|
return text
|
||||||
|
.normalize('NFD') // Descompone "á" en "a" + "´"
|
||||||
|
.replace(/[\u0300-\u036f]/g, '') // Elimina los símbolos diacríticos
|
||||||
|
.toLowerCase() // Convierte a minúsculas
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrado de archivos y de repositorios
|
||||||
|
const archivosFiltrados = useMemo(() => {
|
||||||
|
// Función helper para limpiar texto (quita acentos y hace minúsculas)
|
||||||
|
|
||||||
|
const term = cleanText(busquedaArchivos)
|
||||||
|
return ARCHIVOS.filter((archivo) =>
|
||||||
|
cleanText(archivo.nombre).includes(term),
|
||||||
|
)
|
||||||
|
}, [busquedaArchivos])
|
||||||
|
|
||||||
|
const repositoriosFiltrados = useMemo(() => {
|
||||||
|
const term = cleanText(busquedaRepositorios)
|
||||||
|
return REPOSITORIOS.filter((repositorio) =>
|
||||||
|
cleanText(repositorio.nombre).includes(term),
|
||||||
|
)
|
||||||
|
}, [busquedaRepositorios])
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
name: 'Archivos existentes',
|
||||||
|
|
||||||
|
value: 'archivos-existentes',
|
||||||
|
|
||||||
|
icon: FileText,
|
||||||
|
|
||||||
|
content: (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<BarraBusqueda
|
||||||
|
value={busquedaArchivos}
|
||||||
|
onChange={setBusquedaArchivos}
|
||||||
|
placeholder="Buscar archivo existente..."
|
||||||
|
className="m-1 mb-1.5"
|
||||||
|
/>
|
||||||
|
<div className="flex h-96 flex-col gap-0.5 overflow-y-auto">
|
||||||
|
{archivosFiltrados.map((archivo) => (
|
||||||
|
<Label
|
||||||
|
key={archivo.id}
|
||||||
|
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center 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={selectedArchivoIds.includes(archivo.id)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onToggleArchivo?.(archivo.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 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" />
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-foreground truncate text-sm font-medium">
|
||||||
|
{archivo.nombre}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{archivo.tamaño}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'Repositorios',
|
||||||
|
|
||||||
|
value: 'repositorios',
|
||||||
|
|
||||||
|
icon: FolderOpen,
|
||||||
|
|
||||||
|
content: (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<BarraBusqueda
|
||||||
|
value={busquedaRepositorios}
|
||||||
|
onChange={setBusquedaRepositorios}
|
||||||
|
placeholder="Buscar repositorio..."
|
||||||
|
className="m-1 mb-1.5"
|
||||||
|
/>
|
||||||
|
<div className="flex h-96 flex-col gap-0.5 overflow-y-auto">
|
||||||
|
{repositoriosFiltrados.map((repositorio) => (
|
||||||
|
<Label
|
||||||
|
key={repositorio.id}
|
||||||
|
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center 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={selectedRepositorioIds.includes(repositorio.id)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onToggleRepositorio?.(repositorio.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 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" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-foreground text-sm font-medium">
|
||||||
|
{repositorio.nombre}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{repositorio.descripcion} · {repositorio.cantidadArchivos}{' '}
|
||||||
|
archivos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'Subir archivos',
|
||||||
|
|
||||||
|
value: 'subir-archivos',
|
||||||
|
|
||||||
|
icon: Upload,
|
||||||
|
|
||||||
|
content: (
|
||||||
|
<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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<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">
|
||||||
|
{tabs.map(({ icon: Icon, name, value }) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={value}
|
||||||
|
value={value}
|
||||||
|
className="flex items-center gap-1 px-2.5 sm:px-3"
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
|
||||||
|
<span className="hidden sm:inline">{name}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContents className="bg-background mx-1 -mt-2 mb-1 h-full rounded-sm">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabsContent
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
className="animate-in fade-in duration-300 ease-out"
|
||||||
|
>
|
||||||
|
{tab.content}
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</TabsContents>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReferenciasParaIA
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
|
import type { TipoOrigen } from '@/data/types/domain'
|
||||||
|
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
|
||||||
|
export function PasoModoCardGroup({
|
||||||
|
wizard,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
wizard: NewPlanWizardState
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
|
||||||
|
}) {
|
||||||
|
const isSelected = (m: TipoOrigen) => wizard.tipoOrigen === m
|
||||||
|
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): NewPlanWizardState => ({
|
||||||
|
...w,
|
||||||
|
tipoOrigen: 'MANUAL',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onKeyDown={(e: React.KeyboardEvent) =>
|
||||||
|
handleKeyActivate(e, () =>
|
||||||
|
onChange(
|
||||||
|
(w): NewPlanWizardState => ({
|
||||||
|
...w,
|
||||||
|
tipoOrigen: 'MANUAL',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icons.Pencil className="text-primary h-5 w-5" /> Manual
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Plan vacío con estructura mínima.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={isSelected('IA') ? 'ring-ring ring-2' : ''}
|
||||||
|
onClick={() =>
|
||||||
|
onChange(
|
||||||
|
(w): NewPlanWizardState => ({
|
||||||
|
...w,
|
||||||
|
tipoOrigen: 'IA',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onKeyDown={(e: React.KeyboardEvent) =>
|
||||||
|
handleKeyActivate(e, () =>
|
||||||
|
onChange(
|
||||||
|
(w): NewPlanWizardState => ({
|
||||||
|
...w,
|
||||||
|
tipoOrigen: 'IA',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icons.Sparkles className="text-primary h-5 w-5" /> Con IA
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Borrador completo a partir de datos base.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={isSelected('OTRO') ? 'ring-ring ring-2' : ''}
|
||||||
|
onClick={() =>
|
||||||
|
onChange((w): NewPlanWizardState => ({ ...w, tipoOrigen: 'OTRO' }))
|
||||||
|
}
|
||||||
|
onKeyDown={(e: React.KeyboardEvent) =>
|
||||||
|
handleKeyActivate(e, () =>
|
||||||
|
onChange((w): NewPlanWizardState => ({ ...w, tipoOrigen: 'OTRO' })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Icons.Copy className="text-primary h-5 w-5" /> Clonado
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Desde un plan existente o archivos.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
{(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): NewPlanWizardState => ({
|
||||||
|
...w,
|
||||||
|
tipoOrigen: 'CLONADO_INTERNO',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e: React.KeyboardEvent) =>
|
||||||
|
handleKeyActivate(e, () =>
|
||||||
|
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 ${
|
||||||
|
isSelected('CLONADO_INTERNO')
|
||||||
|
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
||||||
|
: 'border-border text-muted-foreground'
|
||||||
|
} `}
|
||||||
|
>
|
||||||
|
<Icons.Database className="mb-1 h-6 w-6" />
|
||||||
|
<span className="text-sm font-medium">Del sistema</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onChange(
|
||||||
|
(w): NewPlanWizardState => ({
|
||||||
|
...w,
|
||||||
|
tipoOrigen: 'CLONADO_TRADICIONAL',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onKeyDown={(e: React.KeyboardEvent) =>
|
||||||
|
handleKeyActivate(e, () =>
|
||||||
|
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 ${
|
||||||
|
isSelected('CLONADO_TRADICIONAL')
|
||||||
|
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
||||||
|
: 'border-border text-muted-foreground'
|
||||||
|
} `}
|
||||||
|
>
|
||||||
|
<Icons.Upload className="mb-1 h-6 w-6" />
|
||||||
|
<span className="text-sm font-medium">Desde archivos</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import type { UploadedFile } from './PasoDetallesPanel/FileDropZone'
|
||||||
|
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import {
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Resumen</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Verifica la información antes de crear.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-2 text-sm">
|
||||||
|
{(() => {
|
||||||
|
// Precompute common derived values to avoid unnecessary optional chaining warnings
|
||||||
|
const archivosRef = wizard.iaConfig?.archivosReferencia ?? []
|
||||||
|
const repositoriosRef =
|
||||||
|
wizard.iaConfig?.repositoriosReferencia ?? []
|
||||||
|
const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? []
|
||||||
|
const contenido = (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Nombre: </span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{wizard.datosBasicos.nombrePlan || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Facultad/Carrera:{' '}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{wizard.datosBasicos.facultad.nombre || '—'} /{' '}
|
||||||
|
{wizard.datosBasicos.carrera.nombre || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Nivel: </span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{wizard.datosBasicos.nivel || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Ciclos: </span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{wizard.datosBasicos.numCiclos} (
|
||||||
|
{wizard.datosBasicos.tipoCiclo})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="text-muted-foreground">Modo: </span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{wizard.tipoOrigen === 'MANUAL' && 'Manual'}
|
||||||
|
{wizard.tipoOrigen === 'IA' && 'Generado con IA'}
|
||||||
|
{wizard.tipoOrigen === 'CLONADO_INTERNO' &&
|
||||||
|
'Clonado desde plan del sistema'}
|
||||||
|
{wizard.tipoOrigen === 'CLONADO_TRADICIONAL' &&
|
||||||
|
'Importado desde documentos tradicionales'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{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?.descripcionEnfoqueAcademico || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Notas: </span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{wizard.iaConfig?.instruccionesAdicionalesIA || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{archivosRef.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="font-medium">Archivos existentes</div>
|
||||||
|
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||||
|
{archivosRef.map((id) => {
|
||||||
|
const a = ARCHIVOS.find((x) => x.id === id)
|
||||||
|
return (
|
||||||
|
<li key={id}>
|
||||||
|
<span className="text-foreground">
|
||||||
|
{a?.nombre || id}
|
||||||
|
</span>{' '}
|
||||||
|
{a?.tamaño ? <span>· {a.tamaño}</span> : null}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{repositoriosRef.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="font-medium">Repositorios</div>
|
||||||
|
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||||
|
{repositoriosRef.map((id) => {
|
||||||
|
const r = REPOSITORIOS.find((x) => x.id === id)
|
||||||
|
return (
|
||||||
|
<li key={id}>
|
||||||
|
<span className="text-foreground">
|
||||||
|
{r?.nombre || id}
|
||||||
|
</span>{' '}
|
||||||
|
{r?.cantidadArchivos ? (
|
||||||
|
<span>· {r.cantidadArchivos} archivos</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{adjuntos.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="font-medium">Adjuntos</div>
|
||||||
|
<ul className="text-muted-foreground list-disc pl-5 text-xs">
|
||||||
|
{adjuntos.map((f: UploadedFile) => (
|
||||||
|
<li key={f.id}>
|
||||||
|
<span className="text-foreground">
|
||||||
|
{f.file.name}
|
||||||
|
</span>{' '}
|
||||||
|
<span>· {formatFileSize(f.file.size)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{wizard.resumen.previewPlan && (
|
||||||
|
<div className="bg-muted mt-2 rounded-md p-3">
|
||||||
|
<div className="font-medium">Preview IA</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Asignaturas aprox.:{' '}
|
||||||
|
{wizard.resumen.previewPlan.numAsignaturasAprox}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
return contenido
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +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,
|
||||||
|
disablePrev,
|
||||||
|
disableNext,
|
||||||
|
disableCreate,
|
||||||
|
isLastStep,
|
||||||
|
wizard,
|
||||||
|
setWizard,
|
||||||
|
}: {
|
||||||
|
errorMessage?: string | null
|
||||||
|
onPrev: () => void
|
||||||
|
onNext: () => 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 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="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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
|
||||||
|
import { StepWithTooltip } from './StepWithTooltip'
|
||||||
|
|
||||||
|
import { CircularProgress } from '@/components/CircularProgress'
|
||||||
|
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
export function WizardHeader({
|
||||||
|
currentIndex,
|
||||||
|
totalSteps,
|
||||||
|
currentTitle,
|
||||||
|
currentDescription,
|
||||||
|
nextTitle,
|
||||||
|
onClose,
|
||||||
|
Wizard,
|
||||||
|
}: {
|
||||||
|
currentIndex: number
|
||||||
|
totalSteps: number
|
||||||
|
currentTitle: string
|
||||||
|
currentDescription: string
|
||||||
|
nextTitle?: string
|
||||||
|
onClose: () => void
|
||||||
|
Wizard: any
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="z-10 flex-none border-b bg-white">
|
||||||
|
<div className="flex items-center justify-between p-6 pb-4">
|
||||||
|
<DialogHeader className="p-0">
|
||||||
|
<DialogTitle>Nuevo plan de estudios</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<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={currentTitle}
|
||||||
|
desc={currentDescription}
|
||||||
|
/>
|
||||||
|
</h2>
|
||||||
|
{nextTitle ? (
|
||||||
|
<p className="text-sm text-slate-400">Siguiente: {nextTitle}</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">
|
||||||
|
{Wizard.steps.map((step: any) => (
|
||||||
|
<Wizard.Stepper.Step
|
||||||
|
key={step.id}
|
||||||
|
of={step.id}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Wizard.Stepper.Title>
|
||||||
|
<StepWithTooltip title={step.title} desc={step.description} />
|
||||||
|
</Wizard.Stepper.Title>
|
||||||
|
</Wizard.Stepper.Step>
|
||||||
|
))}
|
||||||
|
</Wizard.Stepper.Navigation>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
|
const CheckboxCardDemo = () => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="border-border hover:border-primary/30 hover:bg-accent/50 flex cursor-pointer items-center items-start gap-2 gap-3 rounded-lg border p-3 transition-colors has-[[aria-checked=true]]:border-blue-600 has-[[aria-checked=true]]:bg-blue-50 dark:has-[[aria-checked=true]]:border-blue-900 dark:has-[[aria-checked=true]]:bg-blue-950">
|
||||||
|
<Checkbox
|
||||||
|
defaultChecked
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<div className="grid gap-1.5 font-normal">
|
||||||
|
<p className="text-sm leading-none font-medium">Auto Start</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Starting with your OS.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
<Label className="hover:bg-accent/50 flex items-start gap-2 rounded-lg border p-3 has-[[aria-checked=true]]:border-blue-600 has-[[aria-checked=true]]:bg-blue-50 dark:has-[[aria-checked=true]]:border-blue-900 dark:has-[[aria-checked=true]]:bg-blue-950">
|
||||||
|
<Checkbox 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" />
|
||||||
|
<div className="grid gap-1.5 font-normal">
|
||||||
|
<p className="text-sm leading-none font-medium">Auto update</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Download and install new version
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CheckboxCardDemo
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from '@/components/ui/pagination'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type Pagination03Props = {
|
||||||
|
page: number
|
||||||
|
pageCount: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const toInt = (n: unknown, fallback: number) => {
|
||||||
|
const x = typeof n === 'number' ? n : Number(n)
|
||||||
|
return Number.isFinite(x) ? Math.floor(x) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPageItems(page: number, pageCount: number): Array<number | '...'> {
|
||||||
|
if (pageCount <= 7) {
|
||||||
|
return Array.from({ length: pageCount }, (_, i) => i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: Array<number | '...'> = []
|
||||||
|
const safePage = Math.min(Math.max(page, 1), pageCount)
|
||||||
|
|
||||||
|
items.push(1)
|
||||||
|
|
||||||
|
const start = Math.max(2, safePage - 1)
|
||||||
|
const end = Math.min(pageCount - 1, safePage + 1)
|
||||||
|
|
||||||
|
if (start > 2) items.push('...')
|
||||||
|
for (let p = start; p <= end; p++) items.push(p)
|
||||||
|
if (end < pageCount - 1) items.push('...')
|
||||||
|
|
||||||
|
items.push(pageCount)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination03({
|
||||||
|
page,
|
||||||
|
pageCount,
|
||||||
|
onPageChange,
|
||||||
|
className,
|
||||||
|
}: Pagination03Props) {
|
||||||
|
const safePageCount = Math.max(1, toInt(pageCount, 1))
|
||||||
|
const safePage = Math.min(Math.max(toInt(page, 1), 1), safePageCount)
|
||||||
|
|
||||||
|
const items = getPageItems(safePage, safePageCount)
|
||||||
|
const canPrev = safePage > 1
|
||||||
|
const canNext = safePage < safePageCount
|
||||||
|
|
||||||
|
const go = (p: number) => {
|
||||||
|
const next = Math.min(Math.max(p, 1), safePageCount)
|
||||||
|
if (next === safePage) return
|
||||||
|
onPageChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pagination className={className}>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
className={cn(!canPrev && 'pointer-events-none opacity-50')}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canPrev) return
|
||||||
|
go(safePage - 1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{items.map((it, idx) =>
|
||||||
|
it === '...' ? (
|
||||||
|
<PaginationItem key={`ellipsis-${idx}`}>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
) : (
|
||||||
|
<PaginationItem key={it}>
|
||||||
|
<PaginationLink
|
||||||
|
href="#"
|
||||||
|
isActive={it === safePage}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
go(it)
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
it === safePage
|
||||||
|
? cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: 'default',
|
||||||
|
size: 'icon',
|
||||||
|
}),
|
||||||
|
'hover:text-primary-foreground! dark:bg-primary dark:text-primary-foreground dark:hover:text-primary-foreground dark:hover:bg-primary/90 shadow-none! dark:border-transparent',
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{it}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href="#"
|
||||||
|
className={cn(!canNext && 'pointer-events-none opacity-50')}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canNext) return
|
||||||
|
go(safePage + 1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Pagination03
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { BookIcon, GiftIcon, HeartIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import CheckboxCardDemo from '../checkbox/checkbox-13'
|
||||||
|
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
name: 'Explore',
|
||||||
|
value: 'explore',
|
||||||
|
icon: BookIcon,
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<CheckboxCardDemo />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Favorites',
|
||||||
|
value: 'favorites',
|
||||||
|
icon: HeartIcon,
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
All your{' '}
|
||||||
|
<span className="text-foreground font-semibold">favorites</span> are
|
||||||
|
saved here. Revisit articles, collections, and moments you love, any
|
||||||
|
time you want a little inspiration.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Surprise',
|
||||||
|
value: 'surprise',
|
||||||
|
icon: GiftIcon,
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<span className="text-foreground font-semibold">Surprise!</span>{' '}
|
||||||
|
Here's something unexpected—a fun fact, a quirky tip, or a daily
|
||||||
|
challenge. Come back for a new surprise every day!
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const TabsWithIconDemo = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Tabs defaultValue="explore" className="gap-4">
|
||||||
|
<TabsList className="w-full">
|
||||||
|
{tabs.map(({ icon: Icon, name, value }) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={value}
|
||||||
|
value={value}
|
||||||
|
className="flex items-center gap-1 px-2.5 sm:px-3"
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
{name}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabsContent
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
className="animate-in fade-in duration-300 ease-out"
|
||||||
|
>
|
||||||
|
<p className="text-muted-foreground text-sm">{tab.content}</p>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TabsWithIconDemo
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Tabs, TabsContent, TabsContents, TabsList, TabsTrigger } from '@/components/ui/motion-tabs'
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
name: 'Explore',
|
||||||
|
value: 'explore',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
Discover <span className='text-foreground font-semibold'>fresh ideas</span>, trending topics, and hidden gems
|
||||||
|
curated just for you. Start exploring and let your curiosity lead the way!
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Favorites',
|
||||||
|
value: 'favorites',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
All your <span className='text-foreground font-semibold'>favorites</span> are saved here. Revisit articles,
|
||||||
|
collections, and moments you love, any time you want a little inspiration.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Surprise Me',
|
||||||
|
value: 'surprise',
|
||||||
|
content: (
|
||||||
|
<>
|
||||||
|
<span className='text-foreground font-semibold'>Surprise!</span> Here's something unexpected—a fun fact, a
|
||||||
|
quirky tip, or a daily challenge. Come back for a new surprise every day!
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const AnimatedTabsDemo = () => {
|
||||||
|
return (
|
||||||
|
<div className='w-full max-w-md'>
|
||||||
|
<Tabs defaultValue='explore' className='gap-4'>
|
||||||
|
<TabsList>
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<TabsTrigger key={tab.value} value={tab.value}>
|
||||||
|
{tab.name}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContents className='bg-background mx-1 -mt-2 mb-1 h-full rounded-sm'>
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<TabsContent key={tab.value} value={tab.value}>
|
||||||
|
<p className='text-muted-foreground text-sm'>{tab.content}</p>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</TabsContents>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<p className='text-muted-foreground mt-4 text-center text-xs'>
|
||||||
|
Inspired by{' '}
|
||||||
|
<a
|
||||||
|
className='hover:text-foreground underline'
|
||||||
|
href='https://animate-ui.com/docs/components/tabs'
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
>
|
||||||
|
Animate UI
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnimatedTabsDemo
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import * as Stepperize from '@stepperize/react'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const StepperContext = React.createContext<Stepper.ConfigProps | null>(null)
|
||||||
|
|
||||||
|
const useStepperProvider = (): Stepper.ConfigProps => {
|
||||||
|
const context = React.useContext(StepperContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useStepper must be used within a StepperProvider.')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const defineStepper = <const Steps extends Array<Stepperize.Step>>(
|
||||||
|
...steps: Steps
|
||||||
|
): Stepper.DefineProps<Steps> => {
|
||||||
|
const { Scoped, useStepper, ...rest } = Stepperize.defineStepper(...steps)
|
||||||
|
|
||||||
|
const StepperContainer = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<'div'>, 'children'> & {
|
||||||
|
children:
|
||||||
|
| React.ReactNode
|
||||||
|
| ((props: { methods: Stepperize.Stepper<Steps> }) => React.ReactNode)
|
||||||
|
}) => {
|
||||||
|
const methods = useStepper()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
date-component="stepper"
|
||||||
|
className={cn('w-full', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{typeof children === 'function' ? children({ methods }) : children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
useStepper,
|
||||||
|
Stepper: {
|
||||||
|
Provider: ({
|
||||||
|
variant = 'horizontal',
|
||||||
|
labelOrientation = 'horizontal',
|
||||||
|
tracking = false,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
// Avoid leaking non-DOM props like `initialStep` onto the div
|
||||||
|
const { initialStep, initialMetadata, ...restProps } = props as {
|
||||||
|
initialStep?: any
|
||||||
|
initialMetadata?: any
|
||||||
|
} & Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<StepperContext.Provider
|
||||||
|
value={{ variant, labelOrientation, tracking }}
|
||||||
|
>
|
||||||
|
<Scoped initialStep={initialStep} initialMetadata={initialMetadata}>
|
||||||
|
<StepperContainer className={className} {...(restProps as any)}>
|
||||||
|
{children}
|
||||||
|
</StepperContainer>
|
||||||
|
</Scoped>
|
||||||
|
</StepperContext.Provider>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Navigation: ({
|
||||||
|
children,
|
||||||
|
'aria-label': ariaLabel = 'Stepper Navigation',
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const { variant } = useStepperProvider()
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
date-component="stepper-navigation"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
role="tablist"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ol
|
||||||
|
date-component="stepper-navigation-list"
|
||||||
|
className={classForNavigationList({ variant: variant })}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Step: ({ children, className, icon, ...props }) => {
|
||||||
|
const { variant, labelOrientation } = useStepperProvider()
|
||||||
|
const { current } = useStepper()
|
||||||
|
|
||||||
|
const utils = rest.utils
|
||||||
|
const steps = rest.steps
|
||||||
|
|
||||||
|
const stepIndex = utils.getIndex(props.of)
|
||||||
|
const step = steps[stepIndex]
|
||||||
|
const currentIndex = utils.getIndex(current.id)
|
||||||
|
|
||||||
|
const isLast = utils.getLast().id === props.of
|
||||||
|
const isActive = current.id === props.of
|
||||||
|
|
||||||
|
const dataState = getStepState(currentIndex, stepIndex)
|
||||||
|
const childMap = useStepChildren(children)
|
||||||
|
|
||||||
|
const title = childMap.get('title')
|
||||||
|
const description = childMap.get('description')
|
||||||
|
const panel = childMap.get('panel')
|
||||||
|
|
||||||
|
if (variant === 'circle') {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
date-component="stepper-step"
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 items-center gap-4 rounded-md transition-colors',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CircleStepIndicator
|
||||||
|
currentStep={stepIndex + 1}
|
||||||
|
totalSteps={steps.length}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
date-component="stepper-step-content"
|
||||||
|
className="flex flex-col items-start gap-1"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<li
|
||||||
|
date-component="stepper-step"
|
||||||
|
className={cn([
|
||||||
|
'group peer relative flex items-center gap-2',
|
||||||
|
'data-[variant=vertical]:flex-row',
|
||||||
|
'data-[label-orientation=vertical]:w-full',
|
||||||
|
'data-[label-orientation=vertical]:flex-col',
|
||||||
|
'data-[label-orientation=vertical]:justify-center',
|
||||||
|
])}
|
||||||
|
data-variant={variant}
|
||||||
|
data-label-orientation={labelOrientation}
|
||||||
|
data-state={dataState}
|
||||||
|
data-disabled={props.disabled}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
id={`step-${step.id}`}
|
||||||
|
date-component="stepper-step-indicator"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
tabIndex={dataState !== 'inactive' ? 0 : -1}
|
||||||
|
className="rounded-full"
|
||||||
|
variant={dataState !== 'inactive' ? 'default' : 'secondary'}
|
||||||
|
size="icon"
|
||||||
|
aria-controls={`step-panel-${props.of}`}
|
||||||
|
aria-current={isActive ? 'step' : undefined}
|
||||||
|
aria-posinset={stepIndex + 1}
|
||||||
|
aria-setsize={steps.length}
|
||||||
|
aria-selected={isActive}
|
||||||
|
onKeyDown={(e) =>
|
||||||
|
onStepKeyDown(
|
||||||
|
e,
|
||||||
|
utils.getNext(props.of),
|
||||||
|
utils.getPrev(props.of),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon ?? stepIndex + 1}
|
||||||
|
</Button>
|
||||||
|
{variant === 'horizontal' && labelOrientation === 'vertical' && (
|
||||||
|
<StepperSeparator
|
||||||
|
orientation="horizontal"
|
||||||
|
labelOrientation={labelOrientation}
|
||||||
|
isLast={isLast}
|
||||||
|
state={dataState}
|
||||||
|
disabled={props.disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
date-component="stepper-step-content"
|
||||||
|
className="flex flex-col items-start"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{variant === 'horizontal' && labelOrientation === 'horizontal' && (
|
||||||
|
<StepperSeparator
|
||||||
|
orientation="horizontal"
|
||||||
|
isLast={isLast}
|
||||||
|
state={dataState}
|
||||||
|
disabled={props.disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === 'vertical' && (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{!isLast && (
|
||||||
|
<div className="flex justify-center ps-[calc(var(--spacing)_*_4.5_-_1px)]">
|
||||||
|
<StepperSeparator
|
||||||
|
orientation="vertical"
|
||||||
|
isLast={isLast}
|
||||||
|
state={dataState}
|
||||||
|
disabled={props.disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="my-3 flex-1 ps-4">{panel}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Title,
|
||||||
|
Description,
|
||||||
|
Panel: ({ children, asChild, ...props }) => {
|
||||||
|
const Comp = asChild ? Slot : 'div'
|
||||||
|
const { tracking } = useStepperProvider()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
date-component="stepper-step-panel"
|
||||||
|
ref={(node) => scrollIntoStepperPanel(node, tracking)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Comp>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Controls: ({ children, className, asChild, ...props }) => {
|
||||||
|
const Comp = asChild ? Slot : 'div'
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
date-component="stepper-controls"
|
||||||
|
className={cn('flex justify-end gap-4', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Comp>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Title = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
asChild,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'h4'> & { asChild?: boolean }) => {
|
||||||
|
const Comp = asChild ? Slot : 'h4'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
date-component="stepper-step-title"
|
||||||
|
className={cn('text-base font-medium', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Comp>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Description = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
asChild,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'p'> & { asChild?: boolean }) => {
|
||||||
|
const Comp = asChild ? Slot : 'p'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
date-component="stepper-step-description"
|
||||||
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Comp>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepperSeparator = ({
|
||||||
|
orientation,
|
||||||
|
isLast,
|
||||||
|
labelOrientation,
|
||||||
|
state,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
isLast: boolean
|
||||||
|
state: string
|
||||||
|
disabled?: boolean
|
||||||
|
} & VariantProps<typeof classForSeparator>) => {
|
||||||
|
if (isLast) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
date-component="stepper-separator"
|
||||||
|
data-orientation={orientation}
|
||||||
|
data-state={state}
|
||||||
|
data-disabled={disabled}
|
||||||
|
role="separator"
|
||||||
|
tabIndex={-1}
|
||||||
|
className={classForSeparator({ orientation, labelOrientation })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CircleStepIndicator = ({
|
||||||
|
currentStep,
|
||||||
|
totalSteps,
|
||||||
|
size = 80,
|
||||||
|
strokeWidth = 6,
|
||||||
|
}: Stepper.CircleStepIndicatorProps) => {
|
||||||
|
const radius = (size - strokeWidth) / 2
|
||||||
|
const circumference = radius * 2 * Math.PI
|
||||||
|
const fillPercentage = (currentStep / totalSteps) * 100
|
||||||
|
const dashOffset = circumference - (circumference * fillPercentage) / 100
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
date-component="stepper-step-indicator"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={currentStep}
|
||||||
|
aria-valuemin={1}
|
||||||
|
aria-valuemax={totalSteps}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="relative inline-flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<svg width={size} height={size}>
|
||||||
|
<title>Step Indicator</title>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={dashOffset}
|
||||||
|
className="text-primary transition-all duration-300 ease-in-out"
|
||||||
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium" aria-live="polite">
|
||||||
|
{currentStep} of {totalSteps}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const classForNavigationList = cva('flex gap-2', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
horizontal: 'flex-row items-center justify-between',
|
||||||
|
vertical: 'flex-col',
|
||||||
|
circle: 'flex-row items-center justify-between',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const classForSeparator = cva(
|
||||||
|
[
|
||||||
|
'bg-muted',
|
||||||
|
'data-[state=completed]:bg-primary data-[disabled]:opacity-50',
|
||||||
|
'transition-all duration-300 ease-in-out',
|
||||||
|
],
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal: 'h-0.5 flex-1',
|
||||||
|
vertical: 'h-full w-0.5',
|
||||||
|
},
|
||||||
|
labelOrientation: {
|
||||||
|
vertical:
|
||||||
|
'absolute top-5 right-[calc(-50%+20px)] left-[calc(50%+30px)] block shrink-0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function scrollIntoStepperPanel(
|
||||||
|
node: HTMLDivElement | null,
|
||||||
|
tracking?: boolean,
|
||||||
|
) {
|
||||||
|
if (tracking) {
|
||||||
|
node?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStepChildren = (children: React.ReactNode) => {
|
||||||
|
return React.useMemo(() => extractChildren(children), [children])
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractChildren = (children: React.ReactNode) => {
|
||||||
|
const childrenArray = React.Children.toArray(children)
|
||||||
|
const map = new Map<string, React.ReactNode>()
|
||||||
|
|
||||||
|
for (const child of childrenArray) {
|
||||||
|
if (React.isValidElement(child)) {
|
||||||
|
if (child.type === Title) {
|
||||||
|
map.set('title', child)
|
||||||
|
} else if (child.type === Description) {
|
||||||
|
map.set('description', child)
|
||||||
|
} else {
|
||||||
|
map.set('panel', child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
const onStepKeyDown = (
|
||||||
|
e: React.KeyboardEvent<HTMLButtonElement>,
|
||||||
|
nextStep: Stepperize.Step,
|
||||||
|
prevStep: Stepperize.Step,
|
||||||
|
) => {
|
||||||
|
const { key } = e
|
||||||
|
const directions = {
|
||||||
|
next: ['ArrowRight', 'ArrowDown'],
|
||||||
|
prev: ['ArrowLeft', 'ArrowUp'],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directions.next.includes(key) || directions.prev.includes(key)) {
|
||||||
|
const direction = directions.next.includes(key) ? 'next' : 'prev'
|
||||||
|
const step = direction === 'next' ? nextStep : prevStep
|
||||||
|
|
||||||
|
if (!step) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepElement = document.getElementById(`step-${step.id}`)
|
||||||
|
if (!stepElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive =
|
||||||
|
stepElement.parentElement?.getAttribute('data-state') !== 'inactive'
|
||||||
|
if (isActive || direction === 'prev') {
|
||||||
|
stepElement.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStepState = (currentIndex: number, stepIndex: number) => {
|
||||||
|
if (currentIndex === stepIndex) {
|
||||||
|
return 'active'
|
||||||
|
}
|
||||||
|
if (currentIndex > stepIndex) {
|
||||||
|
return 'completed'
|
||||||
|
}
|
||||||
|
return 'inactive'
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Stepper {
|
||||||
|
export type StepperVariant = 'horizontal' | 'vertical' | 'circle'
|
||||||
|
export type StepperLabelOrientation = 'horizontal' | 'vertical'
|
||||||
|
|
||||||
|
export type ConfigProps = {
|
||||||
|
variant?: StepperVariant
|
||||||
|
labelOrientation?: StepperLabelOrientation
|
||||||
|
tracking?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DefineProps<Steps extends Array<Stepperize.Step>> = Omit<
|
||||||
|
Stepperize.StepperReturn<Steps>,
|
||||||
|
'Scoped'
|
||||||
|
> & {
|
||||||
|
Stepper: {
|
||||||
|
Provider: (
|
||||||
|
props: Omit<Stepperize.ScopedProps<Steps>, 'children'> &
|
||||||
|
Omit<React.ComponentProps<'div'>, 'children'> &
|
||||||
|
Stepper.ConfigProps & {
|
||||||
|
children:
|
||||||
|
| React.ReactNode
|
||||||
|
| ((props: {
|
||||||
|
methods: Stepperize.Stepper<Steps>
|
||||||
|
}) => React.ReactNode)
|
||||||
|
},
|
||||||
|
) => React.ReactElement
|
||||||
|
Navigation: (props: React.ComponentProps<'nav'>) => React.ReactElement
|
||||||
|
Step: (
|
||||||
|
props: React.ComponentProps<'button'> & {
|
||||||
|
of: Stepperize.Get.Id<Steps>
|
||||||
|
icon?: React.ReactNode
|
||||||
|
},
|
||||||
|
) => React.ReactElement
|
||||||
|
Title: (props: AsChildProps<'h4'>) => React.ReactElement
|
||||||
|
Description: (props: AsChildProps<'p'>) => React.ReactElement
|
||||||
|
Panel: (props: AsChildProps<'div'>) => React.ReactElement
|
||||||
|
Controls: (props: AsChildProps<'div'>) => React.ReactElement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CircleStepIndicatorProps = {
|
||||||
|
currentStep: number
|
||||||
|
totalSteps: number
|
||||||
|
size?: number
|
||||||
|
strokeWidth?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AsChildProps<T extends React.ElementType> = React.ComponentProps<T> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export { defineStepper }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
interface InputProps {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
type = 'text',
|
||||||
|
}: InputProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium text-gray-700">{label}</label>
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-[#7b0f1d] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
interface Props {
|
||||||
|
text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubmitButton({ text = 'Iniciar sesión' }: Props) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-[#7b0f1d] text-white py-2 rounded-lg
|
||||||
|
font-semibold hover:opacity-90 transition"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-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 AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import type { VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
|
||||||
|
outline:
|
||||||
|
'bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost:
|
||||||
|
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||||
|
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||||
|
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||||
|
icon: 'size-9',
|
||||||
|
'icon-sm': 'size-8',
|
||||||
|
'icon-lg': 'size-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'button'> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user