Compare commits
3 Commits
issue/147-
...
ad1c889f49
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad1c889f49 | ||
|
|
13f7816786 | ||
|
|
449a83d43d |
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
@@ -1 +0,0 @@
|
|||||||
Al funcionar como agente, ignora los problemas de eslint del orden de imports
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,4 +8,3 @@ count.txt
|
|||||||
.nitro
|
.nitro
|
||||||
.tanstack
|
.tanstack
|
||||||
.wrangler
|
.wrangler
|
||||||
diff.txt
|
|
||||||
7
.vscode/extensions.json
vendored
7
.vscode/extensions.json
vendored
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": [
|
|
||||||
"esbenp.prettier-vscode",
|
|
||||||
"dbaeumer.vscode-eslint",
|
|
||||||
"bradlc.vscode-tailwindcss"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
23
.vscode/launch.json
vendored
23
.vscode/launch.json
vendored
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
// 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}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -22,11 +22,5 @@
|
|||||||
],
|
],
|
||||||
"files.associations": {
|
"files.associations": {
|
||||||
"*.css": "tailwindcss"
|
"*.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
Dockerfile
10
Dockerfile
@@ -1,10 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
@@ -18,11 +17,5 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"registries": {
|
"iconLibrary": "lucide"
|
||||||
"@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",
|
|
||||||
"@supabase": "https://supabase.com/ui/r/{name}.json"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import { tanstackConfig } from '@tanstack/eslint-config'
|
import { tanstackConfig } from '@tanstack/eslint-config'
|
||||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||||
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import unusedImports from 'eslint-plugin-unused-imports'
|
import unusedImports from 'eslint-plugin-unused-imports'
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
@@ -25,12 +24,9 @@ export default [
|
|||||||
|
|
||||||
// 3. TUS REGLAS Y CONFIGURACIÓN "PRO"
|
// 3. TUS REGLAS Y CONFIGURACIÓN "PRO"
|
||||||
{
|
{
|
||||||
// Opcional: Puedes ser explícito sobre dónde aplicar esto
|
|
||||||
files: ['**/*.{ts,tsx,js,jsx}'],
|
|
||||||
plugins: {
|
plugins: {
|
||||||
'jsx-a11y': jsxA11y,
|
'jsx-a11y': jsxA11y,
|
||||||
'unused-imports': unusedImports,
|
'unused-imports': unusedImports,
|
||||||
'react-hooks': reactHooks,
|
|
||||||
},
|
},
|
||||||
// Configuración robusta del Resolver (La versión de Copilot)
|
// Configuración robusta del Resolver (La versión de Copilot)
|
||||||
settings: {
|
settings: {
|
||||||
@@ -48,8 +44,7 @@ export default [
|
|||||||
// --- REGLAS DE ACCESIBILIDAD (A11Y) ---
|
// --- REGLAS DE ACCESIBILIDAD (A11Y) ---
|
||||||
// Activamos las recomendadas manualmente
|
// Activamos las recomendadas manualmente
|
||||||
...jsxA11y.configs.recommended.rules,
|
...jsxA11y.configs.recommended.rules,
|
||||||
'react-hooks/rules-of-hooks': 'error',
|
|
||||||
'react-hooks/exhaustive-deps': 'warn',
|
|
||||||
// --- ORDEN DE IMPORTS ---
|
// --- ORDEN DE IMPORTS ---
|
||||||
'sort-imports': 'off', // Apagamos el nativo
|
'sort-imports': 'off', // Apagamos el nativo
|
||||||
'import/order': [
|
'import/order': [
|
||||||
@@ -124,14 +119,6 @@ export default [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// 5. OVERRIDE: desactivar reglas para tipos generados por supabase
|
// 5. PRETTIER AL FINAL
|
||||||
{
|
|
||||||
files: ['src/types/supabase.ts'],
|
|
||||||
rules: {
|
|
||||||
'@typescript-eslint/naming-convention': 'off',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// 6. PRETTIER AL FINAL
|
|
||||||
eslintConfigPrettier,
|
eslintConfigPrettier,
|
||||||
]
|
]
|
||||||
|
|||||||
11
nginx.conf
11
nginx.conf
@@ -1,11 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
7849
package-lock.json
generated
Normal file
7849
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -17,23 +17,14 @@
|
|||||||
"ci:verify": "prettier --check . && eslint . && tsc --noEmit"
|
"ci:verify": "prettier --check . && eslint . && tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@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-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@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-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@stepperize/react": "^5.1.9",
|
|
||||||
"@supabase/supabase-js": "^2.98.0",
|
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"@tanstack/react-devtools": "^0.7.0",
|
"@tanstack/react-devtools": "^0.7.0",
|
||||||
"@tanstack/react-query": "^5.66.5",
|
"@tanstack/react-query": "^5.66.5",
|
||||||
@@ -41,29 +32,21 @@
|
|||||||
"@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",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"lucide-react": "^0.561.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.4.0",
|
"tailwind-merge": "^3.0.2",
|
||||||
"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",
|
||||||
@@ -72,12 +55,10 @@
|
|||||||
"eslint-import-resolver-typescript": "^4.4.4",
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
|
||||||
"eslint-plugin-unused-imports": "^4.3.0",
|
"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",
|
"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",
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
<?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>
|
|
||||||
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,19 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,9 @@
|
|||||||
import { Link, useNavigate } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { Home, LogOut, Menu, Network, X } from 'lucide-react'
|
import { Home, Menu, Network, X } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { supabaseBrowser } from '@/data/supabase/client'
|
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
try {
|
|
||||||
await supabaseBrowser().auth.signOut()
|
|
||||||
} finally {
|
|
||||||
void navigate({ to: '/login', replace: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -29,19 +18,13 @@ export default function Header() {
|
|||||||
</button>
|
</button>
|
||||||
<h1 className="ml-4 text-xl font-semibold">
|
<h1 className="ml-4 text-xl font-semibold">
|
||||||
<Link to="/">
|
<Link to="/">
|
||||||
<img src="/lasalle-logo.svg" alt="La Salle Logo" className="h-10" />
|
<img
|
||||||
|
src="/tanstack-word-logo-white.svg"
|
||||||
|
alt="TanStack Logo"
|
||||||
|
className="h-10"
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="ml-auto inline-flex items-center gap-2 rounded-lg p-2 transition-colors hover:bg-gray-700"
|
|
||||||
aria-label="Logout"
|
|
||||||
title="Logout"
|
|
||||||
>
|
|
||||||
<LogOut size={20} />
|
|
||||||
<span className="hidden sm:inline">Salir</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
|
|||||||
@@ -1,483 +0,0 @@
|
|||||||
import { createFileRoute, useNavigate, useParams } from '@tanstack/react-router'
|
|
||||||
import { Pencil, Sparkles } from 'lucide-react'
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
|
|
||||||
import type { AsignaturaDetail } from '@/data'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip'
|
|
||||||
import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects'
|
|
||||||
|
|
||||||
export interface BibliografiaEntry {
|
|
||||||
id: string
|
|
||||||
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
|
||||||
cita: string
|
|
||||||
fuenteBibliotecaId?: string
|
|
||||||
fuenteBiblioteca?: any
|
|
||||||
}
|
|
||||||
export interface BibliografiaTabProps {
|
|
||||||
id: string
|
|
||||||
bibliografia: Array<BibliografiaEntry>
|
|
||||||
onSave: (bibliografia: Array<BibliografiaEntry>) => void
|
|
||||||
isSaving: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AsignaturaDatos {
|
|
||||||
[key: string]: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AsignaturaResponse {
|
|
||||||
datos: AsignaturaDatos
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseContenidoTematicoToPlainText(value: unknown): string {
|
|
||||||
if (!Array.isArray(value)) return ''
|
|
||||||
|
|
||||||
const blocks: Array<string> = []
|
|
||||||
|
|
||||||
for (const item of value) {
|
|
||||||
if (!isRecord(item)) continue
|
|
||||||
|
|
||||||
const unidad =
|
|
||||||
typeof item.unidad === 'number' && Number.isFinite(item.unidad)
|
|
||||||
? item.unidad
|
|
||||||
: undefined
|
|
||||||
const titulo = typeof item.titulo === 'string' ? item.titulo : ''
|
|
||||||
|
|
||||||
const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim()
|
|
||||||
if (!header) continue
|
|
||||||
|
|
||||||
const lines: Array<string> = [header]
|
|
||||||
|
|
||||||
const temas = Array.isArray(item.temas) ? item.temas : []
|
|
||||||
temas.forEach((tema, idx) => {
|
|
||||||
const temaNombre =
|
|
||||||
typeof tema === 'string'
|
|
||||||
? tema
|
|
||||||
: isRecord(tema) && typeof tema.nombre === 'string'
|
|
||||||
? tema.nombre
|
|
||||||
: ''
|
|
||||||
if (!temaNombre) return
|
|
||||||
|
|
||||||
if (unidad != null) {
|
|
||||||
lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim())
|
|
||||||
} else {
|
|
||||||
lines.push(`${idx + 1}. ${temaNombre}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
blocks.push(lines.join('\n'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks.join('\n\n').trimEnd()
|
|
||||||
}
|
|
||||||
|
|
||||||
const columnParsers: Partial<Record<string, (value: unknown) => string>> = {
|
|
||||||
contenido_tematico: parseContenidoTematicoToPlainText,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
|
||||||
'/planes/$planId/asignaturas/$asignaturaId',
|
|
||||||
)({
|
|
||||||
component: AsignaturaDetailPage,
|
|
||||||
})
|
|
||||||
|
|
||||||
export default function AsignaturaDetailPage() {
|
|
||||||
const { asignaturaId } = useParams({
|
|
||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
|
||||||
})
|
|
||||||
const { data: asignaturaApi } = useSubject(asignaturaId)
|
|
||||||
|
|
||||||
const [asignatura, setAsignatura] = useState<AsignaturaDetail | null>(null)
|
|
||||||
const updateAsignatura = useUpdateAsignatura()
|
|
||||||
|
|
||||||
const handlePersistDatoGeneral = (clave: string, value: string) => {
|
|
||||||
const baseDatos = asignatura?.datos ?? (asignaturaApi as any)?.datos ?? {}
|
|
||||||
const mergedDatos = { ...baseDatos, [clave]: value }
|
|
||||||
|
|
||||||
// Mantener estado local coherente para merges posteriores.
|
|
||||||
setAsignatura((prev) => ({
|
|
||||||
...((prev ?? asignaturaApi ?? {}) as any),
|
|
||||||
datos: mergedDatos,
|
|
||||||
}))
|
|
||||||
|
|
||||||
updateAsignatura.mutate({
|
|
||||||
asignaturaId,
|
|
||||||
patch: {
|
|
||||||
datos: mergedDatos,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/* ---------- sincronizar API ---------- */
|
|
||||||
useEffect(() => {
|
|
||||||
if (asignaturaApi) setAsignatura(asignaturaApi)
|
|
||||||
}, [asignaturaApi])
|
|
||||||
|
|
||||||
return <DatosGenerales onPersistDato={handlePersistDatoGeneral} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DatosGenerales({
|
|
||||||
onPersistDato,
|
|
||||||
}: {
|
|
||||||
onPersistDato: (clave: string, value: string) => void
|
|
||||||
}) {
|
|
||||||
const { asignaturaId } = useParams({
|
|
||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
|
|
||||||
|
|
||||||
// 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>)
|
|
||||||
: {}
|
|
||||||
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}
|
|
||||||
xColumn={xColumn}
|
|
||||||
placeholder={placeholder}
|
|
||||||
description={description}
|
|
||||||
onPersist={(clave, value) => onPersistDato(clave, value)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Columna Lateral (Información Secundaria) */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Tarjeta de Requisitos */}
|
|
||||||
<InfoCard
|
|
||||||
title="Requisitos y Seriación"
|
|
||||||
type="requirements"
|
|
||||||
initialContent={[
|
|
||||||
{
|
|
||||||
type: 'Pre-requisito',
|
|
||||||
code: 'PA-301',
|
|
||||||
name: 'Programación Avanzada',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Co-requisito',
|
|
||||||
code: 'MAT-201',
|
|
||||||
name: 'Matemáticas Discretas',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Tarjeta de Evaluación */}
|
|
||||||
<InfoCard
|
|
||||||
title="Sistema de Evaluación"
|
|
||||||
type="evaluation"
|
|
||||||
initialContent={[
|
|
||||||
{ label: 'Exámenes parciales', value: '30%' },
|
|
||||||
{ label: 'Proyecto integrador', value: '35%' },
|
|
||||||
{ label: 'Prácticas de laboratorio', value: '20%' },
|
|
||||||
{ label: 'Participación', value: '15%' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InfoCardProps {
|
|
||||||
asignaturaId?: string
|
|
||||||
clave?: string
|
|
||||||
title: string
|
|
||||||
initialContent: any
|
|
||||||
placeholder?: string
|
|
||||||
description?: string
|
|
||||||
xColumn?: string
|
|
||||||
required?: boolean // Nueva prop para el asterisco
|
|
||||||
type?: 'text' | 'requirements' | 'evaluation'
|
|
||||||
onEnhanceAI?: (content: any) => void
|
|
||||||
onPersist?: (clave: string, value: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoCard({
|
|
||||||
asignaturaId,
|
|
||||||
clave,
|
|
||||||
title,
|
|
||||||
initialContent,
|
|
||||||
placeholder,
|
|
||||||
description,
|
|
||||||
xColumn,
|
|
||||||
required,
|
|
||||||
type = 'text',
|
|
||||||
onPersist,
|
|
||||||
}: InfoCardProps) {
|
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
|
||||||
const [data, setData] = useState(initialContent)
|
|
||||||
const [tempText, setTempText] = useState(initialContent)
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const { planId } = useParams({
|
|
||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setData(initialContent)
|
|
||||||
setTempText(initialContent)
|
|
||||||
}, [initialContent])
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
console.log('clave, valor:', clave, String(tempText ?? ''))
|
|
||||||
|
|
||||||
setData(tempText)
|
|
||||||
setIsEditing(false)
|
|
||||||
|
|
||||||
if (type === 'text' && clave && onPersist) {
|
|
||||||
onPersist(clave, String(tempText ?? ''))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleIARequest = (campoClave: string) => {
|
|
||||||
console.log(placeholder)
|
|
||||||
|
|
||||||
// Añadimos un timestamp a la state para forzar que la navegación
|
|
||||||
// genere una nueva ubicación incluso si la ruta y los params son iguales.
|
|
||||||
navigate({
|
|
||||||
to: '/planes/$planId/asignaturas/$asignaturaId/iaasignatura',
|
|
||||||
params: { planId, asignaturaId: asignaturaId! },
|
|
||||||
state: {
|
|
||||||
activeTab: 'ia',
|
|
||||||
prefillCampo: campoClave,
|
|
||||||
prefillContenido: data,
|
|
||||||
_ts: Date.now(),
|
|
||||||
} as any,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="overflow-hidden transition-all hover:border-slate-300">
|
|
||||||
<TooltipProvider>
|
|
||||||
<CardHeader className="border-b bg-slate-50/50 px-5 py-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<CardTitle className="cursor-help text-sm font-bold text-slate-700">
|
|
||||||
{title}
|
|
||||||
</CardTitle>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="max-w-xs text-xs">
|
|
||||||
{description || 'Información del campo'}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{required && (
|
|
||||||
<span
|
|
||||||
className="text-sm font-bold text-red-500"
|
|
||||||
title="Requerido"
|
|
||||||
>
|
|
||||||
*
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isEditing && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-blue-500 hover:bg-blue-100"
|
|
||||||
onClick={() => clave && handleIARequest(clave)}
|
|
||||||
>
|
|
||||||
<Sparkles className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Mejorar con IA</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-slate-400"
|
|
||||||
onClick={() => {
|
|
||||||
// Si esta InfoCard proviene de una columna externa (ej: contenido_tematico),
|
|
||||||
// redirigimos a la pestaña de Contenido en vez de editar inline.
|
|
||||||
if (xColumn === 'contenido_tematico') {
|
|
||||||
// Agregamos un timestamp para forzar la actualización
|
|
||||||
// de la location.state aunque la ruta sea la misma.
|
|
||||||
navigate({
|
|
||||||
to: '/planes/$planId/asignaturas/$asignaturaId/contenido',
|
|
||||||
params: { planId, asignaturaId: asignaturaId! },
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsEditing(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Editar campo</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<CardContent className="pt-4">
|
|
||||||
{isEditing ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Textarea
|
|
||||||
value={tempText}
|
|
||||||
placeholder={placeholder}
|
|
||||||
onChange={(e) => setTempText(e.target.value)}
|
|
||||||
className="min-h-30 text-sm leading-relaxed"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsEditing(false)}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="bg-[#00a878] hover:bg-[#008f66]"
|
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
Guardar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm leading-relaxed text-slate-600">
|
|
||||||
{type === 'text' &&
|
|
||||||
(data ? (
|
|
||||||
<p className="whitespace-pre-wrap">{data}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-slate-400 italic">Sin información.</p>
|
|
||||||
))}
|
|
||||||
{type === 'requirements' && <RequirementsView items={data} />}
|
|
||||||
{type === 'evaluation' && <EvaluationView items={data} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<any> }) {
|
|
||||||
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.label}</span>
|
|
||||||
<span className="font-bold text-blue-600">{item.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
|
||||||
/* eslint-disable jsx-a11y/label-has-associated-control */
|
|
||||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
|
||||||
import { useParams } from '@tanstack/react-router'
|
|
||||||
import { 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 { 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 [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
|
||||||
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
|
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
|
||||||
const [newEntryType, setNewEntryType] = useState<'BASICA' | 'COMPLEMENTARIA'>(
|
|
||||||
'BASICA',
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('Datos actuales en el front:', bibliografia)
|
|
||||||
// --- 4. Derivación de datos (Se calculan en cada render) ---
|
|
||||||
const basicaEntries = bibliografia.filter((e) => e.tipo === 'BASICA')
|
|
||||||
const complementariaEntries = bibliografia.filter(
|
|
||||||
(e) => e.tipo === 'COMPLEMENTARIA',
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Handlers Conectados a la Base de Datos ---
|
|
||||||
|
|
||||||
const handleAddManual = (cita: string) => {
|
|
||||||
crearBibliografia(
|
|
||||||
{
|
|
||||||
asignatura_id: asignaturaId,
|
|
||||||
tipo: newEntryType,
|
|
||||||
cita,
|
|
||||||
tipo_fuente: 'MANUAL',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => setIsAddDialogOpen(false),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddFromLibrary = (
|
|
||||||
resource: any,
|
|
||||||
tipo: 'BASICA' | 'COMPLEMENTARIA',
|
|
||||||
) => {
|
|
||||||
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 mx-auto max-w-5xl space-y-8 py-10 duration-500">
|
|
||||||
<div className="flex items-center justify-between border-b pb-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold 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>
|
|
||||||
|
|
||||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline">
|
|
||||||
<Plus className="mr-2 h-4 w-4" /> Añadir manual
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<AddManualDialog
|
|
||||||
tipo={newEntryType}
|
|
||||||
onTypeChange={setNewEntryType}
|
|
||||||
onAdd={handleAddManual}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</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 AddManualDialog({ tipo, onTypeChange, onAdd }: any) {
|
|
||||||
const [cita, setCita] = useState('')
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Referencia Manual</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
|
||||||
Tipo
|
|
||||||
</label>
|
|
||||||
<Select value={tipo} onValueChange={onTypeChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="BASICA">Básica</SelectItem>
|
|
||||||
<SelectItem value="COMPLEMENTARIA">Complementaria</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-bold text-slate-500 uppercase">
|
|
||||||
Cita APA
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={cita}
|
|
||||||
onChange={(e) => setCita(e.target.value)}
|
|
||||||
placeholder="Autor, A. (Año). Título..."
|
|
||||||
className="min-h-[120px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => onAdd(cita)}
|
|
||||||
disabled={!cita.trim()}
|
|
||||||
className="w-full bg-blue-600"
|
|
||||||
>
|
|
||||||
Añadir a la lista
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LibrarySearchDialog({ resources, onSelect, existingIds }: any) {
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [tipo, setTipo] = useState<'BASICA' | 'COMPLEMENTARIA'>('BASICA')
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,758 +0,0 @@
|
|||||||
import { useParams } from '@tanstack/react-router'
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
GripVertical,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Edit3,
|
|
||||||
Trash2,
|
|
||||||
Clock,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
|
|
||||||
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
|
|
||||||
import type { FocusEvent, KeyboardEvent } 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, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { useSubject, useUpdateSubjectContenido } from '@/data/hooks/useSubjects'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
// import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export interface Tema {
|
|
||||||
id: string
|
|
||||||
nombre: string
|
|
||||||
descripcion?: string
|
|
||||||
horasEstimadas?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UnidadTematica {
|
|
||||||
id: string
|
|
||||||
nombre: string
|
|
||||||
numero: number
|
|
||||||
temas: Array<Tema>
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function coerceNumber(value: unknown): number | undefined {
|
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (!trimmed) return undefined
|
|
||||||
const parsed = Number(trimmed)
|
|
||||||
return Number.isFinite(parsed) ? parsed : undefined
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function coerceString(value: unknown): string | undefined {
|
|
||||||
if (typeof value === 'string') return value
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapTemaValue(value: unknown): ContenidoTemaApi | null {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
const trimmed = value.trim()
|
|
||||||
return trimmed ? trimmed : null
|
|
||||||
}
|
|
||||||
if (isRecord(value)) {
|
|
||||||
const nombre = coerceString(value.nombre)
|
|
||||||
if (!nombre) return null
|
|
||||||
const horasEstimadas = coerceNumber(value.horasEstimadas)
|
|
||||||
const descripcion = coerceString(value.descripcion)
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
nombre,
|
|
||||||
horasEstimadas,
|
|
||||||
descripcion,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapContenidoItem(value: unknown, index: number): ContenidoApi | null {
|
|
||||||
if (!isRecord(value)) return null
|
|
||||||
|
|
||||||
const unidad = coerceNumber(value.unidad) ?? index + 1
|
|
||||||
const titulo = coerceString(value.titulo) ?? 'Sin título'
|
|
||||||
|
|
||||||
let temas: Array<ContenidoTemaApi> = []
|
|
||||||
if (Array.isArray(value.temas)) {
|
|
||||||
temas = value.temas
|
|
||||||
.map(mapTemaValue)
|
|
||||||
.filter((t): t is ContenidoTemaApi => t !== null)
|
|
||||||
} else if (typeof value.temas === 'string' && value.temas.trim()) {
|
|
||||||
temas = value.temas
|
|
||||||
.split(/\r?\n|,/)
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { unidad, titulo, temas }
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
|
|
||||||
if (value == null) return []
|
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
try {
|
|
||||||
return mapContenidoTematicoFromDb(JSON.parse(value))
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value
|
|
||||||
.map((item, idx) => mapContenidoItem(item, idx))
|
|
||||||
.filter((x): x is ContenidoApi => x !== null)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRecord(value)) {
|
|
||||||
if (Array.isArray(value.contenido_tematico)) {
|
|
||||||
return mapContenidoTematicoFromDb(value.contenido_tematico)
|
|
||||||
}
|
|
||||||
if (Array.isArray(value.unidades)) {
|
|
||||||
return mapContenidoTematicoFromDb(value.unidades)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeUnidadesToApi(
|
|
||||||
unidades: Array<UnidadTematica>,
|
|
||||||
): Array<ContenidoApi> {
|
|
||||||
return unidades
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.numero - b.numero)
|
|
||||||
.map((u, idx) => ({
|
|
||||||
unidad: u.numero || idx + 1,
|
|
||||||
titulo: u.nombre || 'Sin título',
|
|
||||||
temas: u.temas.map((t) => ({
|
|
||||||
nombre: t.nombre || 'Tema',
|
|
||||||
horasEstimadas: t.horasEstimadas ?? 0,
|
|
||||||
descripcion: t.descripcion,
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Props del componente
|
|
||||||
|
|
||||||
export function ContenidoTematico() {
|
|
||||||
const updateContenido = useUpdateSubjectContenido()
|
|
||||||
const { asignaturaId } = useParams({
|
|
||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: data, isLoading: isLoading } = useSubject(asignaturaId)
|
|
||||||
const [unidades, setUnidades] = useState<Array<UnidadTematica>>([])
|
|
||||||
const [expandedUnits, setExpandedUnits] = useState<Set<string>>(new Set())
|
|
||||||
const unitContainerRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
|
||||||
const unitTitleInputRef = useRef<HTMLInputElement | null>(null)
|
|
||||||
const temaNombreInputElRef = useRef<HTMLInputElement | null>(null)
|
|
||||||
const [pendingScrollUnitId, setPendingScrollUnitId] = useState<string | null>(
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
const cancelNextBlurRef = useRef(false)
|
|
||||||
const [deleteDialog, setDeleteDialog] = useState<{
|
|
||||||
type: 'unidad' | 'tema'
|
|
||||||
id: string
|
|
||||||
parentId?: string
|
|
||||||
} | null>(null)
|
|
||||||
const [editingUnit, setEditingUnit] = useState<string | null>(null)
|
|
||||||
const [unitDraftNombre, setUnitDraftNombre] = useState('')
|
|
||||||
const [unitOriginalNombre, setUnitOriginalNombre] = useState('')
|
|
||||||
const [editingTema, setEditingTema] = useState<{
|
|
||||||
unitId: string
|
|
||||||
temaId: string
|
|
||||||
} | null>(null)
|
|
||||||
const [temaDraftNombre, setTemaDraftNombre] = useState('')
|
|
||||||
const [temaOriginalNombre, setTemaOriginalNombre] = useState('')
|
|
||||||
const [temaDraftHoras, setTemaDraftHoras] = useState('')
|
|
||||||
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
|
|
||||||
|
|
||||||
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
|
|
||||||
const payload = serializeUnidadesToApi(nextUnidades)
|
|
||||||
await updateContenido.mutateAsync({
|
|
||||||
subjectId: asignaturaId,
|
|
||||||
unidades: payload,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const beginEditUnit = (unitId: string) => {
|
|
||||||
const unit = unidades.find((u) => u.id === unitId)
|
|
||||||
const nombre = unit?.nombre ?? ''
|
|
||||||
setEditingUnit(unitId)
|
|
||||||
setUnitDraftNombre(nombre)
|
|
||||||
setUnitOriginalNombre(nombre)
|
|
||||||
setExpandedUnits((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.add(unitId)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const commitEditUnit = () => {
|
|
||||||
if (!editingUnit) return
|
|
||||||
const next = unidades.map((u) =>
|
|
||||||
u.id === editingUnit ? { ...u, nombre: unitDraftNombre } : u,
|
|
||||||
)
|
|
||||||
setUnidades(next)
|
|
||||||
setEditingUnit(null)
|
|
||||||
void persistUnidades(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelEditUnit = () => {
|
|
||||||
setEditingUnit(null)
|
|
||||||
setUnitDraftNombre(unitOriginalNombre)
|
|
||||||
}
|
|
||||||
|
|
||||||
const beginEditTema = (unitId: string, temaId: string) => {
|
|
||||||
const unit = unidades.find((u) => u.id === unitId)
|
|
||||||
const tema = unit?.temas.find((t) => t.id === temaId)
|
|
||||||
const nombre = tema?.nombre ?? ''
|
|
||||||
const horas = tema?.horasEstimadas ?? 0
|
|
||||||
|
|
||||||
setEditingTema({ unitId, temaId })
|
|
||||||
setTemaDraftNombre(nombre)
|
|
||||||
setTemaOriginalNombre(nombre)
|
|
||||||
setTemaDraftHoras(String(horas))
|
|
||||||
setTemaOriginalHoras(horas)
|
|
||||||
setExpandedUnits((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.add(unitId)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const commitEditTema = () => {
|
|
||||||
if (!editingTema) return
|
|
||||||
const parsedHoras = Number.parseInt(temaDraftHoras, 10)
|
|
||||||
const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
|
|
||||||
|
|
||||||
const next = unidades.map((u) => {
|
|
||||||
if (u.id !== editingTema.unitId) return u
|
|
||||||
return {
|
|
||||||
...u,
|
|
||||||
temas: u.temas.map((t) =>
|
|
||||||
t.id === editingTema.temaId
|
|
||||||
? { ...t, nombre: temaDraftNombre, horasEstimadas }
|
|
||||||
: t,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setUnidades(next)
|
|
||||||
setEditingTema(null)
|
|
||||||
void persistUnidades(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelEditTema = () => {
|
|
||||||
setEditingTema(null)
|
|
||||||
setTemaDraftNombre(temaOriginalNombre)
|
|
||||||
setTemaDraftHoras(String(temaOriginalHoras))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTemaEditorBlurCapture = (e: FocusEvent<HTMLDivElement>) => {
|
|
||||||
if (cancelNextBlurRef.current) {
|
|
||||||
cancelNextBlurRef.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const nextFocus = e.relatedTarget as Node | null
|
|
||||||
if (nextFocus && e.currentTarget.contains(nextFocus)) return
|
|
||||||
commitEditTema()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTemaEditorKeyDownCapture = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
if (e.target instanceof HTMLElement) e.target.blur()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault()
|
|
||||||
cancelNextBlurRef.current = true
|
|
||||||
cancelEditTema()
|
|
||||||
if (e.target instanceof HTMLElement) e.target.blur()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const contenido = mapContenidoTematicoFromDb(
|
|
||||||
data ? data.contenido_tematico : undefined,
|
|
||||||
)
|
|
||||||
|
|
||||||
const transformed = contenido.map((u, idx) => ({
|
|
||||||
id: `u-${u.unidad || idx + 1}`,
|
|
||||||
numero: u.unidad || idx + 1,
|
|
||||||
nombre: u.titulo || 'Sin título',
|
|
||||||
temas: Array.isArray(u.temas)
|
|
||||||
? u.temas.map((t: any, tidx: number) => ({
|
|
||||||
id: `t-${u.unidad || idx + 1}-${tidx + 1}`,
|
|
||||||
nombre: typeof t === 'string' ? t : t?.nombre || 'Tema',
|
|
||||||
horasEstimadas: t?.horasEstimadas || 0,
|
|
||||||
}))
|
|
||||||
: [],
|
|
||||||
}))
|
|
||||||
|
|
||||||
setUnidades(transformed)
|
|
||||||
// Mantener las unidades ya expandidas si existen; si no, expandir la primera.
|
|
||||||
setExpandedUnits((prev) => {
|
|
||||||
const validIds = new Set(transformed.map((u) => u.id))
|
|
||||||
const filtered = new Set(
|
|
||||||
Array.from(prev).filter((id) => validIds.has(id)),
|
|
||||||
)
|
|
||||||
if (filtered.size > 0) return filtered
|
|
||||||
return transformed.length > 0 ? new Set([transformed[0].id]) : new Set()
|
|
||||||
})
|
|
||||||
}, [data])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editingUnit) return
|
|
||||||
// Foco controlado (evitamos autoFocus por lint/a11y)
|
|
||||||
setTimeout(() => unitTitleInputRef.current?.focus(), 0)
|
|
||||||
}, [editingUnit])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editingTema) return
|
|
||||||
setTimeout(() => temaNombreInputElRef.current?.focus(), 0)
|
|
||||||
}, [editingTema])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingScrollUnitId) return
|
|
||||||
const el = unitContainerRefs.current.get(pendingScrollUnitId)
|
|
||||||
if (!el) return
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
setPendingScrollUnitId(null)
|
|
||||||
}, [pendingScrollUnitId, unidades.length])
|
|
||||||
|
|
||||||
if (isLoading)
|
|
||||||
return <div className="p-10 text-center">Cargando contenido...</div>
|
|
||||||
|
|
||||||
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos)
|
|
||||||
const totalHoras = unidades.reduce(
|
|
||||||
(acc, u) =>
|
|
||||||
acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Lógica de Unidades ---
|
|
||||||
const toggleUnit = (id: string) => {
|
|
||||||
const newExpanded = new Set(expandedUnits)
|
|
||||||
newExpanded.has(id) ? newExpanded.delete(id) : newExpanded.add(id)
|
|
||||||
setExpandedUnits(newExpanded)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addUnidad = () => {
|
|
||||||
const newNumero = unidades.length + 1
|
|
||||||
const newId = `u-${newNumero}`
|
|
||||||
const newUnidad: UnidadTematica = {
|
|
||||||
id: newId,
|
|
||||||
nombre: 'Nueva Unidad',
|
|
||||||
numero: newNumero,
|
|
||||||
temas: [],
|
|
||||||
}
|
|
||||||
const next = [...unidades, newUnidad]
|
|
||||||
setUnidades(next)
|
|
||||||
setExpandedUnits((prev) => {
|
|
||||||
const n = new Set(prev)
|
|
||||||
n.add(newId)
|
|
||||||
return n
|
|
||||||
})
|
|
||||||
setPendingScrollUnitId(newId)
|
|
||||||
|
|
||||||
// Abrir edición del título inmediatamente
|
|
||||||
setEditingUnit(newId)
|
|
||||||
setUnitDraftNombre(newUnidad.nombre)
|
|
||||||
setUnitOriginalNombre(newUnidad.nombre)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Lógica de Temas ---
|
|
||||||
const addTema = (unidadId: string) => {
|
|
||||||
const unit = unidades.find((u) => u.id === unidadId)
|
|
||||||
const unitNumero = unit?.numero ?? 0
|
|
||||||
const newTemaIndex = (unit?.temas.length ?? 0) + 1
|
|
||||||
const newTemaId = `t-${unitNumero}-${newTemaIndex}`
|
|
||||||
const newTema: Tema = {
|
|
||||||
id: newTemaId,
|
|
||||||
nombre: 'Nuevo tema',
|
|
||||||
horasEstimadas: 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = unidades.map((u) =>
|
|
||||||
u.id === unidadId ? { ...u, temas: [...u.temas, newTema] } : u,
|
|
||||||
)
|
|
||||||
setUnidades(next)
|
|
||||||
|
|
||||||
// Expandir unidad y poner el subtema en edición con foco en el nombre
|
|
||||||
setExpandedUnits((prev) => {
|
|
||||||
const n = new Set(prev)
|
|
||||||
n.add(unidadId)
|
|
||||||
return n
|
|
||||||
})
|
|
||||||
setEditingTema({ unitId: unidadId, temaId: newTemaId })
|
|
||||||
setTemaDraftNombre(newTema.nombre)
|
|
||||||
setTemaOriginalNombre(newTema.nombre)
|
|
||||||
setTemaDraftHoras(String(newTema.horasEstimadas ?? 0))
|
|
||||||
setTemaOriginalHoras(newTema.horasEstimadas ?? 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
if (!deleteDialog) return
|
|
||||||
let next: Array<UnidadTematica> = unidades
|
|
||||||
if (deleteDialog.type === 'unidad') {
|
|
||||||
next = unidades
|
|
||||||
.filter((u) => u.id !== deleteDialog.id)
|
|
||||||
.map((u, i) => ({ ...u, numero: i + 1 }))
|
|
||||||
} else if (deleteDialog.parentId) {
|
|
||||||
next = unidades.map((u) =>
|
|
||||||
u.id === deleteDialog.parentId
|
|
||||||
? { ...u, temas: u.temas.filter((t) => t.id !== deleteDialog.id) }
|
|
||||||
: u,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
setUnidades(next)
|
|
||||||
setDeleteDialog(null)
|
|
||||||
void persistUnidades(next)
|
|
||||||
// toast.success("Eliminado correctamente");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="animate-in fade-in mx-auto max-w-5xl space-y-6 py-10 duration-500">
|
|
||||||
<div className="flex items-center justify-between border-b pb-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-slate-900">
|
|
||||||
Contenido Temático
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-slate-500">
|
|
||||||
{unidades.length} unidades • {totalHoras} horas estimadas totales
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{unidades.map((unidad) => (
|
|
||||||
<div
|
|
||||||
key={unidad.id}
|
|
||||||
ref={(el) => {
|
|
||||||
if (el) unitContainerRefs.current.set(unidad.id, el)
|
|
||||||
else unitContainerRefs.current.delete(unidad.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
|
||||||
<Collapsible
|
|
||||||
open={expandedUnits.has(unidad.id)}
|
|
||||||
onOpenChange={() => toggleUnit(unidad.id)}
|
|
||||||
>
|
|
||||||
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<GripVertical className="h-4 w-4 cursor-grab text-slate-300" />
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<Button variant="ghost" size="sm" className="h-auto p-0">
|
|
||||||
{expandedUnits.has(unidad.id) ? (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
<Badge className="bg-blue-600 font-mono">
|
|
||||||
Unidad {unidad.numero}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{editingUnit === unidad.id ? (
|
|
||||||
<Input
|
|
||||||
ref={unitTitleInputRef}
|
|
||||||
value={unitDraftNombre}
|
|
||||||
onChange={(e) => setUnitDraftNombre(e.target.value)}
|
|
||||||
onBlur={() => {
|
|
||||||
if (cancelNextBlurRef.current) {
|
|
||||||
cancelNextBlurRef.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
commitEditUnit()
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
e.currentTarget.blur()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.preventDefault()
|
|
||||||
cancelNextBlurRef.current = true
|
|
||||||
cancelEditUnit()
|
|
||||||
e.currentTarget.blur()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="h-8 max-w-md bg-white"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CardTitle
|
|
||||||
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
|
|
||||||
onClick={() => beginEditUnit(unidad.id)}
|
|
||||||
>
|
|
||||||
{unidad.nombre}
|
|
||||||
</CardTitle>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-3">
|
|
||||||
<span className="flex items-center gap-1 text-xs font-medium text-slate-400">
|
|
||||||
<Clock className="h-3 w-3" />{' '}
|
|
||||||
{unidad.temas.reduce(
|
|
||||||
(sum, t) => sum + (t.horasEstimadas || 0),
|
|
||||||
0,
|
|
||||||
)}
|
|
||||||
h
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-slate-400 hover:text-red-500"
|
|
||||||
onClick={() =>
|
|
||||||
setDeleteDialog({ type: 'unidad', id: unidad.id })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<CardContent className="bg-white pt-4">
|
|
||||||
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
|
|
||||||
{unidad.temas.map((tema, idx) => (
|
|
||||||
<TemaRow
|
|
||||||
key={tema.id}
|
|
||||||
tema={tema}
|
|
||||||
index={idx + 1}
|
|
||||||
isEditing={
|
|
||||||
!!editingTema &&
|
|
||||||
editingTema.unitId === unidad.id &&
|
|
||||||
editingTema.temaId === tema.id
|
|
||||||
}
|
|
||||||
draftNombre={temaDraftNombre}
|
|
||||||
draftHoras={temaDraftHoras}
|
|
||||||
onBeginEdit={() => beginEditTema(unidad.id, tema.id)}
|
|
||||||
onDraftNombreChange={setTemaDraftNombre}
|
|
||||||
onDraftHorasChange={setTemaDraftHoras}
|
|
||||||
onEditorBlurCapture={handleTemaEditorBlurCapture}
|
|
||||||
onEditorKeyDownCapture={
|
|
||||||
handleTemaEditorKeyDownCapture
|
|
||||||
}
|
|
||||||
onNombreInputRef={(el) => {
|
|
||||||
temaNombreInputElRef.current = el
|
|
||||||
}}
|
|
||||||
onDelete={() =>
|
|
||||||
setDeleteDialog({
|
|
||||||
type: 'tema',
|
|
||||||
id: tema.id,
|
|
||||||
parentId: unidad.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="mt-2 w-full justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
|
||||||
onClick={() => addTema(unidad.id)}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-center pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="gap-2"
|
|
||||||
onClick={(e) => {
|
|
||||||
// Evita que Enter vuelva a disparar el click sobre el botón.
|
|
||||||
e.currentTarget.blur()
|
|
||||||
addUnidad()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" /> Nueva unidad
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DeleteConfirmDialog
|
|
||||||
dialog={deleteDialog}
|
|
||||||
setDialog={setDeleteDialog}
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Componentes Auxiliares ---
|
|
||||||
interface TemaRowProps {
|
|
||||||
tema: Tema
|
|
||||||
index: number
|
|
||||||
isEditing: boolean
|
|
||||||
draftNombre: string
|
|
||||||
draftHoras: string
|
|
||||||
onBeginEdit: () => void
|
|
||||||
onDraftNombreChange: (value: string) => void
|
|
||||||
onDraftHorasChange: (value: string) => void
|
|
||||||
onEditorBlurCapture: (e: FocusEvent<HTMLDivElement>) => void
|
|
||||||
onEditorKeyDownCapture: (e: KeyboardEvent<HTMLDivElement>) => void
|
|
||||||
onNombreInputRef: (el: HTMLInputElement | null) => void
|
|
||||||
onDelete: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function TemaRow({
|
|
||||||
tema,
|
|
||||||
index,
|
|
||||||
isEditing,
|
|
||||||
draftNombre,
|
|
||||||
draftHoras,
|
|
||||||
onBeginEdit,
|
|
||||||
onDraftNombreChange,
|
|
||||||
onDraftHorasChange,
|
|
||||||
onEditorBlurCapture,
|
|
||||||
onEditorKeyDownCapture,
|
|
||||||
onNombreInputRef,
|
|
||||||
onDelete,
|
|
||||||
}: TemaRowProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'group flex items-center gap-3 rounded-md p-2 transition-all',
|
|
||||||
isEditing ? 'bg-blue-50 ring-1 ring-blue-100' : 'hover:bg-slate-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="w-4 font-mono text-xs text-slate-400">{index}.</span>
|
|
||||||
{isEditing ? (
|
|
||||||
<div
|
|
||||||
className="animate-in slide-in-from-left-2 flex flex-1 items-center gap-2"
|
|
||||||
onBlurCapture={onEditorBlurCapture}
|
|
||||||
onKeyDownCapture={onEditorKeyDownCapture}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
ref={onNombreInputRef}
|
|
||||||
value={draftNombre}
|
|
||||||
onChange={(e) => onDraftNombreChange(e.target.value)}
|
|
||||||
className="h-8 flex-1 bg-white"
|
|
||||||
placeholder="Nombre"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={draftHoras}
|
|
||||||
onChange={(e) => onDraftHorasChange(e.target.value)}
|
|
||||||
className="h-8 w-16 bg-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex flex-1 items-center gap-3 text-left"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onBeginEdit()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="text-sm font-medium text-slate-700">{tema.nombre}</p>
|
|
||||||
<Badge variant="secondary" className="text-[10px] opacity-60">
|
|
||||||
{tema.horasEstimadas}h
|
|
||||||
</Badge>
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-slate-400 hover:text-blue-600"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onBeginEdit()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Edit3 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-slate-400 hover:text-red-500"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onDelete()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeleteDialogState {
|
|
||||||
type: 'unidad' | 'tema'
|
|
||||||
id: string
|
|
||||||
parentId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeleteConfirmDialogProps {
|
|
||||||
dialog: DeleteDialogState | null
|
|
||||||
setDialog: (value: DeleteDialogState | null) => void
|
|
||||||
onConfirm: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeleteConfirmDialog({
|
|
||||||
dialog,
|
|
||||||
setDialog,
|
|
||||||
onConfirm,
|
|
||||||
}: DeleteConfirmDialogProps) {
|
|
||||||
return (
|
|
||||||
<AlertDialog open={!!dialog} onOpenChange={() => setDialog(null)}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>¿Confirmar eliminación?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Estás a punto de borrar un {dialog?.type}. Esta acción no se puede
|
|
||||||
deshacer.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="bg-red-600 text-white hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Eliminar
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
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
|
|
||||||
onDownload: () => void
|
|
||||||
onRegenerate: () => void
|
|
||||||
isRegenerating: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DocumentoSEPTab({
|
|
||||||
pdfUrl,
|
|
||||||
isLoading,
|
|
||||||
onDownload,
|
|
||||||
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">
|
|
||||||
{pdfUrl && !isLoading && (
|
|
||||||
<Button variant="outline" onClick={onDownload}>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Descargar
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={showConfirmDialog}
|
|
||||||
onOpenChange={setShowConfirmDialog}
|
|
||||||
>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button disabled={isRegenerating}>
|
|
||||||
{isRegenerating ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PDF Preview */}
|
|
||||||
<Card className="h-[800px] overflow-hidden">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<Loader2 className="h-10 w-10 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : pdfUrl ? (
|
|
||||||
<iframe
|
|
||||||
src={`${pdfUrl}#toolbar=0`}
|
|
||||||
className="h-full w-full border-none"
|
|
||||||
title="Documento SEP"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground flex h-full items-center justify-center">
|
|
||||||
No se pudo cargar el documento.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
import { useParams } from '@tanstack/react-router'
|
|
||||||
import { format, parseISO } from 'date-fns'
|
|
||||||
import { es } from 'date-fns/locale'
|
|
||||||
import {
|
|
||||||
History,
|
|
||||||
FileText,
|
|
||||||
List,
|
|
||||||
BookMarked,
|
|
||||||
Sparkles,
|
|
||||||
FileCheck,
|
|
||||||
Filter,
|
|
||||||
Calendar,
|
|
||||||
Loader2,
|
|
||||||
Eye,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useState, useMemo } from 'react'
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { 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="card-interactive">
|
|
||||||
<CardContent className="py-4">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'bg-muted rounded-lg p-2',
|
|
||||||
config.color,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<p className="font-medium">
|
|
||||||
{cambio.descripcion}
|
|
||||||
</p>
|
|
||||||
{/* BOTÓN PARA VER CAMBIOS */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2 text-blue-600 hover:bg-blue-50 hover:text-blue-700"
|
|
||||||
onClick={() => openCompareModal(cambio)}
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
Ver cambios
|
|
||||||
</Button>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{format(cambio.fecha, 'HH:mm')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
import { useParams, useRouterState } from '@tanstack/react-router'
|
|
||||||
import {
|
|
||||||
Sparkles,
|
|
||||||
Send,
|
|
||||||
Target,
|
|
||||||
UserCheck,
|
|
||||||
Lightbulb,
|
|
||||||
FileText,
|
|
||||||
GraduationCap,
|
|
||||||
BookOpen,
|
|
||||||
Check,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
|
||||||
|
|
||||||
import type { IAMessage, IASugerencia } from '@/types/asignatura'
|
|
||||||
|
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { useSubject } from '@/data'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
// Tipos importados de tu archivo de asignatura
|
|
||||||
|
|
||||||
const PRESETS = [
|
|
||||||
{
|
|
||||||
id: 'mejorar-objetivo',
|
|
||||||
label: 'Mejorar objetivo',
|
|
||||||
icon: Target,
|
|
||||||
prompt: 'Mejora la redacción del objetivo de esta asignatura...',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'contenido-tematico',
|
|
||||||
label: 'Sugerir contenido',
|
|
||||||
icon: BookOpen,
|
|
||||||
prompt: 'Genera un desglose de temas para esta asignatura...',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actividades',
|
|
||||||
label: 'Actividades de aprendizaje',
|
|
||||||
icon: GraduationCap,
|
|
||||||
prompt: 'Sugiere actividades prácticas para los temas seleccionados...',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bibliografia',
|
|
||||||
label: 'Actualizar bibliografía',
|
|
||||||
icon: FileText,
|
|
||||||
prompt: 'Recomienda bibliografía reciente para esta asignatura...',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
interface SelectedField {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IAAsignaturaTabProps {
|
|
||||||
asignatura: Record<string, any>
|
|
||||||
messages: Array<IAMessage>
|
|
||||||
onSendMessage: (message: string, campoId?: string) => void
|
|
||||||
onAcceptSuggestion: (sugerencia: IASugerencia) => void
|
|
||||||
onRejectSuggestion: (messageId: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IAAsignaturaTab({
|
|
||||||
messages,
|
|
||||||
onSendMessage,
|
|
||||||
onAcceptSuggestion,
|
|
||||||
onRejectSuggestion,
|
|
||||||
}: IAAsignaturaTabProps) {
|
|
||||||
const routerState = useRouterState()
|
|
||||||
const { asignaturaId } = useParams({
|
|
||||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: datosGenerales, isLoading: loadingAsig } =
|
|
||||||
useSubject(asignaturaId)
|
|
||||||
// ESTADOS PRINCIPALES (Igual que en Planes)
|
|
||||||
const [input, setInput] = useState('')
|
|
||||||
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
|
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
// 1. Transformar datos de la asignatura para el menú
|
|
||||||
const availableFields = useMemo(() => {
|
|
||||||
if (!datosGenerales?.datos) return []
|
|
||||||
|
|
||||||
const estructuraProps =
|
|
||||||
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
|
|
||||||
|
|
||||||
return Object.keys(datosGenerales.datos).map((key) => {
|
|
||||||
const estructuraCampo = estructuraProps[key]
|
|
||||||
|
|
||||||
const labelAmigable =
|
|
||||||
estructuraCampo?.title ||
|
|
||||||
key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
label: labelAmigable,
|
|
||||||
value: String(datosGenerales.datos[key] || ''),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [datosGenerales])
|
|
||||||
|
|
||||||
// 2. Manejar el estado inicial si viene de "Datos de Asignatura" (Prefill)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const state = routerState.location.state as any
|
|
||||||
|
|
||||||
if (state?.prefillCampo && availableFields.length > 0) {
|
|
||||||
console.log(state?.prefillCampo)
|
|
||||||
console.log(availableFields)
|
|
||||||
|
|
||||||
const field = availableFields.find((f) => f.key === state.prefillCampo)
|
|
||||||
|
|
||||||
if (field && !selectedFields.find((sf) => sf.key === field.key)) {
|
|
||||||
setSelectedFields([field])
|
|
||||||
// Sincronizamos el texto inicial con el campo pre-seleccionado
|
|
||||||
setInput(`Mejora el campo ${field.label}: `)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [availableFields])
|
|
||||||
|
|
||||||
// Scroll automático
|
|
||||||
useEffect(() => {
|
|
||||||
if (scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
|
||||||
}
|
|
||||||
}, [messages, isLoading])
|
|
||||||
|
|
||||||
// 3. Lógica para el disparador ":"
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const val = e.target.value
|
|
||||||
setInput(val)
|
|
||||||
setShowSuggestions(val.endsWith(':'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleField = (field: SelectedField) => {
|
|
||||||
setSelectedFields((prev) => {
|
|
||||||
const isSelected = prev.find((f) => f.key === field.key)
|
|
||||||
|
|
||||||
// 1. Si ya está seleccionado, lo quitamos (Toggle OFF)
|
|
||||||
if (isSelected) {
|
|
||||||
return prev.filter((f) => f.key !== field.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Si no está, lo agregamos a la lista (Toggle ON)
|
|
||||||
const newSelected = [...prev, field]
|
|
||||||
|
|
||||||
// 3. Actualizamos el texto del input para reflejar los títulos (labels)
|
|
||||||
setInput((prevText) => {
|
|
||||||
// Separamos lo que el usuario escribió antes del disparador ":"
|
|
||||||
// y lo que viene después (posibles keys/labels previos)
|
|
||||||
const parts = prevText.split(':')
|
|
||||||
const beforeColon = parts[0]
|
|
||||||
|
|
||||||
// Creamos un string con los labels de todos los campos seleccionados
|
|
||||||
const labelsPath = newSelected.map((f) => f.label).join(', ')
|
|
||||||
|
|
||||||
return `${beforeColon.trim()}: ${labelsPath} `
|
|
||||||
})
|
|
||||||
|
|
||||||
return newSelected
|
|
||||||
})
|
|
||||||
|
|
||||||
// Opcional: mantener abierto si quieres que el usuario elija varios seguidos
|
|
||||||
// setShowSuggestions(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildPrompt = (userInput: string) => {
|
|
||||||
if (selectedFields.length === 0) return userInput
|
|
||||||
const fieldsText = selectedFields
|
|
||||||
.map((f) => `- ${f.label}: ${f.value || '(vacio)'}`)
|
|
||||||
.join('\n')
|
|
||||||
|
|
||||||
return `${userInput}\n\nCampos a analizar:\n${fieldsText}`.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSend = async (promptOverride?: string) => {
|
|
||||||
const rawText = promptOverride || input
|
|
||||||
if (!rawText.trim() && selectedFields.length === 0) return
|
|
||||||
|
|
||||||
const finalPrompt = buildPrompt(rawText)
|
|
||||||
|
|
||||||
setIsLoading(true)
|
|
||||||
// Llamamos a la función que viene por props
|
|
||||||
onSendMessage(finalPrompt, selectedFields[0]?.key)
|
|
||||||
|
|
||||||
setInput('')
|
|
||||||
setSelectedFields([])
|
|
||||||
|
|
||||||
// Simular carga local para el feedback visual
|
|
||||||
setTimeout(() => setIsLoading(false), 1200)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
|
|
||||||
{/* PANEL DE CHAT PRINCIPAL */}
|
|
||||||
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
|
|
||||||
{/* Barra superior */}
|
|
||||||
<div className="shrink-0 border-b bg-white p-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
|
|
||||||
IA de Asignatura
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CONTENIDO DEL CHAT */}
|
|
||||||
<div className="relative min-h-0 flex-1">
|
|
||||||
<ScrollArea ref={scrollRef} className="h-full w-full">
|
|
||||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
|
||||||
{messages?.map((msg) => (
|
|
||||||
<div
|
|
||||||
key={msg.id}
|
|
||||||
className={`flex ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'} items-start gap-3`}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
className={`h-8 w-8 shrink-0 border ${msg.role === 'assistant' ? 'bg-teal-50' : 'bg-slate-200'}`}
|
|
||||||
>
|
|
||||||
<AvatarFallback className="text-[10px]">
|
|
||||||
{msg.role === 'assistant' ? (
|
|
||||||
<Sparkles size={14} className="text-teal-600" />
|
|
||||||
) : (
|
|
||||||
<UserCheck size={14} />
|
|
||||||
)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div
|
|
||||||
className={`flex max-w-[85%] flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm',
|
|
||||||
msg.role === 'user'
|
|
||||||
? 'rounded-tr-none bg-teal-600 text-white'
|
|
||||||
: 'rounded-tl-none border bg-white text-slate-700',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{msg.content}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Renderizado de Sugerencias (Homologado con lógica de Asignatura) */}
|
|
||||||
{msg.sugerencia && !msg.sugerencia.aceptada && (
|
|
||||||
<div className="animate-in fade-in slide-in-from-top-1 mt-3 w-full">
|
|
||||||
<div className="rounded-xl border border-teal-100 bg-white p-4 shadow-md">
|
|
||||||
<p className="mb-2 text-[10px] font-bold text-slate-400 uppercase">
|
|
||||||
Propuesta para: {msg.sugerencia.campoNombre}
|
|
||||||
</p>
|
|
||||||
<div className="mb-4 max-h-40 overflow-y-auto rounded-lg bg-slate-50 p-3 text-xs text-slate-600 italic">
|
|
||||||
{msg.sugerencia.valorSugerido}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
onAcceptSuggestion(msg.sugerencia!)
|
|
||||||
}
|
|
||||||
className="h-8 bg-teal-600 text-xs hover:bg-teal-700"
|
|
||||||
>
|
|
||||||
<Check size={14} className="mr-1" /> Aplicar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onRejectSuggestion(msg.id)}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
>
|
|
||||||
<X size={14} className="mr-1" /> Descartar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{msg.sugerencia?.aceptada && (
|
|
||||||
<Badge className="mt-2 border-teal-200 bg-teal-100 text-teal-700 hover:bg-teal-100">
|
|
||||||
<Check className="mr-1 h-3 w-3" /> Sugerencia aplicada
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex gap-2 p-4">
|
|
||||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400" />
|
|
||||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.2s]" />
|
|
||||||
<div className="h-2 w-2 animate-bounce rounded-full bg-teal-400 [animation-delay:0.4s]" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* INPUT FIJO AL FONDO */}
|
|
||||||
<div className="shrink-0 border-t bg-white p-4">
|
|
||||||
<div className="relative mx-auto max-w-4xl">
|
|
||||||
{/* MENÚ DE SUGERENCIAS FLOTANTE */}
|
|
||||||
{showSuggestions && (
|
|
||||||
<div className="animate-in slide-in-from-bottom-2 absolute bottom-full z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
|
|
||||||
<div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold tracking-wider text-slate-500 uppercase">
|
|
||||||
Seleccionar campo de asignatura
|
|
||||||
</div>
|
|
||||||
<div className="max-h-64 overflow-y-auto p-1">
|
|
||||||
{availableFields.map((field) => (
|
|
||||||
<button
|
|
||||||
key={field.key}
|
|
||||||
onClick={() => toggleField(field)}
|
|
||||||
className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50"
|
|
||||||
>
|
|
||||||
<span className="text-slate-700 group-hover:text-teal-700">
|
|
||||||
{field.label}
|
|
||||||
</span>
|
|
||||||
{selectedFields.find((f) => f.key === field.key) && (
|
|
||||||
<Check size={14} className="text-teal-600" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* CONTENEDOR DEL INPUT */}
|
|
||||||
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
|
|
||||||
{/* Visualización de Tags */}
|
|
||||||
{selectedFields.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2 px-2 pt-1">
|
|
||||||
{selectedFields.map((field) => (
|
|
||||||
<div
|
|
||||||
key={field.key}
|
|
||||||
className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-100 px-2 py-0.5 text-[11px] font-semibold text-teal-800"
|
|
||||||
>
|
|
||||||
<span className="opacity-70">Campo:</span> {field.label}
|
|
||||||
<button
|
|
||||||
onClick={() => toggleField(field)}
|
|
||||||
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200"
|
|
||||||
>
|
|
||||||
<X size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<Textarea
|
|
||||||
value={input}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
handleSend()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={
|
|
||||||
selectedFields.length > 0
|
|
||||||
? 'Instrucciones para los campos seleccionados...'
|
|
||||||
: 'Escribe tu solicitud o ":" para campos...'
|
|
||||||
}
|
|
||||||
className="max-h-[120px] min-h-[40px] flex-1 resize-none border-none bg-transparent py-2 text-sm shadow-none focus-visible:ring-0"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleSend()}
|
|
||||||
disabled={
|
|
||||||
(!input.trim() && selectedFields.length === 0) || isLoading
|
|
||||||
}
|
|
||||||
size="icon"
|
|
||||||
className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700"
|
|
||||||
>
|
|
||||||
<Send size={16} className="text-white" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PANEL LATERAL (ACCIONES RÁPIDAS) */}
|
|
||||||
<div className="flex flex-[1] flex-col gap-4 overflow-y-auto pr-2">
|
|
||||||
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
|
|
||||||
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{PRESETS.map((preset) => (
|
|
||||||
<button
|
|
||||||
key={preset.id}
|
|
||||||
onClick={() => handleSend(preset.prompt)}
|
|
||||||
className="group flex w-full items-center gap-3 rounded-xl border bg-white p-3 text-left text-sm shadow-sm transition-all hover:border-teal-500 hover:bg-teal-50"
|
|
||||||
>
|
|
||||||
<div className="rounded-lg bg-slate-100 p-2 text-slate-500 group-hover:bg-teal-100 group-hover:text-teal-600">
|
|
||||||
<preset.icon size={16} />
|
|
||||||
</div>
|
|
||||||
<span className="leading-tight font-medium text-slate-700">
|
|
||||||
{preset.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
import PasoSugerenciasForm from './PasoSugerenciasForm'
|
|
||||||
|
|
||||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
|
||||||
import type { Database } from '@/types/supabase'
|
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { useSubjectEstructuras } from '@/data'
|
|
||||||
import { TIPOS_MATERIA } from '@/features/asignaturas/nueva/catalogs'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
export function PasoBasicosForm({
|
|
||||||
wizard,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
wizard: NewSubjectWizardState
|
|
||||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
|
||||||
}) {
|
|
||||||
const { data: estructuras } = useSubjectEstructuras()
|
|
||||||
|
|
||||||
const [creditosInput, setCreditosInput] = useState<string>(() => {
|
|
||||||
const c = Number(wizard.datosBasicos.creditos ?? 0)
|
|
||||||
let newC = c
|
|
||||||
console.log('antes', newC)
|
|
||||||
|
|
||||||
if (Number.isFinite(c) && c > 999) {
|
|
||||||
newC = 999
|
|
||||||
}
|
|
||||||
console.log('desp', newC)
|
|
||||||
return newC > 0 ? newC.toFixed(2) : ''
|
|
||||||
})
|
|
||||||
const [creditosFocused, setCreditosFocused] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (creditosFocused) return
|
|
||||||
const c = Number(wizard.datosBasicos.creditos ?? 0)
|
|
||||||
let newC = c
|
|
||||||
if (Number.isFinite(c) && c > 999) {
|
|
||||||
newC = 999
|
|
||||||
}
|
|
||||||
setCreditosInput(newC > 0 ? newC.toFixed(2) : '')
|
|
||||||
}, [wizard.datosBasicos.creditos, creditosFocused])
|
|
||||||
|
|
||||||
if (wizard.tipoOrigen !== 'IA_MULTIPLE') {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="grid gap-1 sm:col-span-2">
|
|
||||||
<Label htmlFor="nombre">Nombre de la asignatura</Label>
|
|
||||||
<Input
|
|
||||||
id="nombre"
|
|
||||||
placeholder="Ej. Matemáticas Discretas"
|
|
||||||
maxLength={200}
|
|
||||||
value={wizard.datosBasicos.nombre}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange(
|
|
||||||
(w): NewSubjectWizardState => ({
|
|
||||||
...w,
|
|
||||||
datosBasicos: { ...w.datosBasicos, nombre: e.target.value },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label htmlFor="codigo">
|
|
||||||
Código
|
|
||||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
|
|
||||||
(Opcional)
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="codigo"
|
|
||||||
placeholder="Ej. MAT-101"
|
|
||||||
maxLength={200}
|
|
||||||
value={wizard.datosBasicos.codigo || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange(
|
|
||||||
(w): NewSubjectWizardState => ({
|
|
||||||
...w,
|
|
||||||
datosBasicos: { ...w.datosBasicos, codigo: e.target.value },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="placeholder:text-muted-foreground/70 placeholder:italicplaceholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label htmlFor="tipo">Tipo</Label>
|
|
||||||
<Select
|
|
||||||
value={(wizard.datosBasicos.tipo ?? '') as string}
|
|
||||||
onValueChange={(value: string) =>
|
|
||||||
onChange(
|
|
||||||
(w): NewSubjectWizardState => ({
|
|
||||||
...w,
|
|
||||||
datosBasicos: {
|
|
||||||
...w.datosBasicos,
|
|
||||||
tipo: value as NewSubjectWizardState['datosBasicos']['tipo'],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
id="tipo"
|
|
||||||
className={cn(
|
|
||||||
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
|
|
||||||
!wizard.datosBasicos.tipo
|
|
||||||
? 'text-muted-foreground font-normal italic opacity-70'
|
|
||||||
: 'font-medium not-italic',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Ej. Obligatoria" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{TIPOS_MATERIA.map((t) => (
|
|
||||||
<SelectItem key={t.value} value={t.value}>
|
|
||||||
{t.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label htmlFor="creditos">Créditos</Label>
|
|
||||||
<Input
|
|
||||||
id="creditos"
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
maxLength={6}
|
|
||||||
pattern="^\\d*(?:[.,]\\d{0,2})?$"
|
|
||||||
value={creditosInput}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (['-', 'e', 'E', '+'].includes(e.key)) {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={() => setCreditosFocused(true)}
|
|
||||||
onBlur={() => {
|
|
||||||
setCreditosFocused(false)
|
|
||||||
|
|
||||||
const raw = creditosInput.trim()
|
|
||||||
if (!raw) {
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
datosBasicos: { ...w.datosBasicos, creditos: 0 },
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = raw.replace(',', '.')
|
|
||||||
let asNumber = Number.parseFloat(normalized)
|
|
||||||
if (!Number.isFinite(asNumber) || asNumber <= 0) {
|
|
||||||
setCreditosInput('')
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
datosBasicos: { ...w.datosBasicos, creditos: 0 },
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cap to 999
|
|
||||||
if (asNumber > 999) asNumber = 999
|
|
||||||
|
|
||||||
const fixed = asNumber.toFixed(2)
|
|
||||||
setCreditosInput(fixed)
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
datosBasicos: { ...w.datosBasicos, creditos: Number(fixed) },
|
|
||||||
}))
|
|
||||||
}}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const nextRaw = e.target.value
|
|
||||||
if (nextRaw === '') {
|
|
||||||
setCreditosInput('')
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
datosBasicos: { ...w.datosBasicos, creditos: 0 },
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^\d*(?:[.,]\d{0,2})?$/.test(nextRaw)) return
|
|
||||||
|
|
||||||
// If typed number exceeds 999, cap it immediately (prevents entering >999)
|
|
||||||
const asNumberRaw = Number.parseFloat(nextRaw.replace(',', '.'))
|
|
||||||
if (Number.isFinite(asNumberRaw) && asNumberRaw > 999) {
|
|
||||||
// show capped value to the user
|
|
||||||
const cappedStr = '999.00'
|
|
||||||
setCreditosInput(cappedStr)
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
datosBasicos: {
|
|
||||||
...w.datosBasicos,
|
|
||||||
creditos: 999,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setCreditosInput(nextRaw)
|
|
||||||
|
|
||||||
const asNumber = Number.parseFloat(nextRaw.replace(',', '.'))
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
datosBasicos: {
|
|
||||||
...w.datosBasicos,
|
|
||||||
creditos:
|
|
||||||
Number.isFinite(asNumber) && asNumber > 0 ? asNumber : 0,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}}
|
|
||||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
|
||||||
placeholder="Ej. 4.50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label htmlFor="estructura">Estructura de la asignatura</Label>
|
|
||||||
<Select
|
|
||||||
value={wizard.datosBasicos.estructuraId as string}
|
|
||||||
onValueChange={(val) =>
|
|
||||||
onChange(
|
|
||||||
(w): NewSubjectWizardState => ({
|
|
||||||
...w,
|
|
||||||
datosBasicos: { ...w.datosBasicos, estructuraId: val },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
id="estructura"
|
|
||||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Selecciona plantilla..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{estructuras?.map(
|
|
||||||
(
|
|
||||||
e: Database['public']['Tables']['estructuras_asignatura']['Row'],
|
|
||||||
) => (
|
|
||||||
<SelectItem key={e.id} value={e.id}>
|
|
||||||
{e.nombre}
|
|
||||||
</SelectItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label htmlFor="horasAcademicas">
|
|
||||||
Horas Académicas
|
|
||||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
|
|
||||||
(Opcional)
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="horasAcademicas"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={999}
|
|
||||||
step={1}
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={wizard.datosBasicos.horasAcademicas ?? ''}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
onChange(
|
|
||||||
(w): NewSubjectWizardState => ({
|
|
||||||
...w,
|
|
||||||
datosBasicos: {
|
|
||||||
...w.datosBasicos,
|
|
||||||
horasAcademicas: (() => {
|
|
||||||
const raw = e.target.value
|
|
||||||
if (raw === '') return null
|
|
||||||
const asNumber = Number(raw)
|
|
||||||
if (Number.isNaN(asNumber)) return null
|
|
||||||
// Coerce to positive integer (natural numbers without zero)
|
|
||||||
const n = Math.floor(Math.abs(asNumber))
|
|
||||||
const capped = Math.min(n >= 1 ? n : 1, 999)
|
|
||||||
return capped
|
|
||||||
})(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
|
||||||
placeholder="Ej. 48"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label htmlFor="horasIndependientes">
|
|
||||||
Horas Independientes
|
|
||||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
|
|
||||||
(Opcional)
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="horasIndependientes"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={999}
|
|
||||||
step={1}
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
value={wizard.datosBasicos.horasIndependientes ?? ''}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
||||||
onChange(
|
|
||||||
(w): NewSubjectWizardState => ({
|
|
||||||
...w,
|
|
||||||
datosBasicos: {
|
|
||||||
...w.datosBasicos,
|
|
||||||
horasIndependientes: (() => {
|
|
||||||
const raw = e.target.value
|
|
||||||
if (raw === '') return null
|
|
||||||
const asNumber = Number(raw)
|
|
||||||
if (Number.isNaN(asNumber)) return null
|
|
||||||
// Coerce to positive integer (natural numbers without zero)
|
|
||||||
const n = Math.floor(Math.abs(asNumber))
|
|
||||||
const capped = Math.min(n >= 1 ? n : 1, 999)
|
|
||||||
return capped
|
|
||||||
})(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
|
||||||
placeholder="Ej. 24"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <PasoSugerenciasForm wizard={wizard} onChange={onChange} />
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
import { RefreshCw, Sparkles, X } from 'lucide-react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
|
||||||
import type { Dispatch, SetStateAction } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip'
|
|
||||||
import { generate_subject_suggestions, usePlan } from '@/data'
|
|
||||||
import { AIProgressLoader } from '@/features/asignaturas/nueva/AIProgressLoader'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
export default function PasoSugerenciasForm({
|
|
||||||
wizard,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
wizard: NewSubjectWizardState
|
|
||||||
onChange: Dispatch<SetStateAction<NewSubjectWizardState>>
|
|
||||||
}) {
|
|
||||||
const enfoque = wizard.iaMultiple?.enfoque ?? ''
|
|
||||||
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 5
|
|
||||||
const isLoading = wizard.iaMultiple?.isLoading ?? false
|
|
||||||
|
|
||||||
const [showConservacionTooltip, setShowConservacionTooltip] = useState(false)
|
|
||||||
|
|
||||||
const setIaMultiple = (
|
|
||||||
patch: Partial<NonNullable<NewSubjectWizardState['iaMultiple']>>,
|
|
||||||
) =>
|
|
||||||
onChange(
|
|
||||||
(w): NewSubjectWizardState => ({
|
|
||||||
...w,
|
|
||||||
iaMultiple: {
|
|
||||||
enfoque: w.iaMultiple?.enfoque ?? '',
|
|
||||||
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
|
|
||||||
isLoading: w.iaMultiple?.isLoading ?? false,
|
|
||||||
...patch,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { data: plan } = usePlan(wizard.plan_estudio_id)
|
|
||||||
|
|
||||||
const toggleAsignatura = (id: string, checked: boolean) => {
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
sugerencias: w.sugerencias.map((s) =>
|
|
||||||
s.id === id ? { ...s, selected: checked } : s,
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const onGenerarSugerencias = async () => {
|
|
||||||
const hadNoSugerenciasBefore = wizard.sugerencias.length === 0
|
|
||||||
const sugerenciasConservadas = wizard.sugerencias.filter((s) => s.selected)
|
|
||||||
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
errorMessage: null,
|
|
||||||
sugerencias: sugerenciasConservadas,
|
|
||||||
iaMultiple: {
|
|
||||||
enfoque: w.iaMultiple?.enfoque ?? '',
|
|
||||||
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
|
|
||||||
isLoading: true,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cantidad = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
|
|
||||||
if (!Number.isFinite(cantidad) || cantidad <= 0 || cantidad > 15) {
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
errorMessage: 'La cantidad de sugerencias debe ser entre 1 y 15.',
|
|
||||||
iaMultiple: {
|
|
||||||
enfoque: w.iaMultiple?.enfoque ?? '',
|
|
||||||
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
|
|
||||||
isLoading: false,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const enfoqueTrim = wizard.iaMultiple?.enfoque.trim() ?? ''
|
|
||||||
|
|
||||||
const nuevasSugerencias = await generate_subject_suggestions({
|
|
||||||
plan_estudio_id: wizard.plan_estudio_id,
|
|
||||||
enfoque: enfoqueTrim ? enfoqueTrim : undefined,
|
|
||||||
cantidad_de_sugerencias: cantidad,
|
|
||||||
sugerencias_conservadas: sugerenciasConservadas.map((s) => ({
|
|
||||||
nombre: s.nombre,
|
|
||||||
descripcion: s.descripcion,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (hadNoSugerenciasBefore && nuevasSugerencias.length > 0) {
|
|
||||||
setShowConservacionTooltip(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange(
|
|
||||||
(w): NewSubjectWizardState => ({
|
|
||||||
...w,
|
|
||||||
sugerencias: [...nuevasSugerencias, ...sugerenciasConservadas],
|
|
||||||
iaMultiple: {
|
|
||||||
enfoque: w.iaMultiple?.enfoque ?? '',
|
|
||||||
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
|
|
||||||
isLoading: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
const message =
|
|
||||||
err instanceof Error ? err.message : 'Error generando sugerencias.'
|
|
||||||
onChange(
|
|
||||||
(w): NewSubjectWizardState => ({
|
|
||||||
...w,
|
|
||||||
errorMessage: message,
|
|
||||||
iaMultiple: {
|
|
||||||
enfoque: w.iaMultiple?.enfoque ?? '',
|
|
||||||
cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10,
|
|
||||||
isLoading: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* --- BLOQUE SUPERIOR: PARÁMETROS --- */}
|
|
||||||
<div className="border-border/60 bg-muted/30 mb-4 rounded-xl border p-4">
|
|
||||||
<div className="mb-3 flex items-center gap-2">
|
|
||||||
<Sparkles className="text-primary h-4 w-4" />
|
|
||||||
<span className="text-sm font-semibold">
|
|
||||||
Parámetros de sugerencia
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="w-full">
|
|
||||||
<Label className="text-muted-foreground mb-1 block text-xs">
|
|
||||||
Enfoque (opcional)
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Ej. Enfocado en normativa mexicana y tecnología"
|
|
||||||
value={enfoque}
|
|
||||||
maxLength={7000}
|
|
||||||
rows={4}
|
|
||||||
onChange={(e) => setIaMultiple({ enfoque: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 flex w-full flex-col items-end justify-between gap-3 sm:flex-row">
|
|
||||||
<div className="w-full sm:w-44">
|
|
||||||
<Label className="text-muted-foreground mb-1 block text-xs">
|
|
||||||
Cantidad de sugerencias
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="Ej. 5"
|
|
||||||
value={cantidadDeSugerencias}
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={15}
|
|
||||||
step={1}
|
|
||||||
inputMode="numeric"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={(e) => {
|
|
||||||
const raw = e.target.value
|
|
||||||
if (raw === '') return
|
|
||||||
const asNumber = Number(raw)
|
|
||||||
if (!Number.isFinite(asNumber)) return
|
|
||||||
const n = Math.floor(Math.abs(asNumber))
|
|
||||||
const capped = Math.min(n >= 1 ? n : 1, 15)
|
|
||||||
setIaMultiple({ cantidadDeSugerencias: capped })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-9 gap-1.5"
|
|
||||||
onClick={onGenerarSugerencias}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
|
||||||
{wizard.sugerencias.length > 0
|
|
||||||
? 'Generar más sugerencias'
|
|
||||||
: 'Generar sugerencias'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AIProgressLoader
|
|
||||||
isLoading={isLoading}
|
|
||||||
cantidadDeSugerencias={cantidadDeSugerencias}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* --- HEADER LISTA --- */}
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-foreground text-base font-semibold">
|
|
||||||
Asignaturas sugeridas
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Basadas en el plan{' '}
|
|
||||||
{plan ? `${plan.nivel} en ${plan.nombre}` : '...'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Tooltip open={showConservacionTooltip}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="bg-muted text-foreground inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-sm font-semibold">
|
|
||||||
<span aria-hidden>📌</span>
|
|
||||||
{wizard.sugerencias.filter((s) => s.selected).length}{' '}
|
|
||||||
seleccionadas
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" sideOffset={8} className="max-w-xs">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="flex-1 text-sm">
|
|
||||||
Al generar más sugerencias, se conservarán las asignaturas
|
|
||||||
seleccionadas.
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-5 w-5"
|
|
||||||
onClick={() => setShowConservacionTooltip(false)}
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* --- LISTA DE ASIGNATURAS --- */}
|
|
||||||
<div className="max-h-100 space-y-1 overflow-y-auto pr-1">
|
|
||||||
{wizard.sugerencias.map((asignatura) => {
|
|
||||||
const isSelected = asignatura.selected
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Label
|
|
||||||
key={asignatura.id}
|
|
||||||
aria-checked={isSelected}
|
|
||||||
className={cn(
|
|
||||||
'border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={isSelected}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
toggleAsignatura(asignatura.id, !!checked)
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
'peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring mt-0.5 h-5 w-5 shrink-0 border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
|
||||||
// isSelected ? '' : 'invisible',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Contenido de la tarjeta */}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-foreground text-sm font-medium">
|
|
||||||
{asignatura.nombre}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Badges de Tipo */}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
|
||||||
asignatura.tipo === 'OBLIGATORIA'
|
|
||||||
? 'border-blue-200 bg-transparent text-blue-700 dark:border-blue-800 dark:text-blue-300'
|
|
||||||
: 'border-yellow-200 bg-transparent text-yellow-700 dark:border-yellow-800 dark:text-yellow-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{asignatura.tipo}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
|
||||||
{asignatura.creditos} cred. · {asignatura.horasAcademicas}h
|
|
||||||
acad. · {asignatura.horasIndependientes}h indep.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
|
||||||
{asignatura.descripcion}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,459 +0,0 @@
|
|||||||
import * as Icons from 'lucide-react'
|
|
||||||
|
|
||||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
|
||||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
|
||||||
|
|
||||||
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from '@/components/ui/accordion'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@/components/ui/card'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { usePlan, usePlanLineas, useSubjectEstructuras } from '@/data'
|
|
||||||
import {
|
|
||||||
FACULTADES,
|
|
||||||
MATERIAS_MOCK,
|
|
||||||
PLANES_MOCK,
|
|
||||||
} from '@/features/asignaturas/nueva/catalogs'
|
|
||||||
|
|
||||||
export function PasoDetallesPanel({
|
|
||||||
wizard,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
wizard: NewSubjectWizardState
|
|
||||||
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
|
||||||
}) {
|
|
||||||
const { data: estructurasAsignatura } = useSubjectEstructuras()
|
|
||||||
const { data: plan } = usePlan(wizard.plan_estudio_id)
|
|
||||||
const { data: lineasPlan } = usePlanLineas(wizard.plan_estudio_id)
|
|
||||||
|
|
||||||
if (wizard.tipoOrigen === 'MANUAL') {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Configuración Manual</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
La asignatura se creará vacía. Podrás editar el contenido detallado
|
|
||||||
en la siguiente pantalla.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<Label>Descripción del enfoque académico</Label>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Describe el enfoque, alcance y público objetivo. Ej.: Teórica-práctica enfocada en patrones de diseño, con proyectos semanales..."
|
|
||||||
maxLength={7000}
|
|
||||||
value={wizard.iaConfig?.descripcionEnfoqueAcademico}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange(
|
|
||||||
(w): NewSubjectWizardState => ({
|
|
||||||
...w,
|
|
||||||
iaConfig: {
|
|
||||||
...w.iaConfig!,
|
|
||||||
descripcionEnfoqueAcademico: e.target.value,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="placeholder:text-muted-foreground/70 min-h-25 font-medium not-italic placeholder:font-normal placeholder:italic"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<Label>
|
|
||||||
Instrucciones adicionales para la IA
|
|
||||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
|
|
||||||
(Opcional)
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Opcional: restricciones y preferencias. Ej.: incluye bibliografía en español, evita contenido avanzado, prioriza evaluación por proyectos..."
|
|
||||||
maxLength={7000}
|
|
||||||
value={wizard.iaConfig?.instruccionesAdicionalesIA}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange(
|
|
||||||
(w): NewSubjectWizardState => ({
|
|
||||||
...w,
|
|
||||||
iaConfig: {
|
|
||||||
...w.iaConfig!,
|
|
||||||
instruccionesAdicionalesIA: e.target.value,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ReferenciasParaIA
|
|
||||||
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
|
|
||||||
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
|
|
||||||
uploadedFiles={wizard.iaConfig?.archivosAdjuntos || []}
|
|
||||||
onToggleArchivo={(id, checked) =>
|
|
||||||
onChange((w): NewSubjectWizardState => {
|
|
||||||
const prev = w.iaConfig?.archivosReferencia || []
|
|
||||||
const next = checked
|
|
||||||
? [...prev, id]
|
|
||||||
: prev.filter((a) => a !== id)
|
|
||||||
return {
|
|
||||||
...w,
|
|
||||||
iaConfig: {
|
|
||||||
...w.iaConfig!,
|
|
||||||
archivosReferencia: next,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onToggleRepositorio={(id, checked) =>
|
|
||||||
onChange((w): NewSubjectWizardState => {
|
|
||||||
const prev = w.iaConfig?.repositoriosReferencia || []
|
|
||||||
const next = checked
|
|
||||||
? [...prev, id]
|
|
||||||
: prev.filter((r) => r !== id)
|
|
||||||
return {
|
|
||||||
...w,
|
|
||||||
iaConfig: {
|
|
||||||
...w.iaConfig!,
|
|
||||||
repositoriosReferencia: next,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onFilesChange={(files: Array<UploadedFile>) =>
|
|
||||||
onChange(
|
|
||||||
(w): NewSubjectWizardState => ({
|
|
||||||
...w,
|
|
||||||
iaConfig: {
|
|
||||||
...w.iaConfig!,
|
|
||||||
archivosAdjuntos: files,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
|
|
||||||
const maxCiclos = Math.max(1, plan?.numero_ciclos ?? 1)
|
|
||||||
const sugerenciasSeleccionadas = wizard.sugerencias.filter(
|
|
||||||
(s) => s.selected,
|
|
||||||
)
|
|
||||||
|
|
||||||
const patchSugerencia = (
|
|
||||||
id: string,
|
|
||||||
patch: Partial<NewSubjectWizardState['sugerencias'][number]>,
|
|
||||||
) =>
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
sugerencias: w.sugerencias.map((s) =>
|
|
||||||
s.id === id ? { ...s, ...patch } : s,
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="border-border/60 bg-muted/30 rounded-xl border p-4">
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label className="text-muted-foreground text-xs">
|
|
||||||
Estructura de la asignatura
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={wizard.datosBasicos.estructuraId ?? undefined}
|
|
||||||
onValueChange={(val) =>
|
|
||||||
onChange(
|
|
||||||
(w): NewSubjectWizardState => ({
|
|
||||||
...w,
|
|
||||||
estructuraId: val,
|
|
||||||
datosBasicos: { ...w.datosBasicos, estructuraId: val },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Selecciona una estructura" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(estructurasAsignatura ?? []).map((e) => (
|
|
||||||
<SelectItem key={e.id} value={e.id}>
|
|
||||||
{e.nombre}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-border/60 bg-muted/30 rounded-xl border p-4">
|
|
||||||
<h3 className="text-foreground mx-3 mb-2 text-lg font-semibold">
|
|
||||||
Materias seleccionadas
|
|
||||||
</h3>
|
|
||||||
{sugerenciasSeleccionadas.length === 0 ? (
|
|
||||||
<div className="text-muted-foreground text-sm">
|
|
||||||
Selecciona al menos una sugerencia para configurar su descripción,
|
|
||||||
línea curricular y ciclo.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Accordion type="multiple" className="w-full space-y-2">
|
|
||||||
{sugerenciasSeleccionadas.map((asig) => (
|
|
||||||
<AccordionItem
|
|
||||||
key={asig.id}
|
|
||||||
value={asig.id}
|
|
||||||
className="border-border/60 bg-background/40 rounded-lg border border-b-0 px-3"
|
|
||||||
>
|
|
||||||
<AccordionTrigger className="hover:bg-accent/30 data-[state=open]:bg-accent/20 data-[state=open]:text-accent-foreground -mx-3 px-3">
|
|
||||||
{asig.nombre}
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="text-muted-foreground">
|
|
||||||
<div className="mx-1 grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label className="text-muted-foreground text-xs">
|
|
||||||
Descripción
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
value={asig.descripcion}
|
|
||||||
maxLength={7000}
|
|
||||||
rows={6}
|
|
||||||
onChange={(e) =>
|
|
||||||
patchSugerencia(asig.id, {
|
|
||||||
descripcion: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid content-start gap-3">
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label className="text-muted-foreground text-xs">
|
|
||||||
Ciclo (opcional)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={maxCiclos}
|
|
||||||
step={1}
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder={`1-${maxCiclos}`}
|
|
||||||
value={asig.numero_ciclo ?? ''}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (
|
|
||||||
['.', ',', '-', 'e', 'E', '+'].includes(e.key)
|
|
||||||
) {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={(e) => {
|
|
||||||
const raw = e.target.value
|
|
||||||
if (raw === '') {
|
|
||||||
patchSugerencia(asig.id, { numero_ciclo: null })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const asNumber = Number(raw)
|
|
||||||
if (!Number.isFinite(asNumber)) return
|
|
||||||
|
|
||||||
const n = Math.floor(Math.abs(asNumber))
|
|
||||||
const capped = Math.min(
|
|
||||||
Math.max(n >= 1 ? n : 1, 1),
|
|
||||||
maxCiclos,
|
|
||||||
)
|
|
||||||
|
|
||||||
patchSugerencia(asig.id, { numero_ciclo: capped })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1">
|
|
||||||
<Label className="text-muted-foreground text-xs">
|
|
||||||
Línea curricular (opcional)
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={asig.linea_plan_id ?? '__none__'}
|
|
||||||
onValueChange={(val) =>
|
|
||||||
patchSugerencia(asig.id, {
|
|
||||||
linea_plan_id: val === '__none__' ? null : val,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Sin línea" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="__none__">Ninguna</SelectItem>
|
|
||||||
{(lineasPlan ?? []).map((l) => (
|
|
||||||
<SelectItem key={l.id} value={l.id}>
|
|
||||||
{l.nombre}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
|
||||||
<div>
|
|
||||||
<Label>Facultad</Label>
|
|
||||||
<Select
|
|
||||||
onValueChange={(val) =>
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
clonInterno: { ...w.clonInterno, facultadId: val },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Todas" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{FACULTADES.map((f) => (
|
|
||||||
<SelectItem key={f.id} value={f.id}>
|
|
||||||
{f.nombre}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>Plan</Label>
|
|
||||||
<Select
|
|
||||||
onValueChange={(val) =>
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
clonInterno: { ...w.clonInterno, planOrigenId: val },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Todos" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{PLANES_MOCK.map((p) => (
|
|
||||||
<SelectItem key={p.id} value={p.id}>
|
|
||||||
{p.nombre}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>Buscar</Label>
|
|
||||||
<Input placeholder="Nombre..." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid max-h-75 gap-2 overflow-y-auto">
|
|
||||||
{MATERIAS_MOCK.map((m) => (
|
|
||||||
<div
|
|
||||||
key={m.id}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() =>
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
|
||||||
e.preventDefault()
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
clonInterno: { ...w.clonInterno, asignaturaOrigenId: m.id },
|
|
||||||
}))
|
|
||||||
}}
|
|
||||||
className={`hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 ${
|
|
||||||
wizard.clonInterno?.asignaturaOrigenId === m.id
|
|
||||||
? 'border-primary bg-primary/5 ring-primary ring-1'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{m.nombre}</div>
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
{m.clave} • {m.creditos} créditos
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{wizard.clonInterno?.asignaturaOrigenId === m.id && (
|
|
||||||
<Icons.CheckCircle2 className="text-primary h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wizard.tipoOrigen === 'CLONADO_TRADICIONAL') {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
|
||||||
<Icons.Upload className="text-muted-foreground mx-auto mb-4 h-10 w-10" />
|
|
||||||
<h3 className="mb-1 text-sm font-medium">
|
|
||||||
Sube el Word de la asignatura
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground mb-4 text-xs">
|
|
||||||
Arrastra el archivo o haz clic para buscar (.doc, .docx)
|
|
||||||
</p>
|
|
||||||
<Input
|
|
||||||
type="file"
|
|
||||||
accept=".doc,.docx"
|
|
||||||
className="mx-auto max-w-xs"
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange((w) => ({
|
|
||||||
...w,
|
|
||||||
clonTradicional: {
|
|
||||||
...w.clonTradicional!,
|
|
||||||
archivoWordAsignaturaId:
|
|
||||||
e.target.files?.[0]?.name || 'mock_file',
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{wizard.clonTradicional?.archivoWordAsignaturaId && (
|
|
||||||
<div className="flex items-center gap-2 rounded-md bg-green-50 p-3 text-sm text-green-700">
|
|
||||||
<Icons.FileText className="h-4 w-4" />
|
|
||||||
Archivo cargado listo para procesar.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,477 +0,0 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
|
||||||
import { Loader2 } from 'lucide-react'
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
|
|
||||||
import type { AISubjectUnifiedInput } from '@/data'
|
|
||||||
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
|
||||||
import type { TablesInsert } from '@/types/supabase'
|
|
||||||
import type { RealtimeChannel } from '@supabase/supabase-js'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
supabaseBrowser,
|
|
||||||
useGenerateSubjectAI,
|
|
||||||
qk,
|
|
||||||
useCreateSubjectManual,
|
|
||||||
subjects_get_maybe,
|
|
||||||
} from '@/data'
|
|
||||||
|
|
||||||
export function WizardControls({
|
|
||||||
wizard,
|
|
||||||
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 === '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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,44 +1,18 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// import { supabase } from '@/lib/supabase'
|
||||||
import { LoginInput } from '../ui/LoginInput'
|
import { LoginInput } from '../ui/LoginInput'
|
||||||
import { SubmitButton } from '../ui/SubmitButton'
|
import { SubmitButton } from '../ui/SubmitButton'
|
||||||
|
|
||||||
import { throwIfError } from '@/data/api/_helpers'
|
|
||||||
import { qk } from '@/data/query/keys'
|
|
||||||
import { supabaseBrowser } from '@/data/supabase/client'
|
|
||||||
|
|
||||||
export function ExternalLoginForm() {
|
export function ExternalLoginForm() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
const qc = useQueryClient()
|
|
||||||
const navigate = useNavigate({ from: '/login' })
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
setIsLoading(true)
|
/* await supabase.auth.signInWithPassword({
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { error } = await supabase.auth.signInWithPassword({
|
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
})
|
})*/
|
||||||
throwIfError(error)
|
|
||||||
|
|
||||||
qc.invalidateQueries({ queryKey: qk.session() })
|
|
||||||
qc.invalidateQueries({ queryKey: qk.auth })
|
|
||||||
await navigate({ to: '/dashboard', replace: true })
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const anyErr = e as any
|
|
||||||
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,11 +34,7 @@ export function ExternalLoginForm() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={setPassword}
|
||||||
/>
|
/>
|
||||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
<SubmitButton />
|
||||||
<SubmitButton
|
|
||||||
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,18 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
// import { supabase } from '@/lib/supabase'
|
||||||
import { LoginInput } from '../ui/LoginInput'
|
import { LoginInput } from '../ui/LoginInput'
|
||||||
import { SubmitButton } from '../ui/SubmitButton'
|
import { SubmitButton } from '../ui/SubmitButton'
|
||||||
|
|
||||||
import { throwIfError } from '@/data/api/_helpers'
|
|
||||||
import { qk } from '@/data/query/keys'
|
|
||||||
import { supabaseBrowser } from '@/data/supabase/client'
|
|
||||||
|
|
||||||
export function InternalLoginForm() {
|
export function InternalLoginForm() {
|
||||||
const [clave, setClave] = useState('')
|
const [clave, setClave] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
const qc = useQueryClient()
|
|
||||||
const navigate = useNavigate({ from: '/login' })
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
setIsLoading(true)
|
/* await supabase.auth.signInWithPassword({
|
||||||
setError(null)
|
email: `${clave}@ulsa.mx`,
|
||||||
|
|
||||||
try {
|
|
||||||
const email = clave.includes('@') ? clave : `${clave}@ulsa.mx`
|
|
||||||
const { error } = await supabase.auth.signInWithPassword({
|
|
||||||
email,
|
|
||||||
password,
|
password,
|
||||||
})
|
})*/
|
||||||
throwIfError(error)
|
|
||||||
|
|
||||||
qc.invalidateQueries({ queryKey: qk.session() })
|
|
||||||
qc.invalidateQueries({ queryKey: qk.auth })
|
|
||||||
await navigate({ to: '/dashboard', replace: true })
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const anyErr = e as any
|
|
||||||
setError(anyErr?.message ?? 'No se pudo iniciar sesión')
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,11 +30,7 @@ export function InternalLoginForm() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={setPassword}
|
onChange={setPassword}
|
||||||
/>
|
/>
|
||||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
<SubmitButton />
|
||||||
<SubmitButton
|
|
||||||
text={isLoading ? 'Iniciando…' : 'Iniciar sesión'}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
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,
|
|
||||||
currentDatos,
|
|
||||||
activeChatId,
|
|
||||||
onApplySuccess,
|
|
||||||
}: {
|
|
||||||
suggestions: Array<any>
|
|
||||||
onApply?: (key: string, value: string) => void
|
|
||||||
planId: string
|
|
||||||
currentDatos: any
|
|
||||||
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 (activeChatId) {
|
|
||||||
updateAppliedStatus.mutate({
|
|
||||||
conversacionId: activeChatId,
|
|
||||||
campoAfectado: key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onApply) onApply(key, newValue)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-2 flex w-full flex-col gap-4">
|
|
||||||
{suggestions.map((sug) => {
|
|
||||||
const isApplied = sug.applied === true || localApplied.includes(sug.key)
|
|
||||||
const isUpdating =
|
|
||||||
updatePlan.isPending &&
|
|
||||||
updatePlan.variables.patch.datos?.[sug.key] !== undefined
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={sug.key}
|
|
||||||
className={`rounded-2xl border bg-white p-5 shadow-sm transition-all ${
|
|
||||||
isApplied ? 'border-teal-200 bg-teal-50/20' : 'border-slate-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-bold text-slate-900">{sug.label}</h3>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleApply(sug.key, sug.newValue)}
|
|
||||||
disabled={isApplied || !!isUpdating}
|
|
||||||
className={`h-8 rounded-full px-4 text-xs transition-all ${
|
|
||||||
isApplied
|
|
||||||
? 'cursor-not-allowed bg-slate-100 text-slate-400'
|
|
||||||
: 'bg-[#00a189] text-white hover:bg-[#008f7a]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isUpdating ? (
|
|
||||||
<Loader2 size={12} className="animate-spin" />
|
|
||||||
) : isApplied ? (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Check size={12} /> Aplicado
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'Aplicar mejora'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${
|
|
||||||
isApplied
|
|
||||||
? 'border-teal-100 bg-teal-50/50 text-slate-700'
|
|
||||||
: 'border-slate-200 bg-slate-50 text-slate-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{sug.newValue}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
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'
|
|
||||||
}
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,536 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
text?: string
|
text?: string
|
||||||
disabled?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubmitButton({ text = 'Iniciar sesión', disabled }: Props) {
|
export function SubmitButton({ text = 'Iniciar sesión' }: Props) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={disabled}
|
className="w-full bg-[#7b0f1d] text-white py-2 rounded-lg
|
||||||
className="w-full rounded-lg bg-[#7b0f1d] py-2 font-semibold text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
font-semibold hover:opacity-90 transition"
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,54 +1,52 @@
|
|||||||
import { Slot } from '@radix-ui/react-slot'
|
import * as React from "react"
|
||||||
import { cva } from 'class-variance-authority'
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import * as React from 'react'
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import type { VariantProps } from 'class-variance-authority'
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
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',
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost:
|
ghost:
|
||||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
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',
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
icon: 'size-9',
|
icon: "size-9",
|
||||||
'icon-sm': 'size-8',
|
"icon-sm": "size-8",
|
||||||
'icon-lg': 'size-10',
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
size: 'default',
|
size: "default",
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
variant = 'default',
|
variant = "default",
|
||||||
size = 'default',
|
size = "default",
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'button'> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : 'button'
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
|
||||||
import { CheckIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Checkbox({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<CheckboxPrimitive.Root
|
|
||||||
data-slot="checkbox"
|
|
||||||
className={cn(
|
|
||||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<CheckboxPrimitive.Indicator
|
|
||||||
data-slot="checkbox-indicator"
|
|
||||||
className="grid place-content-center text-current transition-none"
|
|
||||||
>
|
|
||||||
<CheckIcon className="size-3.5" />
|
|
||||||
</CheckboxPrimitive.Indicator>
|
|
||||||
</CheckboxPrimitive.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Checkbox }
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
|
||||||
|
|
||||||
function Collapsible({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
|
||||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function CollapsibleTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
|
||||||
return (
|
|
||||||
<CollapsiblePrimitive.CollapsibleTrigger
|
|
||||||
data-slot="collapsible-trigger"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CollapsibleContent({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
|
||||||
return (
|
|
||||||
<CollapsiblePrimitive.CollapsibleContent
|
|
||||||
data-slot="collapsible-content"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function ContextMenu({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
|
||||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuSub({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
|
||||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuRadioGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.RadioGroup
|
|
||||||
data-slot="context-menu-radio-group"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuSubTrigger({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.SubTrigger
|
|
||||||
data-slot="context-menu-sub-trigger"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRightIcon className="ml-auto" />
|
|
||||||
</ContextMenuPrimitive.SubTrigger>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuSubContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.SubContent
|
|
||||||
data-slot="context-menu-sub-content"
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Portal>
|
|
||||||
<ContextMenuPrimitive.Content
|
|
||||||
data-slot="context-menu-content"
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</ContextMenuPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuItem({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
|
||||||
inset?: boolean
|
|
||||||
variant?: "default" | "destructive"
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Item
|
|
||||||
data-slot="context-menu-item"
|
|
||||||
data-inset={inset}
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuCheckboxItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
checked,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.CheckboxItem
|
|
||||||
data-slot="context-menu-checkbox-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
checked={checked}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
||||||
<ContextMenuPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</ContextMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</ContextMenuPrimitive.CheckboxItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuRadioItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.RadioItem
|
|
||||||
data-slot="context-menu-radio-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
||||||
<ContextMenuPrimitive.ItemIndicator>
|
|
||||||
<CircleIcon className="size-2 fill-current" />
|
|
||||||
</ContextMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</ContextMenuPrimitive.RadioItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuLabel({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Label
|
|
||||||
data-slot="context-menu-label"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Separator
|
|
||||||
data-slot="context-menu-separator"
|
|
||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuShortcut({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="context-menu-shortcut"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuCheckboxItem,
|
|
||||||
ContextMenuRadioItem,
|
|
||||||
ContextMenuLabel,
|
|
||||||
ContextMenuSeparator,
|
|
||||||
ContextMenuShortcut,
|
|
||||||
ContextMenuGroup,
|
|
||||||
ContextMenuPortal,
|
|
||||||
ContextMenuSub,
|
|
||||||
ContextMenuSubContent,
|
|
||||||
ContextMenuSubTrigger,
|
|
||||||
ContextMenuRadioGroup,
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Drawer as DrawerPrimitive } from "vaul"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Drawer({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
|
||||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
|
||||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
|
||||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerClose({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
|
||||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerOverlay({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
|
||||||
return (
|
|
||||||
<DrawerPrimitive.Overlay
|
|
||||||
data-slot="drawer-overlay"
|
|
||||||
className={cn(
|
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<DrawerPortal data-slot="drawer-portal">
|
|
||||||
<DrawerOverlay />
|
|
||||||
<DrawerPrimitive.Content
|
|
||||||
data-slot="drawer-content"
|
|
||||||
className={cn(
|
|
||||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
|
||||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
|
||||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
|
||||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
|
||||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
|
||||||
{children}
|
|
||||||
</DrawerPrimitive.Content>
|
|
||||||
</DrawerPortal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="drawer-header"
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="drawer-footer"
|
|
||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerTitle({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
|
||||||
return (
|
|
||||||
<DrawerPrimitive.Title
|
|
||||||
data-slot="drawer-title"
|
|
||||||
className={cn("text-foreground font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
|
||||||
return (
|
|
||||||
<DrawerPrimitive.Description
|
|
||||||
data-slot="drawer-description"
|
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Drawer,
|
|
||||||
DrawerPortal,
|
|
||||||
DrawerOverlay,
|
|
||||||
DrawerTrigger,
|
|
||||||
DrawerClose,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerFooter,
|
|
||||||
DrawerTitle,
|
|
||||||
DrawerDescription,
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function DropdownMenu({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
|
||||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Trigger
|
|
||||||
data-slot="dropdown-menu-trigger"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuContent({
|
|
||||||
className,
|
|
||||||
sideOffset = 4,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Portal>
|
|
||||||
<DropdownMenuPrimitive.Content
|
|
||||||
data-slot="dropdown-menu-content"
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</DropdownMenuPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuItem({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
|
||||||
inset?: boolean
|
|
||||||
variant?: "default" | "destructive"
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Item
|
|
||||||
data-slot="dropdown-menu-item"
|
|
||||||
data-inset={inset}
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
checked,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
checked={checked}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.RadioGroup
|
|
||||||
data-slot="dropdown-menu-radio-group"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.RadioItem
|
|
||||||
data-slot="dropdown-menu-radio-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<CircleIcon className="size-2 fill-current" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Label
|
|
||||||
data-slot="dropdown-menu-label"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Separator
|
|
||||||
data-slot="dropdown-menu-separator"
|
|
||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="dropdown-menu-shortcut"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSub({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
|
||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.SubContent
|
|
||||||
data-slot="dropdown-menu-sub-content"
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
// src/components/ui/lateral-confetti.tsx
|
|
||||||
|
|
||||||
import confetti from 'canvas-confetti'
|
|
||||||
|
|
||||||
export function lateralConfetti() {
|
|
||||||
// 1. Reset para limpiar cualquier configuración vieja pegada en memoria
|
|
||||||
confetti.reset()
|
|
||||||
|
|
||||||
const duration = 1500
|
|
||||||
const end = Date.now() + duration
|
|
||||||
|
|
||||||
// 2. Colores vibrantes (cálidos primero)
|
|
||||||
const vibrantColors = [
|
|
||||||
'#FF0000', // Rojo puro
|
|
||||||
'#fcff42', // Amarillo
|
|
||||||
'#88ff5a', // Verde
|
|
||||||
'#26ccff', // Azul
|
|
||||||
'#a25afd', // Morado
|
|
||||||
]
|
|
||||||
|
|
||||||
;(function frame() {
|
|
||||||
const commonSettings = {
|
|
||||||
particleCount: 5,
|
|
||||||
spread: 55,
|
|
||||||
// origin: { x: 0.5 }, // No necesario si definimos origin abajo, pero útil en otros contextos
|
|
||||||
colors: vibrantColors,
|
|
||||||
zIndex: 99999,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cañón izquierdo
|
|
||||||
confetti({
|
|
||||||
...commonSettings,
|
|
||||||
angle: 60,
|
|
||||||
origin: { x: 0, y: 0.6 },
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cañón derecho
|
|
||||||
confetti({
|
|
||||||
...commonSettings,
|
|
||||||
angle: 120,
|
|
||||||
origin: { x: 1, y: 0.6 },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (Date.now() < end) {
|
|
||||||
requestAnimationFrame(frame)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
@@ -1,549 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
|
|
||||||
import type { Transition } from 'motion/react'
|
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
type MotionHighlightMode = 'children' | 'parent'
|
|
||||||
|
|
||||||
type Bounds = {
|
|
||||||
top: number
|
|
||||||
left: number
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type MotionHighlightContextType<T extends string> = {
|
|
||||||
mode: MotionHighlightMode
|
|
||||||
activeValue: T | null
|
|
||||||
setActiveValue: (value: T | null) => void
|
|
||||||
setBounds: (bounds: DOMRect) => void
|
|
||||||
clearBounds: () => void
|
|
||||||
id: string
|
|
||||||
hover: boolean
|
|
||||||
className?: string
|
|
||||||
activeClassName?: string
|
|
||||||
setActiveClassName: (className: string) => void
|
|
||||||
transition?: Transition
|
|
||||||
disabled?: boolean
|
|
||||||
enabled?: boolean
|
|
||||||
exitDelay?: number
|
|
||||||
forceUpdateBounds?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const MotionHighlightContext = React.createContext<
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
MotionHighlightContextType<any> | undefined
|
|
||||||
>(undefined)
|
|
||||||
|
|
||||||
function useMotionHighlight<T extends string>(): MotionHighlightContextType<T> {
|
|
||||||
const context = React.useContext(MotionHighlightContext)
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useMotionHighlight must be used within a MotionHighlightProvider')
|
|
||||||
}
|
|
||||||
|
|
||||||
return context as unknown as MotionHighlightContextType<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
type BaseMotionHighlightProps<T extends string> = {
|
|
||||||
mode?: MotionHighlightMode
|
|
||||||
value?: T | null
|
|
||||||
defaultValue?: T | null
|
|
||||||
onValueChange?: (value: T | null) => void
|
|
||||||
className?: string
|
|
||||||
transition?: Transition
|
|
||||||
hover?: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
enabled?: boolean
|
|
||||||
exitDelay?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type ParentModeMotionHighlightProps = {
|
|
||||||
boundsOffset?: Partial<Bounds>
|
|
||||||
containerClassName?: string
|
|
||||||
forceUpdateBounds?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type ControlledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> &
|
|
||||||
ParentModeMotionHighlightProps & {
|
|
||||||
mode: 'parent'
|
|
||||||
controlledItems: true
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
type ControlledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & {
|
|
||||||
mode?: 'children' | undefined
|
|
||||||
controlledItems: true
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
type UncontrolledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> &
|
|
||||||
ParentModeMotionHighlightProps & {
|
|
||||||
mode: 'parent'
|
|
||||||
controlledItems?: false
|
|
||||||
itemsClassName?: string
|
|
||||||
children: React.ReactElement | React.ReactElement[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type UncontrolledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & {
|
|
||||||
mode?: 'children'
|
|
||||||
controlledItems?: false
|
|
||||||
itemsClassName?: string
|
|
||||||
children: React.ReactElement | React.ReactElement[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type MotionHighlightProps<T extends string> = React.ComponentProps<'div'> &
|
|
||||||
(
|
|
||||||
| ControlledParentModeMotionHighlightProps<T>
|
|
||||||
| ControlledChildrenModeMotionHighlightProps<T>
|
|
||||||
| UncontrolledParentModeMotionHighlightProps<T>
|
|
||||||
| UncontrolledChildrenModeMotionHighlightProps<T>
|
|
||||||
)
|
|
||||||
|
|
||||||
function MotionHighlight<T extends string>({ ref, ...props }: MotionHighlightProps<T>) {
|
|
||||||
const {
|
|
||||||
children,
|
|
||||||
value,
|
|
||||||
defaultValue,
|
|
||||||
onValueChange,
|
|
||||||
className,
|
|
||||||
transition = { type: 'spring', stiffness: 350, damping: 35 },
|
|
||||||
hover = false,
|
|
||||||
enabled = true,
|
|
||||||
controlledItems,
|
|
||||||
disabled = false,
|
|
||||||
exitDelay = 0.2,
|
|
||||||
mode = 'children'
|
|
||||||
} = props
|
|
||||||
|
|
||||||
const localRef = React.useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement)
|
|
||||||
|
|
||||||
const [activeValue, setActiveValue] = React.useState<T | null>(value ?? defaultValue ?? null)
|
|
||||||
const [boundsState, setBoundsState] = React.useState<Bounds | null>(null)
|
|
||||||
const [activeClassNameState, setActiveClassNameState] = React.useState<string>('')
|
|
||||||
|
|
||||||
const safeSetActiveValue = React.useCallback(
|
|
||||||
(id: T | null) => {
|
|
||||||
setActiveValue(prev => (prev === id ? prev : id))
|
|
||||||
if (id !== activeValue) onValueChange?.(id as T)
|
|
||||||
},
|
|
||||||
[activeValue, onValueChange]
|
|
||||||
)
|
|
||||||
|
|
||||||
const safeSetBounds = React.useCallback(
|
|
||||||
(bounds: DOMRect) => {
|
|
||||||
if (!localRef.current) return
|
|
||||||
|
|
||||||
const boundsOffset = (props as ParentModeMotionHighlightProps)?.boundsOffset ?? {
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: 0,
|
|
||||||
height: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerRect = localRef.current.getBoundingClientRect()
|
|
||||||
|
|
||||||
const newBounds: Bounds = {
|
|
||||||
top: bounds.top - containerRect.top + (boundsOffset.top ?? 0),
|
|
||||||
left: bounds.left - containerRect.left + (boundsOffset.left ?? 0),
|
|
||||||
width: bounds.width + (boundsOffset.width ?? 0),
|
|
||||||
height: bounds.height + (boundsOffset.height ?? 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
setBoundsState(prev => {
|
|
||||||
if (
|
|
||||||
prev &&
|
|
||||||
prev.top === newBounds.top &&
|
|
||||||
prev.left === newBounds.left &&
|
|
||||||
prev.width === newBounds.width &&
|
|
||||||
prev.height === newBounds.height
|
|
||||||
) {
|
|
||||||
return prev
|
|
||||||
}
|
|
||||||
|
|
||||||
return newBounds
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[props]
|
|
||||||
)
|
|
||||||
|
|
||||||
const clearBounds = React.useCallback(() => {
|
|
||||||
setBoundsState(prev => (prev === null ? prev : null))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (value !== undefined) setActiveValue(value)
|
|
||||||
else if (defaultValue !== undefined) setActiveValue(defaultValue)
|
|
||||||
}, [value, defaultValue])
|
|
||||||
|
|
||||||
const id = React.useId()
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (mode !== 'parent') return
|
|
||||||
const container = localRef.current
|
|
||||||
|
|
||||||
if (!container) return
|
|
||||||
|
|
||||||
const onScroll = () => {
|
|
||||||
if (!activeValue) return
|
|
||||||
const activeEl = container.querySelector<HTMLElement>(`[data-value="${activeValue}"][data-highlight="true"]`)
|
|
||||||
|
|
||||||
if (activeEl) safeSetBounds(activeEl.getBoundingClientRect())
|
|
||||||
}
|
|
||||||
|
|
||||||
container.addEventListener('scroll', onScroll, { passive: true })
|
|
||||||
|
|
||||||
return () => container.removeEventListener('scroll', onScroll)
|
|
||||||
}, [mode, activeValue, safeSetBounds])
|
|
||||||
|
|
||||||
const render = React.useCallback(
|
|
||||||
(children: React.ReactNode) => {
|
|
||||||
if (mode === 'parent') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={localRef}
|
|
||||||
data-slot='motion-highlight-container'
|
|
||||||
className={cn('relative', (props as ParentModeMotionHighlightProps)?.containerClassName)}
|
|
||||||
>
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{boundsState && (
|
|
||||||
<motion.div
|
|
||||||
data-slot='motion-highlight'
|
|
||||||
animate={{
|
|
||||||
top: boundsState.top,
|
|
||||||
left: boundsState.left,
|
|
||||||
width: boundsState.width,
|
|
||||||
height: boundsState.height,
|
|
||||||
opacity: 1
|
|
||||||
}}
|
|
||||||
initial={{
|
|
||||||
top: boundsState.top,
|
|
||||||
left: boundsState.left,
|
|
||||||
width: boundsState.width,
|
|
||||||
height: boundsState.height,
|
|
||||||
opacity: 0
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
transition: {
|
|
||||||
...transition,
|
|
||||||
delay: (transition?.delay ?? 0) + (exitDelay ?? 0)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
transition={transition}
|
|
||||||
className={cn('bg-muted absolute z-0', className, activeClassNameState)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return children
|
|
||||||
},
|
|
||||||
[mode, props, boundsState, transition, exitDelay, className, activeClassNameState]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MotionHighlightContext.Provider
|
|
||||||
value={{
|
|
||||||
mode,
|
|
||||||
activeValue,
|
|
||||||
setActiveValue: safeSetActiveValue,
|
|
||||||
id,
|
|
||||||
hover,
|
|
||||||
className,
|
|
||||||
transition,
|
|
||||||
disabled,
|
|
||||||
enabled,
|
|
||||||
exitDelay,
|
|
||||||
setBounds: safeSetBounds,
|
|
||||||
clearBounds,
|
|
||||||
activeClassName: activeClassNameState,
|
|
||||||
setActiveClassName: setActiveClassNameState,
|
|
||||||
forceUpdateBounds: (props as ParentModeMotionHighlightProps)?.forceUpdateBounds
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{enabled
|
|
||||||
? controlledItems
|
|
||||||
? render(children)
|
|
||||||
: render(
|
|
||||||
React.Children.map(children, (child, index) => (
|
|
||||||
<MotionHighlightItem key={index} className={props?.itemsClassName}>
|
|
||||||
{child}
|
|
||||||
</MotionHighlightItem>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
: children}
|
|
||||||
</MotionHighlightContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNonOverridingDataAttributes(
|
|
||||||
element: React.ReactElement,
|
|
||||||
dataAttributes: Record<string, unknown>
|
|
||||||
): Record<string, unknown> {
|
|
||||||
return Object.keys(dataAttributes).reduce<Record<string, unknown>>((acc, key) => {
|
|
||||||
if ((element.props as Record<string, unknown>)[key] === undefined) {
|
|
||||||
acc[key] = dataAttributes[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExtendedChildProps = React.ComponentProps<'div'> & {
|
|
||||||
id?: string
|
|
||||||
ref?: React.Ref<HTMLElement>
|
|
||||||
'data-active'?: string
|
|
||||||
'data-value'?: string
|
|
||||||
'data-disabled'?: boolean
|
|
||||||
'data-highlight'?: boolean
|
|
||||||
'data-slot'?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type MotionHighlightItemProps = React.ComponentProps<'div'> & {
|
|
||||||
children: React.ReactElement
|
|
||||||
id?: string
|
|
||||||
value?: string
|
|
||||||
className?: string
|
|
||||||
transition?: Transition
|
|
||||||
activeClassName?: string
|
|
||||||
disabled?: boolean
|
|
||||||
exitDelay?: number
|
|
||||||
asChild?: boolean
|
|
||||||
forceUpdateBounds?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function MotionHighlightItem({
|
|
||||||
ref,
|
|
||||||
children,
|
|
||||||
id,
|
|
||||||
value,
|
|
||||||
className,
|
|
||||||
transition,
|
|
||||||
disabled = false,
|
|
||||||
activeClassName,
|
|
||||||
exitDelay,
|
|
||||||
asChild = false,
|
|
||||||
forceUpdateBounds,
|
|
||||||
...props
|
|
||||||
}: MotionHighlightItemProps) {
|
|
||||||
const itemId = React.useId()
|
|
||||||
|
|
||||||
const {
|
|
||||||
activeValue,
|
|
||||||
setActiveValue,
|
|
||||||
mode,
|
|
||||||
setBounds,
|
|
||||||
clearBounds,
|
|
||||||
hover,
|
|
||||||
enabled,
|
|
||||||
className: contextClassName,
|
|
||||||
transition: contextTransition,
|
|
||||||
id: contextId,
|
|
||||||
disabled: contextDisabled,
|
|
||||||
exitDelay: contextExitDelay,
|
|
||||||
forceUpdateBounds: contextForceUpdateBounds,
|
|
||||||
setActiveClassName
|
|
||||||
} = useMotionHighlight()
|
|
||||||
|
|
||||||
const element = children as React.ReactElement<ExtendedChildProps>
|
|
||||||
const childValue = id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId
|
|
||||||
const isActive = activeValue === childValue
|
|
||||||
const isDisabled = disabled === undefined ? contextDisabled : disabled
|
|
||||||
const itemTransition = transition ?? contextTransition
|
|
||||||
|
|
||||||
const localRef = React.useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (mode !== 'parent') return
|
|
||||||
let rafId: number
|
|
||||||
let previousBounds: Bounds | null = null
|
|
||||||
const shouldUpdateBounds = forceUpdateBounds === true || (contextForceUpdateBounds && forceUpdateBounds !== false)
|
|
||||||
|
|
||||||
const updateBounds = () => {
|
|
||||||
if (!localRef.current) return
|
|
||||||
|
|
||||||
const bounds = localRef.current.getBoundingClientRect()
|
|
||||||
|
|
||||||
if (shouldUpdateBounds) {
|
|
||||||
if (
|
|
||||||
previousBounds &&
|
|
||||||
previousBounds.top === bounds.top &&
|
|
||||||
previousBounds.left === bounds.left &&
|
|
||||||
previousBounds.width === bounds.width &&
|
|
||||||
previousBounds.height === bounds.height
|
|
||||||
) {
|
|
||||||
rafId = requestAnimationFrame(updateBounds)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
previousBounds = bounds
|
|
||||||
rafId = requestAnimationFrame(updateBounds)
|
|
||||||
}
|
|
||||||
|
|
||||||
setBounds(bounds)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isActive) {
|
|
||||||
updateBounds()
|
|
||||||
setActiveClassName(activeClassName ?? '')
|
|
||||||
} else if (!activeValue) clearBounds()
|
|
||||||
|
|
||||||
if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId)
|
|
||||||
}, [
|
|
||||||
mode,
|
|
||||||
isActive,
|
|
||||||
activeValue,
|
|
||||||
setBounds,
|
|
||||||
clearBounds,
|
|
||||||
activeClassName,
|
|
||||||
setActiveClassName,
|
|
||||||
forceUpdateBounds,
|
|
||||||
contextForceUpdateBounds
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!React.isValidElement(children)) return children
|
|
||||||
|
|
||||||
const dataAttributes = {
|
|
||||||
'data-active': isActive ? 'true' : 'false',
|
|
||||||
'aria-selected': isActive,
|
|
||||||
'data-disabled': isDisabled,
|
|
||||||
'data-value': childValue,
|
|
||||||
'data-highlight': true
|
|
||||||
}
|
|
||||||
|
|
||||||
const commonHandlers = hover
|
|
||||||
? {
|
|
||||||
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
setActiveValue(childValue)
|
|
||||||
element.props.onMouseEnter?.(e)
|
|
||||||
},
|
|
||||||
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
setActiveValue(null)
|
|
||||||
element.props.onMouseLeave?.(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
onClick: (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
setActiveValue(childValue)
|
|
||||||
element.props.onClick?.(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (asChild) {
|
|
||||||
if (mode === 'children') {
|
|
||||||
return React.cloneElement(
|
|
||||||
element,
|
|
||||||
{
|
|
||||||
key: childValue,
|
|
||||||
ref: localRef,
|
|
||||||
className: cn('relative', element.props.className),
|
|
||||||
...getNonOverridingDataAttributes(element, {
|
|
||||||
...dataAttributes,
|
|
||||||
'data-slot': 'motion-highlight-item-container'
|
|
||||||
}),
|
|
||||||
...commonHandlers,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
<>
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{isActive && !isDisabled && (
|
|
||||||
<motion.div
|
|
||||||
layoutId={`transition-background-${contextId}`}
|
|
||||||
data-slot='motion-highlight'
|
|
||||||
className={cn('bg-muted absolute inset-0 z-0', contextClassName, activeClassName)}
|
|
||||||
transition={itemTransition}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
transition: {
|
|
||||||
...itemTransition,
|
|
||||||
delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
{...dataAttributes}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<div data-slot='motion-highlight-item' className={cn('relative z-[1]', className)} {...dataAttributes}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return React.cloneElement(element, {
|
|
||||||
ref: localRef,
|
|
||||||
...getNonOverridingDataAttributes(element, {
|
|
||||||
...dataAttributes,
|
|
||||||
'data-slot': 'motion-highlight-item'
|
|
||||||
}),
|
|
||||||
...commonHandlers
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return enabled ? (
|
|
||||||
<div
|
|
||||||
key={childValue}
|
|
||||||
ref={localRef}
|
|
||||||
data-slot='motion-highlight-item-container'
|
|
||||||
className={cn(mode === 'children' && 'relative', className)}
|
|
||||||
{...dataAttributes}
|
|
||||||
{...props}
|
|
||||||
{...commonHandlers}
|
|
||||||
>
|
|
||||||
{mode === 'children' && (
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{isActive && !isDisabled && (
|
|
||||||
<motion.div
|
|
||||||
layoutId={`transition-background-${contextId}`}
|
|
||||||
data-slot='motion-highlight'
|
|
||||||
className={cn('bg-muted absolute inset-0 z-0', contextClassName, activeClassName)}
|
|
||||||
transition={itemTransition}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
transition: {
|
|
||||||
...itemTransition,
|
|
||||||
delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
{...dataAttributes}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{React.cloneElement(element, {
|
|
||||||
className: cn('relative z-[1]', element.props.className),
|
|
||||||
...getNonOverridingDataAttributes(element, {
|
|
||||||
...dataAttributes,
|
|
||||||
'data-slot': 'motion-highlight-item'
|
|
||||||
})
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
MotionHighlight,
|
|
||||||
MotionHighlightItem,
|
|
||||||
useMotionHighlight,
|
|
||||||
type MotionHighlightProps,
|
|
||||||
type MotionHighlightItemProps
|
|
||||||
}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import * as React from 'react'
|
|
||||||
|
|
||||||
import { motion, type Transition, type HTMLMotionProps } from 'motion/react'
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { MotionHighlight, MotionHighlightItem } from '@/components/ui/motion-highlight'
|
|
||||||
|
|
||||||
type TabsContextType<T extends string> = {
|
|
||||||
activeValue: T
|
|
||||||
handleValueChange: (value: T) => void
|
|
||||||
registerTrigger: (value: T, node: HTMLElement | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const TabsContext = React.createContext<TabsContextType<any> | undefined>(undefined)
|
|
||||||
|
|
||||||
function useTabs<T extends string = string>(): TabsContextType<T> {
|
|
||||||
const context = React.useContext(TabsContext)
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useTabs must be used within a TabsProvider')
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
type BaseTabsProps = React.ComponentProps<'div'> & {
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
type UnControlledTabsProps<T extends string = string> = BaseTabsProps & {
|
|
||||||
defaultValue?: T
|
|
||||||
value?: never
|
|
||||||
onValueChange?: never
|
|
||||||
}
|
|
||||||
|
|
||||||
type ControlledTabsProps<T extends string = string> = BaseTabsProps & {
|
|
||||||
value: T
|
|
||||||
onValueChange?: (value: T) => void
|
|
||||||
defaultValue?: never
|
|
||||||
}
|
|
||||||
|
|
||||||
type TabsProps<T extends string = string> = UnControlledTabsProps<T> | ControlledTabsProps<T>
|
|
||||||
|
|
||||||
function Tabs<T extends string = string>({
|
|
||||||
defaultValue,
|
|
||||||
value,
|
|
||||||
onValueChange,
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: TabsProps<T>) {
|
|
||||||
const [activeValue, setActiveValue] = React.useState<T | undefined>(defaultValue ?? undefined)
|
|
||||||
const triggersRef = React.useRef(new Map<string, HTMLElement>())
|
|
||||||
const initialSet = React.useRef(false)
|
|
||||||
const isControlled = value !== undefined
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!isControlled && activeValue === undefined && triggersRef.current.size > 0 && !initialSet.current) {
|
|
||||||
const firstTab = Array.from(triggersRef.current.keys())[0]
|
|
||||||
|
|
||||||
setActiveValue(firstTab as T)
|
|
||||||
initialSet.current = true
|
|
||||||
}
|
|
||||||
}, [activeValue, isControlled])
|
|
||||||
|
|
||||||
const registerTrigger = (value: string, node: HTMLElement | null) => {
|
|
||||||
if (node) {
|
|
||||||
triggersRef.current.set(value, node)
|
|
||||||
|
|
||||||
if (!isControlled && activeValue === undefined && !initialSet.current) {
|
|
||||||
setActiveValue(value as T)
|
|
||||||
initialSet.current = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
triggersRef.current.delete(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleValueChange = (val: T) => {
|
|
||||||
if (!isControlled) setActiveValue(val)
|
|
||||||
else onValueChange?.(val)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TabsContext.Provider
|
|
||||||
value={{
|
|
||||||
activeValue: (value ?? activeValue)!,
|
|
||||||
handleValueChange,
|
|
||||||
registerTrigger
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div data-slot='tabs' className={cn('flex flex-col gap-2', className)} {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</TabsContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type TabsListProps = React.ComponentProps<'div'> & {
|
|
||||||
children: React.ReactNode
|
|
||||||
activeClassName?: string
|
|
||||||
transition?: Transition
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsList({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
activeClassName,
|
|
||||||
transition = {
|
|
||||||
type: 'spring',
|
|
||||||
stiffness: 200,
|
|
||||||
damping: 25
|
|
||||||
},
|
|
||||||
...props
|
|
||||||
}: TabsListProps) {
|
|
||||||
const { activeValue } = useTabs()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MotionHighlight
|
|
||||||
controlledItems
|
|
||||||
className={cn('bg-background rounded-sm shadow-sm', activeClassName)}
|
|
||||||
value={activeValue}
|
|
||||||
transition={transition}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
role='tablist'
|
|
||||||
data-slot='tabs-list'
|
|
||||||
className={cn(
|
|
||||||
'bg-muted text-muted-foreground inline-flex h-10 w-fit items-center justify-center rounded-lg p-[4px]',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</MotionHighlight>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type TabsTriggerProps = HTMLMotionProps<'button'> & {
|
|
||||||
value: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsTrigger({ ref, value, children, className, ...props }: TabsTriggerProps) {
|
|
||||||
const { activeValue, handleValueChange, registerTrigger } = useTabs()
|
|
||||||
|
|
||||||
const localRef = React.useRef<HTMLButtonElement | null>(null)
|
|
||||||
|
|
||||||
React.useImperativeHandle(ref, () => localRef.current as HTMLButtonElement)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
registerTrigger(value, localRef.current)
|
|
||||||
|
|
||||||
return () => registerTrigger(value, null)
|
|
||||||
}, [value, registerTrigger])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MotionHighlightItem value={value} className='size-full'>
|
|
||||||
<motion.button
|
|
||||||
ref={localRef}
|
|
||||||
data-slot='tabs-trigger'
|
|
||||||
role='tab'
|
|
||||||
onClick={() => handleValueChange(value)}
|
|
||||||
data-state={activeValue === value ? 'active' : 'inactive'}
|
|
||||||
className={cn(
|
|
||||||
'ring-offset-background focus-visible:ring-ring data-[state=active]:text-foreground z-[1] inline-flex size-full cursor-pointer items-center justify-center rounded-sm px-2 py-1 text-sm font-medium whitespace-nowrap transition-transform focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.button>
|
|
||||||
</MotionHighlightItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type TabsContentsProps = React.ComponentProps<'div'> & {
|
|
||||||
children: React.ReactNode
|
|
||||||
transition?: Transition
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsContents({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
transition = {
|
|
||||||
type: 'spring',
|
|
||||||
stiffness: 300,
|
|
||||||
damping: 30,
|
|
||||||
bounce: 0,
|
|
||||||
restDelta: 0.01
|
|
||||||
},
|
|
||||||
...props
|
|
||||||
}: TabsContentsProps) {
|
|
||||||
const { activeValue } = useTabs()
|
|
||||||
const childrenArray = React.Children.toArray(children)
|
|
||||||
|
|
||||||
const activeIndex = childrenArray.findIndex(
|
|
||||||
(child): child is React.ReactElement<{ value: string }> =>
|
|
||||||
React.isValidElement(child) &&
|
|
||||||
typeof child.props === 'object' &&
|
|
||||||
child.props !== null &&
|
|
||||||
'value' in child.props &&
|
|
||||||
child.props.value === activeValue
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div data-slot='tabs-contents' className={cn('overflow-hidden', className)} {...props}>
|
|
||||||
<motion.div className='-mx-2 flex' animate={{ x: activeIndex * -100 + '%' }} transition={transition}>
|
|
||||||
{childrenArray.map((child, index) => (
|
|
||||||
<div key={index} className='w-full shrink-0 px-2'>
|
|
||||||
{child}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type TabsContentProps = HTMLMotionProps<'div'> & {
|
|
||||||
value: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsContent({ children, value, className, ...props }: TabsContentProps) {
|
|
||||||
const { activeValue } = useTabs()
|
|
||||||
const isActive = activeValue === value
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
role='tabpanel'
|
|
||||||
data-slot='tabs-content'
|
|
||||||
className={cn('overflow-hidden', className)}
|
|
||||||
initial={{ filter: 'blur(0px)' }}
|
|
||||||
animate={{ filter: isActive ? 'blur(0px)' : 'blur(2px)' }}
|
|
||||||
exit={{ filter: 'blur(0px)' }}
|
|
||||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Tabs,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
TabsContents,
|
|
||||||
TabsContent,
|
|
||||||
useTabs,
|
|
||||||
type TabsContextType,
|
|
||||||
type TabsProps,
|
|
||||||
type TabsListProps,
|
|
||||||
type TabsTriggerProps,
|
|
||||||
type TabsContentsProps,
|
|
||||||
type TabsContentProps
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Select({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectValue({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectTrigger({
|
|
||||||
className,
|
|
||||||
size = "default",
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
|
||||||
size?: "sm" | "default"
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Trigger
|
|
||||||
data-slot="select-trigger"
|
|
||||||
data-size={size}
|
|
||||||
className={cn(
|
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SelectPrimitive.Icon asChild>
|
|
||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
|
||||||
</SelectPrimitive.Icon>
|
|
||||||
</SelectPrimitive.Trigger>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
position = "item-aligned",
|
|
||||||
align = "center",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Portal>
|
|
||||||
<SelectPrimitive.Content
|
|
||||||
data-slot="select-content"
|
|
||||||
className={cn(
|
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
|
||||||
position === "popper" &&
|
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
position={position}
|
|
||||||
align={align}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SelectScrollUpButton />
|
|
||||||
<SelectPrimitive.Viewport
|
|
||||||
className={cn(
|
|
||||||
"p-1",
|
|
||||||
position === "popper" &&
|
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SelectPrimitive.Viewport>
|
|
||||||
<SelectScrollDownButton />
|
|
||||||
</SelectPrimitive.Content>
|
|
||||||
</SelectPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectLabel({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Label
|
|
||||||
data-slot="select-label"
|
|
||||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Item
|
|
||||||
data-slot="select-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
data-slot="select-item-indicator"
|
|
||||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
|
||||||
>
|
|
||||||
<SelectPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</SelectPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
||||||
</SelectPrimitive.Item>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Separator
|
|
||||||
data-slot="select-separator"
|
|
||||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.ScrollUpButton
|
|
||||||
data-slot="select-scroll-up-button"
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronUpIcon className="size-4" />
|
|
||||||
</SelectPrimitive.ScrollUpButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.ScrollDownButton
|
|
||||||
data-slot="select-scroll-down-button"
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronDownIcon className="size-4" />
|
|
||||||
</SelectPrimitive.ScrollDownButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectScrollDownButton,
|
|
||||||
SelectScrollUpButton,
|
|
||||||
SelectSeparator,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Separator({
|
|
||||||
className,
|
|
||||||
orientation = "horizontal",
|
|
||||||
decorative = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<SeparatorPrimitive.Root
|
|
||||||
data-slot="separator"
|
|
||||||
decorative={decorative}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Separator }
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="skeleton"
|
|
||||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Skeleton }
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Tabs({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Root
|
|
||||||
data-slot="tabs"
|
|
||||||
className={cn("flex flex-col gap-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsList({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.List
|
|
||||||
data-slot="tabs-list"
|
|
||||||
className={cn(
|
|
||||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsTrigger({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Trigger
|
|
||||||
data-slot="tabs-trigger"
|
|
||||||
className={cn(
|
|
||||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabsContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<TabsPrimitive.Content
|
|
||||||
data-slot="tabs-content"
|
|
||||||
className={cn("flex-1 outline-none", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import * as Icons from 'lucide-react'
|
|
||||||
|
|
||||||
import { CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
|
||||||
|
|
||||||
export function WizardLayout({
|
|
||||||
title,
|
|
||||||
onClose,
|
|
||||||
headerSlot,
|
|
||||||
footerSlot,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
onClose: () => void
|
|
||||||
headerSlot?: React.ReactNode
|
|
||||||
footerSlot?: React.ReactNode
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Dialog open={true} onOpenChange={(open) => !open && onClose()}>
|
|
||||||
<DialogContent
|
|
||||||
className="flex h-[90vh] w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl"
|
|
||||||
onInteractOutside={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="z-10 flex-none border-b bg-white">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-4 p-6 pb-4">
|
|
||||||
<CardTitle>{title}</CardTitle>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none"
|
|
||||||
>
|
|
||||||
<Icons.X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Cerrar</span>
|
|
||||||
</button>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
{headerSlot ? <div className="px-6 pb-6">{headerSlot}</div> : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto bg-gray-50/30 p-6">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{footerSlot ? (
|
|
||||||
<div className="flex-none border-t bg-white p-6">{footerSlot}</div>
|
|
||||||
) : null}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { CircularProgress } from '@/components/CircularProgress'
|
|
||||||
import { StepWithTooltip } from '@/components/wizard/StepWithTooltip'
|
|
||||||
|
|
||||||
export function WizardResponsiveHeader({
|
|
||||||
wizard,
|
|
||||||
methods,
|
|
||||||
titleOverrides,
|
|
||||||
}: {
|
|
||||||
wizard: any
|
|
||||||
methods: any
|
|
||||||
titleOverrides?: Record<string, string>
|
|
||||||
}) {
|
|
||||||
const idx = wizard.utils.getIndex(methods.current.id)
|
|
||||||
const totalSteps = wizard.steps.length
|
|
||||||
const currentIndex = idx + 1
|
|
||||||
const hasNextStep = idx < totalSteps - 1
|
|
||||||
const nextStep = wizard.steps[currentIndex]
|
|
||||||
|
|
||||||
const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="block sm:hidden">
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<CircularProgress current={currentIndex} total={totalSteps} />
|
|
||||||
<div className="flex flex-col justify-center">
|
|
||||||
<h2 className="text-lg font-bold text-slate-900">
|
|
||||||
<StepWithTooltip
|
|
||||||
title={resolveTitle(methods.current)}
|
|
||||||
desc={methods.current.description}
|
|
||||||
/>
|
|
||||||
</h2>
|
|
||||||
{hasNextStep && nextStep ? (
|
|
||||||
<p className="text-sm text-slate-400">
|
|
||||||
Siguiente: {resolveTitle(nextStep)}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm font-medium text-green-500">
|
|
||||||
¡Último paso!
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
|
|
||||||
{wizard.steps.map((step: any) => (
|
|
||||||
<wizard.Stepper.Step
|
|
||||||
key={step.id}
|
|
||||||
of={step.id}
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<wizard.Stepper.Title>
|
|
||||||
<StepWithTooltip
|
|
||||||
title={resolveTitle(step)}
|
|
||||||
desc={step.description}
|
|
||||||
/>
|
|
||||||
</wizard.Stepper.Title>
|
|
||||||
</wizard.Stepper.Step>
|
|
||||||
))}
|
|
||||||
</wizard.Stepper.Navigation>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import type { Database } from '../types/database'
|
|
||||||
import type {
|
|
||||||
PostgrestError,
|
|
||||||
AuthError,
|
|
||||||
SupabaseClient,
|
|
||||||
} from '@supabase/supabase-js'
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly code?: string,
|
|
||||||
public readonly details?: unknown,
|
|
||||||
public readonly hint?: string,
|
|
||||||
) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'ApiError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function throwIfError(error: PostgrestError | AuthError | null): void {
|
|
||||||
if (!error) return
|
|
||||||
const anyErr = error as any
|
|
||||||
throw new ApiError(
|
|
||||||
anyErr.message ?? 'Error inesperado',
|
|
||||||
anyErr.code,
|
|
||||||
anyErr.details,
|
|
||||||
anyErr.hint,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requireData<T>(
|
|
||||||
data: T | null | undefined,
|
|
||||||
message = 'Respuesta vacía',
|
|
||||||
): T {
|
|
||||||
if (data === null || data === undefined) throw new ApiError(message)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUserIdOrThrow(
|
|
||||||
supabase: SupabaseClient<Database>,
|
|
||||||
): Promise<string> {
|
|
||||||
const { data, error } = await supabase.auth.getUser()
|
|
||||||
throwIfError(error)
|
|
||||||
if (!data?.user?.id) throw new ApiError('No hay sesión activa (auth).')
|
|
||||||
return data.user.id
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildRange(
|
|
||||||
limit?: number,
|
|
||||||
offset?: number,
|
|
||||||
): { from?: number; to?: number } {
|
|
||||||
if (!limit) return {}
|
|
||||||
const from = Math.max(0, offset ?? 0)
|
|
||||||
const to = from + Math.max(1, limit) - 1
|
|
||||||
return { from, to }
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
import { supabaseBrowser } from '../supabase/client'
|
|
||||||
import { invokeEdge } from '../supabase/invokeEdge'
|
|
||||||
|
|
||||||
import type { InteraccionIA, UUID } from '../types/domain'
|
|
||||||
|
|
||||||
const EDGE = {
|
|
||||||
ai_plan_improve: 'ai_plan_improve',
|
|
||||||
ai_plan_chat: 'ai_plan_chat',
|
|
||||||
ai_subject_improve: 'ai_subject_improve',
|
|
||||||
ai_subject_chat: 'ai_subject_chat',
|
|
||||||
|
|
||||||
library_search: 'library_search',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export async function ai_plan_improve(payload: {
|
|
||||||
planId: UUID
|
|
||||||
sectionKey: string // ej: "perfil_de_egreso" o tu key interna
|
|
||||||
prompt: string
|
|
||||||
context?: Record<string, any>
|
|
||||||
fuentes?: {
|
|
||||||
archivosIds?: Array<UUID>
|
|
||||||
vectorStoresIds?: Array<UUID>
|
|
||||||
usarMCP?: boolean
|
|
||||||
conversacionId?: string
|
|
||||||
}
|
|
||||||
}): Promise<{ interaccion: InteraccionIA; propuesta: any }> {
|
|
||||||
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(
|
|
||||||
EDGE.ai_plan_improve,
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ai_plan_chat(payload: {
|
|
||||||
planId: UUID
|
|
||||||
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
|
|
||||||
fuentes?: {
|
|
||||||
archivosIds?: Array<UUID>
|
|
||||||
vectorStoresIds?: Array<UUID>
|
|
||||||
usarMCP?: boolean
|
|
||||||
conversacionId?: string
|
|
||||||
}
|
|
||||||
}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> {
|
|
||||||
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(
|
|
||||||
EDGE.ai_plan_chat,
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ai_subject_improve(payload: {
|
|
||||||
subjectId: UUID
|
|
||||||
sectionKey: string
|
|
||||||
prompt: string
|
|
||||||
context?: Record<string, any>
|
|
||||||
fuentes?: {
|
|
||||||
archivosIds?: Array<UUID>
|
|
||||||
vectorStoresIds?: Array<UUID>
|
|
||||||
usarMCP?: boolean
|
|
||||||
conversacionId?: string
|
|
||||||
}
|
|
||||||
}): Promise<{ interaccion: InteraccionIA; propuesta: any }> {
|
|
||||||
return invokeEdge<{ interaccion: InteraccionIA; propuesta: any }>(
|
|
||||||
EDGE.ai_subject_improve,
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ai_subject_chat(payload: {
|
|
||||||
subjectId: UUID
|
|
||||||
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
|
|
||||||
fuentes?: {
|
|
||||||
archivosIds?: Array<UUID>
|
|
||||||
vectorStoresIds?: Array<UUID>
|
|
||||||
usarMCP?: boolean
|
|
||||||
conversacionId?: string
|
|
||||||
}
|
|
||||||
}): Promise<{ interaccion: InteraccionIA; reply: string; meta?: any }> {
|
|
||||||
return invokeEdge<{ interaccion: InteraccionIA; reply: string; meta?: any }>(
|
|
||||||
EDGE.ai_subject_chat,
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Biblioteca (Edge; adapta a tu API real) */
|
|
||||||
export type LibraryItem = {
|
|
||||||
id: string
|
|
||||||
titulo: string
|
|
||||||
autor?: string
|
|
||||||
isbn?: string
|
|
||||||
citaSugerida?: string
|
|
||||||
disponibilidad?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function library_search(payload: {
|
|
||||||
query: string
|
|
||||||
limit?: number
|
|
||||||
}): Promise<Array<LibraryItem>> {
|
|
||||||
return invokeEdge<Array<LibraryItem>>(EDGE.library_search, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function create_conversation(planId: string) {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const { data, error } = await supabase.functions.invoke(
|
|
||||||
'create-chat-conversation/conversations',
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
plan_estudio_id: planId, // O el nombre que confirmamos que funciona
|
|
||||||
instanciador: 'alex',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function get_chat_history(conversacionId: string) {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const { data, error } = await supabase.functions.invoke(
|
|
||||||
`create-chat-conversation/conversations/${conversacionId}/messages`,
|
|
||||||
{ method: 'GET' },
|
|
||||||
)
|
|
||||||
if (error) throw error
|
|
||||||
return data // Retorna Array de mensajes
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function update_conversation_status(
|
|
||||||
conversacionId: string,
|
|
||||||
nuevoEstado: 'ARCHIVADA' | 'ACTIVA',
|
|
||||||
) {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('conversaciones_plan') // Asegúrate que el nombre de la tabla sea exacto
|
|
||||||
.update({ estado: nuevoEstado })
|
|
||||||
.eq('id', conversacionId)
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modificamos la función de chat para que use la ruta de mensajes
|
|
||||||
export async function ai_plan_chat_v2(payload: {
|
|
||||||
conversacionId: string
|
|
||||||
content: string
|
|
||||||
campos?: Array<string>
|
|
||||||
}): Promise<{ reply: string; meta?: any }> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const { data, error } = await supabase.functions.invoke(
|
|
||||||
`create-chat-conversation/conversations/${payload.conversacionId}/messages`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
content: payload.content,
|
|
||||||
campos: payload.campos || [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (error) throw error
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getConversationByPlan(planId: string) {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('conversaciones_plan')
|
|
||||||
.select('*')
|
|
||||||
.eq('plan_estudio_id', planId)
|
|
||||||
.order('creado_en', { ascending: false })
|
|
||||||
if (error) throw error
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
||||||
return data ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function update_conversation_title(
|
|
||||||
conversacionId: string,
|
|
||||||
nuevoTitulo: string,
|
|
||||||
) {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('conversaciones_plan')
|
|
||||||
.update({ nombre: nuevoTitulo }) // Asegúrate que la columna se llame 'title' o 'nombre'
|
|
||||||
.eq('id', conversacionId)
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function update_recommendation_applied_status(
|
|
||||||
conversacionId: string,
|
|
||||||
campoAfectado: string,
|
|
||||||
) {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
// 1. Obtener el estado actual del JSON
|
|
||||||
const { data: conv, error: fetchError } = await supabase
|
|
||||||
.from('conversaciones_plan')
|
|
||||||
.select('conversacion_json')
|
|
||||||
.eq('id', conversacionId)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (fetchError) throw fetchError
|
|
||||||
if (!conv.conversacion_json) throw new Error('No se encontró la conversación')
|
|
||||||
|
|
||||||
// 2. Transformar el JSON para marcar como aplicada la recomendación específica
|
|
||||||
// Usamos una transformación inmutable para evitar efectos secundarios
|
|
||||||
const nuevoJson = (conv.conversacion_json as Array<any>).map((msg) => {
|
|
||||||
if (msg.user === 'assistant' && Array.isArray(msg.recommendations)) {
|
|
||||||
return {
|
|
||||||
...msg,
|
|
||||||
recommendations: msg.recommendations.map((rec: any) =>
|
|
||||||
rec.campo_afectado === campoAfectado
|
|
||||||
? { ...rec, aplicada: true }
|
|
||||||
: rec,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. Actualizar la base de datos con el nuevo JSON
|
|
||||||
const { data, error: updateError } = await supabase
|
|
||||||
.from('conversaciones_plan')
|
|
||||||
.update({ conversacion_json: nuevoJson })
|
|
||||||
.eq('id', conversacionId)
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (updateError) throw updateError
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// document.api.ts
|
|
||||||
|
|
||||||
const DOCUMENT_PDF_URL =
|
|
||||||
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
|
|
||||||
|
|
||||||
interface GeneratePdfParams {
|
|
||||||
plan_estudio_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchPlanPdf({
|
|
||||||
plan_estudio_id,
|
|
||||||
}: GeneratePdfParams): Promise<Blob> {
|
|
||||||
const response = await fetch(DOCUMENT_PDF_URL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ plan_estudio_id }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Error al generar el PDF')
|
|
||||||
}
|
|
||||||
|
|
||||||
// n8n devuelve el archivo → lo tratamos como blob
|
|
||||||
return await response.blob()
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { supabaseBrowser } from "../supabase/client";
|
|
||||||
import { invokeEdge } from "../supabase/invokeEdge";
|
|
||||||
import { throwIfError } from "./_helpers";
|
|
||||||
import type { AppFile } from "./openaiFiles.api";
|
|
||||||
|
|
||||||
const EDGE = {
|
|
||||||
signedUrl: "files_signed_url", // Edge: recibe archivoId o ruta_storage y devuelve URL
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export async function files_list(params?: {
|
|
||||||
temporal?: boolean;
|
|
||||||
search?: string;
|
|
||||||
limit?: number;
|
|
||||||
}): Promise<AppFile[]> {
|
|
||||||
const supabase = supabaseBrowser();
|
|
||||||
|
|
||||||
let q = supabase
|
|
||||||
.from("archivos")
|
|
||||||
.select("id,openai_file_id,nombre,mime_type,bytes,ruta_storage,temporal,notas,subido_en")
|
|
||||||
.order("subido_en", { ascending: false });
|
|
||||||
|
|
||||||
if (typeof params?.temporal === "boolean") q = q.eq("temporal", params.temporal);
|
|
||||||
if (params?.search?.trim()) q = q.ilike("nombre", `%${params.search.trim()}%`);
|
|
||||||
if (params?.limit) q = q.limit(params.limit);
|
|
||||||
|
|
||||||
const { data, error } = await q;
|
|
||||||
throwIfError(error);
|
|
||||||
return (data ?? []) as AppFile[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Para preview/descarga desde espejo — SIN tocar storage directo en el cliente */
|
|
||||||
export async function files_get_signed_url(payload: {
|
|
||||||
archivoId: string; // id interno (tabla archivos)
|
|
||||||
expiresIn?: number; // segundos
|
|
||||||
}): Promise<{ signedUrl: string }> {
|
|
||||||
return invokeEdge<{ signedUrl: string }>(EDGE.signedUrl, payload);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { supabaseBrowser } from "../supabase/client";
|
|
||||||
import { throwIfError } from "./_helpers";
|
|
||||||
import type { Carrera, EstadoPlan, EstructuraAsignatura, EstructuraPlan, Facultad } from "../types/domain";
|
|
||||||
|
|
||||||
export async function facultades_list(): Promise<Facultad[]> {
|
|
||||||
const supabase = supabaseBrowser();
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("facultades")
|
|
||||||
.select("id,nombre,nombre_corto,color,icono,creado_en,actualizado_en")
|
|
||||||
.order("nombre", { ascending: true });
|
|
||||||
|
|
||||||
throwIfError(error);
|
|
||||||
return data ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function carreras_list(params?: { facultadId?: string | null }): Promise<Carrera[]> {
|
|
||||||
const supabase = supabaseBrowser();
|
|
||||||
|
|
||||||
let q = supabase
|
|
||||||
.from("carreras")
|
|
||||||
.select(
|
|
||||||
"id,facultad_id,nombre,nombre_corto,clave_sep,activa,creado_en,actualizado_en, facultades(id,nombre,nombre_corto,color,icono)"
|
|
||||||
)
|
|
||||||
.order("nombre", { ascending: true });
|
|
||||||
|
|
||||||
if (params?.facultadId) q = q.eq("facultad_id", params.facultadId);
|
|
||||||
|
|
||||||
const { data, error } = await q;
|
|
||||||
throwIfError(error);
|
|
||||||
return data ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function estructuras_plan_list(params?: { nivel?: string | null }): Promise<EstructuraPlan[]> {
|
|
||||||
const supabase = supabaseBrowser();
|
|
||||||
|
|
||||||
// Nota: en tu DDL no hay "nivel" en estructuras_plan; si luego lo agregas, filtra aquí.
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("estructuras_plan")
|
|
||||||
.select("id,nombre,tipo,version,definicion")
|
|
||||||
.order("nombre", { ascending: true });
|
|
||||||
|
|
||||||
throwIfError(error);
|
|
||||||
return data ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function estructuras_asignatura_list(): Promise<EstructuraAsignatura[]> {
|
|
||||||
const supabase = supabaseBrowser();
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("estructuras_asignatura")
|
|
||||||
.select("id,nombre,version,definicion")
|
|
||||||
.order("nombre", { ascending: true });
|
|
||||||
|
|
||||||
throwIfError(error);
|
|
||||||
return data ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function estados_plan_list(): Promise<EstadoPlan[]> {
|
|
||||||
const supabase = supabaseBrowser();
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("estados_plan")
|
|
||||||
.select("id,clave,etiqueta,orden,es_final")
|
|
||||||
.order("orden", { ascending: true });
|
|
||||||
|
|
||||||
throwIfError(error);
|
|
||||||
return data ?? [];
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { supabaseBrowser } from "../supabase/client";
|
|
||||||
import { throwIfError, getUserIdOrThrow, requireData } from "./_helpers";
|
|
||||||
import type { Notificacion, UUID } from "../types/domain";
|
|
||||||
|
|
||||||
export async function notificaciones_mias_list(): Promise<Notificacion[]> {
|
|
||||||
const supabase = supabaseBrowser();
|
|
||||||
const userId = await getUserIdOrThrow(supabase);
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("notificaciones")
|
|
||||||
.select("id,usuario_id,tipo,payload,leida,creado_en,leida_en")
|
|
||||||
.eq("usuario_id", userId as UUID)
|
|
||||||
.order("creado_en", { ascending: false });
|
|
||||||
|
|
||||||
throwIfError(error);
|
|
||||||
return data ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function notificaciones_marcar_leida(notificacionId: UUID): Promise<Notificacion> {
|
|
||||||
const supabase = supabaseBrowser();
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("notificaciones")
|
|
||||||
.update({ leida: true, leida_en: new Date().toISOString() })
|
|
||||||
.eq("id", notificacionId)
|
|
||||||
.select("id,usuario_id,tipo,payload,leida,creado_en,leida_en")
|
|
||||||
.single();
|
|
||||||
|
|
||||||
throwIfError(error);
|
|
||||||
return requireData(data, "No se pudo marcar notificación.");
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { invokeEdge } from '../supabase/invokeEdge'
|
|
||||||
import type { UUID } from '../types/domain'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Metadata “canónica” para UI (archivo OpenAI + espejo en Supabase)
|
|
||||||
* Se apoya en tu tabla `archivos`.
|
|
||||||
*/
|
|
||||||
export type AppFile = {
|
|
||||||
id: UUID // id interno (tabla archivos)
|
|
||||||
openai_file_id: string // id OpenAI
|
|
||||||
nombre: string
|
|
||||||
mime_type: string | null
|
|
||||||
bytes: number | null
|
|
||||||
|
|
||||||
// espejo Supabase para preview/descarga
|
|
||||||
ruta_storage: string | null // "bucket/path"
|
|
||||||
signed_url?: string | null
|
|
||||||
|
|
||||||
// auditoría/evidencia
|
|
||||||
temporal: boolean
|
|
||||||
notas?: string | null
|
|
||||||
|
|
||||||
subido_en: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const EDGE = {
|
|
||||||
upload: 'openai_files_upload',
|
|
||||||
remove: 'openai_files_delete',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sube archivo a OpenAI y (opcional) crea espejo en Storage
|
|
||||||
* - El frontend NO toca Storage.
|
|
||||||
*/
|
|
||||||
export async function openai_files_upload(payload: {
|
|
||||||
/**
|
|
||||||
* Si tu Edge soporta multipart: manda File/Blob directo.
|
|
||||||
* Si no, manda base64/bytes (según tu implementación).
|
|
||||||
*/
|
|
||||||
file: File
|
|
||||||
|
|
||||||
/** “temporal” = evidencia usada para generar plan/asignatura */
|
|
||||||
temporal?: boolean
|
|
||||||
|
|
||||||
/** contexto para auditoría */
|
|
||||||
contexto?: {
|
|
||||||
planId?: UUID
|
|
||||||
asignaturaId?: UUID
|
|
||||||
motivo?: 'WIZARD_PLAN' | 'WIZARD_MATERIA' | 'ADHOC'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** si quieres forzar espejo para preview siempre */
|
|
||||||
mirrorToSupabase?: boolean
|
|
||||||
}): Promise<AppFile> {
|
|
||||||
return invokeEdge<AppFile>(EDGE.upload, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openai_files_delete(payload: {
|
|
||||||
openaiFileId: string
|
|
||||||
/** si quieres borrar también espejo y registro */
|
|
||||||
hardDelete?: boolean
|
|
||||||
}): Promise<{ ok: true }> {
|
|
||||||
return invokeEdge<{ ok: true }>(EDGE.remove, payload)
|
|
||||||
}
|
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
import { supabaseBrowser } from '../supabase/client'
|
|
||||||
import { invokeEdge } from '../supabase/invokeEdge'
|
|
||||||
|
|
||||||
import { buildRange, requireData, throwIfError } from './_helpers'
|
|
||||||
|
|
||||||
import type { Database } from '../../types/supabase'
|
|
||||||
import type {
|
|
||||||
Asignatura,
|
|
||||||
CambioPlan,
|
|
||||||
LineaPlan,
|
|
||||||
NivelPlanEstudio,
|
|
||||||
Paged,
|
|
||||||
PlanDatosSep,
|
|
||||||
PlanEstudio,
|
|
||||||
TipoCiclo,
|
|
||||||
UUID,
|
|
||||||
} from '../types/domain'
|
|
||||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
|
||||||
|
|
||||||
const EDGE = {
|
|
||||||
plans_create_manual: 'plans_create_manual',
|
|
||||||
ai_generate_plan: 'ai-generate-plan',
|
|
||||||
plans_persist_from_ai: 'plans_persist_from_ai',
|
|
||||||
plans_clone_from_existing: 'plans_clone_from_existing',
|
|
||||||
|
|
||||||
plans_import_from_files: 'plans_import_from_files',
|
|
||||||
|
|
||||||
// plans_update_fields: 'plans_update_fields',
|
|
||||||
plans_update_map: 'plans_update_map',
|
|
||||||
plans_transition_state: 'plans_transition_state',
|
|
||||||
|
|
||||||
plans_generate_document: 'plans_generate_document',
|
|
||||||
plans_get_document: 'plans_get_document',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type PlanListFilters = {
|
|
||||||
search?: string
|
|
||||||
carreraId?: UUID
|
|
||||||
facultadId?: UUID // filtra por carreras.facultad_id
|
|
||||||
estadoId?: UUID
|
|
||||||
activo?: boolean
|
|
||||||
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper para limpiar texto (lo movemos fuera para reutilizar o lo dejas en un utils)
|
|
||||||
const cleanText = (text: string) => {
|
|
||||||
return text
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
|
||||||
.toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plans_list(
|
|
||||||
filters: PlanListFilters = {},
|
|
||||||
): Promise<Paged<PlanEstudio>> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
// 1. Construimos la query base
|
|
||||||
// NOTA IMPORTANTE: Para filtrar planes basados en facultad (que está en carreras),
|
|
||||||
// necesitamos hacer un INNER JOIN. En Supabase se usa "!inner".
|
|
||||||
// Si filters.facultadId existe, forzamos el inner join, si no, lo dejamos normal.
|
|
||||||
|
|
||||||
const carreraModifier =
|
|
||||||
filters.facultadId && filters.facultadId !== 'todas' ? '!inner' : ''
|
|
||||||
|
|
||||||
let q = supabase
|
|
||||||
.from('planes_estudio')
|
|
||||||
.select(
|
|
||||||
`
|
|
||||||
*,
|
|
||||||
carreras${carreraModifier} (
|
|
||||||
*,
|
|
||||||
facultades (*)
|
|
||||||
),
|
|
||||||
estructuras_plan (*),
|
|
||||||
estados_plan (*)
|
|
||||||
`,
|
|
||||||
{ count: 'exact' },
|
|
||||||
)
|
|
||||||
.order('creado_en', { ascending: false })
|
|
||||||
|
|
||||||
// 2. Aplicamos filtros dinámicos
|
|
||||||
|
|
||||||
// SOLUCIÓN SEARCH: Limpiamos el input y buscamos en la columna generada
|
|
||||||
if (filters.search?.trim()) {
|
|
||||||
const cleanTerm = cleanText(filters.search.trim())
|
|
||||||
// Usamos la columna nueva creada en el Paso 1
|
|
||||||
q = q.ilike('nombre_search', `%${cleanTerm}%`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.carreraId && filters.carreraId !== 'todas') {
|
|
||||||
q = q.eq('carrera_id', filters.carreraId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.estadoId && filters.estadoId !== 'todos') {
|
|
||||||
q = q.eq('estado_actual_id', filters.estadoId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof filters.activo === 'boolean') {
|
|
||||||
q = q.eq('activo', filters.activo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtro por facultad (gracias al !inner arriba, esto filtrará los planes)
|
|
||||||
if (filters.facultadId && filters.facultadId !== 'todas') {
|
|
||||||
q = q.eq('carreras.facultad_id', filters.facultadId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Paginación
|
|
||||||
const { from, to } = buildRange(filters.limit, filters.offset)
|
|
||||||
if (from !== undefined && to !== undefined) q = q.range(from, to)
|
|
||||||
|
|
||||||
const { data, error, count } = await q
|
|
||||||
throwIfError(error)
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 1. Si data es null, usa [].
|
|
||||||
// 2. Luego dile a TS que el resultado es tu Array tipado.
|
|
||||||
data: (data ?? []) as unknown as Array<PlanEstudio>,
|
|
||||||
count: count ?? 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
|
||||||
console.log('plans_get')
|
|
||||||
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('planes_estudio')
|
|
||||||
.select(
|
|
||||||
`
|
|
||||||
*,
|
|
||||||
carreras (*, facultades(*)),
|
|
||||||
estructuras_plan (*),
|
|
||||||
estados_plan (*)
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.eq('id', planId)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
return requireData(data, 'Plan no encontrado.')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Variante de `plans_get` que NO lanza si no existe (devuelve null).
|
|
||||||
* Útil para flujos de polling donde el plan puede tardar en aparecer.
|
|
||||||
*/
|
|
||||||
export async function plans_get_maybe(
|
|
||||||
planId: UUID,
|
|
||||||
): Promise<PlanEstudio | null> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('planes_estudio')
|
|
||||||
.select(
|
|
||||||
`
|
|
||||||
*,
|
|
||||||
carreras (*, facultades(*)),
|
|
||||||
estructuras_plan (*),
|
|
||||||
estados_plan (*)
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.eq('id', planId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
return (data ?? null) as unknown as PlanEstudio | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plans_delete(planId: UUID): Promise<{ id: UUID }> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('planes_estudio')
|
|
||||||
.delete()
|
|
||||||
.eq('id', planId)
|
|
||||||
.select('id')
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
|
|
||||||
// Si por alguna razón no retorna fila (RLS / triggers), devolvemos el id solicitado.
|
|
||||||
return { id: ((data as any)?.id ?? planId) as UUID }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plan_lineas_list(
|
|
||||||
planId: UUID,
|
|
||||||
): Promise<Array<LineaPlan>> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('lineas_plan')
|
|
||||||
.select('id,plan_estudio_id,nombre,orden,area,creado_en,actualizado_en')
|
|
||||||
.eq('plan_estudio_id', planId)
|
|
||||||
.order('orden', { ascending: true })
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
return data ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plan_asignaturas_list(
|
|
||||||
planId: UUID,
|
|
||||||
): Promise<Array<Asignatura>> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('asignaturas')
|
|
||||||
.select(
|
|
||||||
'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en',
|
|
||||||
)
|
|
||||||
.eq('plan_estudio_id', planId)
|
|
||||||
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
|
||||||
.order('orden_celda', { ascending: true, nullsFirst: false })
|
|
||||||
.order('nombre', { ascending: true })
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
return data ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plans_history(
|
|
||||||
planId: UUID,
|
|
||||||
page: number = 0,
|
|
||||||
pageSize: number = 4,
|
|
||||||
): Promise<{ data: Array<CambioPlan>; count: number }> {
|
|
||||||
// Cambiamos el retorno
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const from = page * pageSize
|
|
||||||
const to = from + pageSize - 1
|
|
||||||
|
|
||||||
const { data, error, count } = await supabase
|
|
||||||
.from('cambios_plan')
|
|
||||||
.select(
|
|
||||||
'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,response_id',
|
|
||||||
{ count: 'exact' }, // <--- Pedimos el conteo exacto
|
|
||||||
)
|
|
||||||
.eq('plan_estudio_id', planId)
|
|
||||||
.order('cambiado_en', { ascending: false })
|
|
||||||
.range(from, to)
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
return {
|
|
||||||
data: data ?? [],
|
|
||||||
count: count ?? 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wizard: crear plan manual (Edge Function) */
|
|
||||||
export type PlansCreateManualInput = {
|
|
||||||
carreraId: UUID
|
|
||||||
estructuraId: UUID
|
|
||||||
nombre: string
|
|
||||||
nivel: NivelPlanEstudio
|
|
||||||
tipoCiclo: TipoCiclo
|
|
||||||
numCiclos: number
|
|
||||||
datos?: Partial<PlanDatosSep> & Record<string, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plans_create_manual(
|
|
||||||
input: PlansCreateManualInput,
|
|
||||||
): Promise<PlanEstudio> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
// 1. Obtener estado 'BORRADOR'
|
|
||||||
const { data: estado, error: estadoError } = await supabase
|
|
||||||
.from('estados_plan')
|
|
||||||
.select('id,clave,orden')
|
|
||||||
.ilike('clave', 'BORRADOR%')
|
|
||||||
.order('orden', { ascending: true })
|
|
||||||
.limit(1)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (estadoError) {
|
|
||||||
throw new Error(estadoError.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Preparar insert
|
|
||||||
const planInsert: Database['public']['Tables']['planes_estudio']['Insert'] = {
|
|
||||||
activo: true,
|
|
||||||
actualizado_en: new Date().toISOString(),
|
|
||||||
carrera_id: input.carreraId,
|
|
||||||
creado_en: new Date().toISOString(),
|
|
||||||
datos: input.datos || {},
|
|
||||||
estado_actual_id: estado?.id || null,
|
|
||||||
estructura_id: input.estructuraId,
|
|
||||||
nivel: input.nivel,
|
|
||||||
nombre: input.nombre,
|
|
||||||
numero_ciclos: input.numCiclos,
|
|
||||||
tipo_ciclo: input.tipoCiclo,
|
|
||||||
tipo_origen: 'MANUAL',
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Insertar
|
|
||||||
const { data: nuevoPlan, error: planError } = await supabase
|
|
||||||
.from('planes_estudio')
|
|
||||||
.insert([planInsert])
|
|
||||||
.select(
|
|
||||||
`
|
|
||||||
*,
|
|
||||||
carreras (*, facultades(*)),
|
|
||||||
estructuras_plan (*),
|
|
||||||
estados_plan (*)
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (planError) {
|
|
||||||
throw new Error(planError.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nuevoPlan as unknown as PlanEstudio
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wizard: IA genera preview JSON (Edge Function) */
|
|
||||||
export type AIGeneratePlanInput = {
|
|
||||||
datosBasicos: {
|
|
||||||
nombrePlan: string
|
|
||||||
carreraId: UUID
|
|
||||||
facultadId?: UUID
|
|
||||||
nivel: string
|
|
||||||
tipoCiclo: TipoCiclo
|
|
||||||
numCiclos: number
|
|
||||||
estructuraPlanId: UUID
|
|
||||||
}
|
|
||||||
iaConfig: {
|
|
||||||
descripcionEnfoqueAcademico: string
|
|
||||||
instruccionesAdicionalesIA?: string
|
|
||||||
archivosReferencia?: Array<UUID>
|
|
||||||
repositoriosIds?: Array<UUID>
|
|
||||||
archivosAdjuntos: Array<UploadedFile>
|
|
||||||
usarMCP?: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ai_generate_plan(
|
|
||||||
input: AIGeneratePlanInput,
|
|
||||||
): Promise<any> {
|
|
||||||
console.log('input ai generate', input)
|
|
||||||
|
|
||||||
const edgeFunctionBody = new FormData()
|
|
||||||
edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos))
|
|
||||||
edgeFunctionBody.append(
|
|
||||||
'iaConfig',
|
|
||||||
JSON.stringify({
|
|
||||||
...input.iaConfig,
|
|
||||||
archivosAdjuntos: undefined, // los manejamos aparte
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
input.iaConfig.archivosAdjuntos.forEach((file) => {
|
|
||||||
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
|
|
||||||
})
|
|
||||||
|
|
||||||
return invokeEdge<any>(
|
|
||||||
EDGE.ai_generate_plan,
|
|
||||||
edgeFunctionBody,
|
|
||||||
undefined,
|
|
||||||
supabaseBrowser(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plans_persist_from_ai(payload: {
|
|
||||||
jsonPlan: any
|
|
||||||
}): Promise<PlanEstudio> {
|
|
||||||
return invokeEdge<PlanEstudio>(EDGE.plans_persist_from_ai, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plans_clone_from_existing(payload: {
|
|
||||||
planOrigenId: UUID
|
|
||||||
overrides: Partial<
|
|
||||||
Pick<PlanEstudio, 'nombre' | 'nivel' | 'tipo_ciclo' | 'numero_ciclos'>
|
|
||||||
> & {
|
|
||||||
carrera_id?: UUID
|
|
||||||
estructura_id?: UUID
|
|
||||||
datos?: Partial<PlanDatosSep> & Record<string, any>
|
|
||||||
}
|
|
||||||
}): Promise<PlanEstudio> {
|
|
||||||
return invokeEdge<PlanEstudio>(EDGE.plans_clone_from_existing, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plans_import_from_files(payload: {
|
|
||||||
datosBasicos: {
|
|
||||||
nombrePlan: string
|
|
||||||
carreraId: UUID
|
|
||||||
estructuraId: UUID
|
|
||||||
nivel: string
|
|
||||||
tipoCiclo: TipoCiclo
|
|
||||||
numCiclos: number
|
|
||||||
}
|
|
||||||
archivoWordPlanId: UUID
|
|
||||||
archivoMapaExcelId?: UUID | null
|
|
||||||
archivoAsignaturasExcelId?: UUID | null
|
|
||||||
}): Promise<PlanEstudio> {
|
|
||||||
return invokeEdge<PlanEstudio>(EDGE.plans_import_from_files, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update de tarjetas/fields del plan (Edge Function: merge server-side) */
|
|
||||||
export type PlansUpdateFieldsPatch = {
|
|
||||||
nombre?: string
|
|
||||||
nivel?: NivelPlanEstudio
|
|
||||||
tipo_ciclo?: TipoCiclo
|
|
||||||
numero_ciclos?: number
|
|
||||||
datos?: Partial<PlanDatosSep> & Record<string, any>
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plans_update_fields(
|
|
||||||
planId: UUID,
|
|
||||||
patch: PlansUpdateFieldsPatch,
|
|
||||||
): Promise<PlanEstudio> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('planes_estudio')
|
|
||||||
.update(patch)
|
|
||||||
.eq('id', planId)
|
|
||||||
.select(
|
|
||||||
`
|
|
||||||
*,
|
|
||||||
carreras (*, facultades(*)),
|
|
||||||
estructuras_plan (*),
|
|
||||||
estados_plan (*)
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
return requireData(data, 'No se pudo actualizar el plan.')
|
|
||||||
// Alternativa Edge Function:
|
|
||||||
// return invokeEdge<PlanEstudio>(EDGE.plans_update_fields, { planId, patch })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Operaciones del mapa curricular (mover/reordenar) */
|
|
||||||
export type PlanMapOperation =
|
|
||||||
| {
|
|
||||||
op: 'MOVE_ASIGNATURA'
|
|
||||||
asignaturaId: UUID
|
|
||||||
numero_ciclo: number | null
|
|
||||||
linea_plan_id: UUID | null
|
|
||||||
orden_celda?: number | null
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
op: 'REORDER_CELDA'
|
|
||||||
linea_plan_id: UUID
|
|
||||||
numero_ciclo: number
|
|
||||||
asignaturaIdsOrdenados: Array<UUID>
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plans_update_map(
|
|
||||||
planId: UUID,
|
|
||||||
ops: Array<PlanMapOperation>,
|
|
||||||
): Promise<{ ok: true }> {
|
|
||||||
return invokeEdge<{ ok: true }>(EDGE.plans_update_map, { planId, ops })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plans_transition_state(payload: {
|
|
||||||
planId: UUID
|
|
||||||
haciaEstadoId: UUID
|
|
||||||
comentario?: string
|
|
||||||
}): Promise<{ ok: true }> {
|
|
||||||
return invokeEdge<{ ok: true }>(EDGE.plans_transition_state, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Documento (Edge Function: genera y devuelve URL firmada o metadata) */
|
|
||||||
export type DocumentoResult = {
|
|
||||||
archivoId: UUID
|
|
||||||
signedUrl: string
|
|
||||||
mimeType?: string
|
|
||||||
nombre?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plans_generate_document(
|
|
||||||
planId: UUID,
|
|
||||||
): Promise<DocumentoResult> {
|
|
||||||
return invokeEdge<DocumentoResult>(EDGE.plans_generate_document, { planId })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function plans_get_document(
|
|
||||||
planId: UUID,
|
|
||||||
): Promise<DocumentoResult | null> {
|
|
||||||
return invokeEdge<DocumentoResult | null>(EDGE.plans_get_document, {
|
|
||||||
planId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCatalogos() {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
const [facultadesRes, carrerasRes, estadosRes, estructurasPlanRes] =
|
|
||||||
await Promise.all([
|
|
||||||
supabase.from('facultades').select('*').order('nombre'),
|
|
||||||
supabase.from('carreras').select('*').order('nombre'),
|
|
||||||
supabase.from('estados_plan').select('*').order('orden'),
|
|
||||||
supabase.from('estructuras_plan').select('*').order('creado_en', {
|
|
||||||
ascending: true,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
return {
|
|
||||||
facultades: facultadesRes.data ?? [],
|
|
||||||
carreras: carrerasRes.data ?? [],
|
|
||||||
estados: estadosRes.data ?? [],
|
|
||||||
estructurasPlan: estructurasPlanRes.data ?? [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { invokeEdge } from "../supabase/invokeEdge";
|
|
||||||
|
|
||||||
export type Repository = {
|
|
||||||
id: string; // id del vector store (OpenAI) o tu id interno
|
|
||||||
nombre: string;
|
|
||||||
creado_en?: string;
|
|
||||||
meta?: Record<string, any>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const EDGE = {
|
|
||||||
create: "repos_create",
|
|
||||||
remove: "repos_delete",
|
|
||||||
add: "repos_add_files",
|
|
||||||
detach: "repos_remove_files",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export async function repos_create(payload: {
|
|
||||||
nombre: string;
|
|
||||||
descripcion?: string;
|
|
||||||
/** si tu implementación crea también registro DB */
|
|
||||||
persist?: boolean;
|
|
||||||
}): Promise<Repository> {
|
|
||||||
return invokeEdge<Repository>(EDGE.create, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function repos_delete(payload: { repoId: string }): Promise<{ ok: true }> {
|
|
||||||
return invokeEdge<{ ok: true }>(EDGE.remove, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Agrega archivos (OpenAI file ids) a un repositorio */
|
|
||||||
export async function repos_add_files(payload: {
|
|
||||||
repoId: string;
|
|
||||||
openaiFileIds: string[];
|
|
||||||
}): Promise<{ ok: true }> {
|
|
||||||
return invokeEdge<{ ok: true }>(EDGE.add, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Quita archivos (OpenAI file ids) del repositorio */
|
|
||||||
export async function repos_remove_files(payload: {
|
|
||||||
repoId: string;
|
|
||||||
openaiFileIds: string[];
|
|
||||||
}): Promise<{ ok: true }> {
|
|
||||||
return invokeEdge<{ ok: true }>(EDGE.detach, payload);
|
|
||||||
}
|
|
||||||
@@ -1,512 +0,0 @@
|
|||||||
import { supabaseBrowser } from '../supabase/client'
|
|
||||||
import { invokeEdge } from '../supabase/invokeEdge'
|
|
||||||
|
|
||||||
import { throwIfError, requireData } from './_helpers'
|
|
||||||
|
|
||||||
import type { DocumentoResult } from './plans.api'
|
|
||||||
import type {
|
|
||||||
Asignatura,
|
|
||||||
BibliografiaAsignatura,
|
|
||||||
CarreraRow,
|
|
||||||
CambioAsignatura,
|
|
||||||
EstructuraAsignatura,
|
|
||||||
FacultadRow,
|
|
||||||
PlanEstudioRow,
|
|
||||||
TipoAsignatura,
|
|
||||||
UUID,
|
|
||||||
} from '../types/domain'
|
|
||||||
import type {
|
|
||||||
AsignaturaSugerida,
|
|
||||||
DataAsignaturaSugerida,
|
|
||||||
} from '@/features/asignaturas/nueva/types'
|
|
||||||
import type { Database, TablesInsert } from '@/types/supabase'
|
|
||||||
|
|
||||||
const EDGE = {
|
|
||||||
generate_subject_suggestions: 'generate-subject-suggestions',
|
|
||||||
subjects_create_manual: 'subjects_create_manual',
|
|
||||||
ai_generate_subject: 'ai-generate-subject',
|
|
||||||
subjects_persist_from_ai: 'subjects_persist_from_ai',
|
|
||||||
subjects_clone_from_existing: 'subjects_clone_from_existing',
|
|
||||||
subjects_import_from_file: 'subjects_import_from_file',
|
|
||||||
|
|
||||||
subjects_update_fields: 'subjects_update_fields',
|
|
||||||
subjects_update_bibliografia: 'subjects_update_bibliografia',
|
|
||||||
|
|
||||||
subjects_generate_document: 'subjects_generate_document',
|
|
||||||
subjects_get_document: 'subjects_get_document',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type ContenidoTemaApi =
|
|
||||||
| string
|
|
||||||
| {
|
|
||||||
nombre: string
|
|
||||||
horasEstimadas?: number
|
|
||||||
descripcion?: string
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estructura persistida en `asignaturas.contenido_tematico`.
|
|
||||||
* La BDD guarda un arreglo de unidades, cada una con temas (strings u objetos).
|
|
||||||
*/
|
|
||||||
export type ContenidoApi = {
|
|
||||||
unidad: number
|
|
||||||
titulo: string
|
|
||||||
temas: Array<ContenidoTemaApi>
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FacultadInSubject = Pick<
|
|
||||||
FacultadRow,
|
|
||||||
'id' | 'nombre' | 'nombre_corto' | 'color' | 'icono'
|
|
||||||
>
|
|
||||||
|
|
||||||
export type CarreraInSubject = Pick<
|
|
||||||
CarreraRow,
|
|
||||||
'id' | 'facultad_id' | 'nombre' | 'nombre_corto' | 'clave_sep' | 'activa'
|
|
||||||
> & {
|
|
||||||
facultades: FacultadInSubject | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PlanEstudioInSubject = Pick<
|
|
||||||
PlanEstudioRow,
|
|
||||||
| 'id'
|
|
||||||
| 'carrera_id'
|
|
||||||
| 'estructura_id'
|
|
||||||
| 'nombre'
|
|
||||||
| 'nivel'
|
|
||||||
| 'tipo_ciclo'
|
|
||||||
| 'numero_ciclos'
|
|
||||||
| 'datos'
|
|
||||||
| 'estado_actual_id'
|
|
||||||
| 'activo'
|
|
||||||
| 'tipo_origen'
|
|
||||||
| 'meta_origen'
|
|
||||||
| 'creado_por'
|
|
||||||
| 'actualizado_por'
|
|
||||||
| 'creado_en'
|
|
||||||
| 'actualizado_en'
|
|
||||||
> & {
|
|
||||||
carreras: CarreraInSubject | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EstructuraAsignaturaInSubject = Pick<
|
|
||||||
EstructuraAsignatura,
|
|
||||||
'id' | 'nombre' | 'version' | 'definicion'
|
|
||||||
>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tipo real que devuelve `subjects_get` (asignatura + relaciones seleccionadas).
|
|
||||||
* Nota: `asignaturas_update` (update directo) NO devuelve estas relaciones.
|
|
||||||
*/
|
|
||||||
export type AsignaturaDetail = Omit<Asignatura, 'contenido_tematico'> & {
|
|
||||||
contenido_tematico: Array<ContenidoApi> | null
|
|
||||||
planes_estudio: PlanEstudioInSubject | null
|
|
||||||
estructuras_asignatura: EstructuraAsignaturaInSubject | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subjects_get(subjectId: UUID): Promise<AsignaturaDetail> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('asignaturas')
|
|
||||||
.select(
|
|
||||||
`
|
|
||||||
id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
|
||||||
planes_estudio(
|
|
||||||
id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,
|
|
||||||
carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono))
|
|
||||||
),
|
|
||||||
estructuras_asignatura(id,nombre,version,definicion)
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.eq('id', subjectId)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
return requireData(
|
|
||||||
data,
|
|
||||||
'Asignatura no encontrada.',
|
|
||||||
) as unknown as AsignaturaDetail
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subjects_history(
|
|
||||||
subjectId: UUID,
|
|
||||||
): Promise<Array<CambioAsignatura>> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('cambios_asignatura')
|
|
||||||
.select(
|
|
||||||
'id,asignatura_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,fuente,interaccion_ia_id',
|
|
||||||
)
|
|
||||||
.eq('asignatura_id', subjectId)
|
|
||||||
.order('cambiado_en', { ascending: false })
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
return data ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subjects_bibliografia_list(
|
|
||||||
subjectId: UUID,
|
|
||||||
): Promise<Array<BibliografiaAsignatura>> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('bibliografia_asignatura')
|
|
||||||
.select(
|
|
||||||
'id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en',
|
|
||||||
)
|
|
||||||
.eq('asignatura_id', subjectId)
|
|
||||||
.order('tipo', { ascending: true })
|
|
||||||
.order('creado_en', { ascending: true })
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
return data ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subjects_create_manual(
|
|
||||||
payload: TablesInsert<'asignaturas'>,
|
|
||||||
): Promise<Asignatura> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('asignaturas')
|
|
||||||
.insert(payload)
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
return requireData(data, 'No se pudo crear la asignatura.')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nuevo payload unificado (JSON) para la Edge `ai_generate_subject`.
|
|
||||||
* - Siempre incluye `datosUpdate.plan_estudio_id`.
|
|
||||||
* - `datosUpdate.id` es opcional (si no existe, la Edge puede crear).
|
|
||||||
* En el frontend, insertamos primero y usamos `id` para actualizar.
|
|
||||||
*/
|
|
||||||
export type AISubjectUnifiedInput = {
|
|
||||||
datosUpdate: Partial<{
|
|
||||||
id: string
|
|
||||||
plan_estudio_id: string
|
|
||||||
estructura_id: string
|
|
||||||
nombre: string
|
|
||||||
codigo: string | null
|
|
||||||
tipo: string | null
|
|
||||||
creditos: number
|
|
||||||
horas_academicas: number | null
|
|
||||||
horas_independientes: number | null
|
|
||||||
numero_ciclo: number | null
|
|
||||||
linea_plan_id: string | null
|
|
||||||
orden_celda: number | null
|
|
||||||
}> & {
|
|
||||||
plan_estudio_id: string
|
|
||||||
}
|
|
||||||
iaConfig?: {
|
|
||||||
descripcionEnfoqueAcademico?: string
|
|
||||||
instruccionesAdicionalesIA?: string
|
|
||||||
archivosAdjuntos?: Array<string>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subjects_get_maybe(
|
|
||||||
subjectId: UUID,
|
|
||||||
): Promise<Asignatura | null> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('asignaturas')
|
|
||||||
.select('id,plan_estudio_id,estado')
|
|
||||||
.eq('id', subjectId)
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
return (data ?? null) as unknown as Asignatura | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GenerateSubjectSuggestionsInput = {
|
|
||||||
plan_estudio_id: UUID
|
|
||||||
enfoque?: string
|
|
||||||
cantidad_de_sugerencias: number
|
|
||||||
sugerencias_conservadas: Array<{ nombre: string; descripcion: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generate_subject_suggestions(
|
|
||||||
input: GenerateSubjectSuggestionsInput,
|
|
||||||
): Promise<Array<AsignaturaSugerida>> {
|
|
||||||
const raw = await invokeEdge<Array<DataAsignaturaSugerida>>(
|
|
||||||
EDGE.generate_subject_suggestions,
|
|
||||||
input,
|
|
||||||
{ headers: { 'Content-Type': 'application/json' } },
|
|
||||||
)
|
|
||||||
|
|
||||||
return raw.map(
|
|
||||||
(s): AsignaturaSugerida => ({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
selected: false,
|
|
||||||
source: 'IA',
|
|
||||||
nombre: s.nombre,
|
|
||||||
codigo: s.codigo,
|
|
||||||
tipo: s.tipo ?? null,
|
|
||||||
creditos: s.creditos ?? null,
|
|
||||||
horasAcademicas: s.horasAcademicas ?? null,
|
|
||||||
horasIndependientes: s.horasIndependientes ?? null,
|
|
||||||
descripcion: s.descripcion,
|
|
||||||
linea_plan_id: null,
|
|
||||||
numero_ciclo: null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ai_generate_subject(
|
|
||||||
input: AISubjectUnifiedInput,
|
|
||||||
): Promise<any> {
|
|
||||||
return invokeEdge<any>(EDGE.ai_generate_subject, input, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subjects_persist_from_ai(payload: {
|
|
||||||
planId: UUID
|
|
||||||
jsonAsignatura: any
|
|
||||||
}): Promise<Asignatura> {
|
|
||||||
return invokeEdge<Asignatura>(EDGE.subjects_persist_from_ai, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subjects_clone_from_existing(payload: {
|
|
||||||
asignaturaOrigenId: UUID
|
|
||||||
planDestinoId: UUID
|
|
||||||
overrides?: Partial<{
|
|
||||||
nombre: string
|
|
||||||
codigo: string
|
|
||||||
tipo: TipoAsignatura
|
|
||||||
creditos: number
|
|
||||||
horas_semana: number
|
|
||||||
}>
|
|
||||||
}): Promise<Asignatura> {
|
|
||||||
return invokeEdge<Asignatura>(EDGE.subjects_clone_from_existing, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subjects_import_from_file(payload: {
|
|
||||||
planId: UUID
|
|
||||||
archivoWordAsignaturaId: UUID
|
|
||||||
archivosAdicionalesIds?: Array<UUID>
|
|
||||||
}): Promise<Asignatura> {
|
|
||||||
return invokeEdge<Asignatura>(EDGE.subjects_import_from_file, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Guardado de tarjetas/fields (Edge: merge server-side en asignaturas.datos y columnas) */
|
|
||||||
export type SubjectsUpdateFieldsPatch = Partial<{
|
|
||||||
codigo: string | null
|
|
||||||
nombre: string
|
|
||||||
tipo: TipoAsignatura
|
|
||||||
creditos: number
|
|
||||||
horas_semana: number | null
|
|
||||||
numero_ciclo: number | null
|
|
||||||
linea_plan_id: UUID | null
|
|
||||||
|
|
||||||
datos: Record<string, any>
|
|
||||||
}>
|
|
||||||
|
|
||||||
export async function subjects_update_fields(
|
|
||||||
subjectId: UUID,
|
|
||||||
patch: SubjectsUpdateFieldsPatch,
|
|
||||||
): Promise<Asignatura> {
|
|
||||||
return invokeEdge<Asignatura>(EDGE.subjects_update_fields, {
|
|
||||||
subjectId,
|
|
||||||
patch,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subjects_update_contenido(
|
|
||||||
subjectId: UUID,
|
|
||||||
unidades: Array<ContenidoApi>,
|
|
||||||
): Promise<Asignatura> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
type AsignaturaUpdate = Database['public']['Tables']['asignaturas']['Update']
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('asignaturas')
|
|
||||||
.update({
|
|
||||||
contenido_tematico:
|
|
||||||
unidades as unknown as AsignaturaUpdate['contenido_tematico'],
|
|
||||||
})
|
|
||||||
.eq('id', subjectId)
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
return requireData(data, 'No se pudo actualizar la asignatura.')
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BibliografiaUpsertInput = Array<{
|
|
||||||
id?: UUID
|
|
||||||
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
|
||||||
cita: string
|
|
||||||
tipo_fuente?: 'MANUAL' | 'BIBLIOTECA'
|
|
||||||
biblioteca_item_id?: string | null
|
|
||||||
}>
|
|
||||||
|
|
||||||
export async function subjects_update_bibliografia(
|
|
||||||
subjectId: UUID,
|
|
||||||
entries: BibliografiaUpsertInput,
|
|
||||||
): Promise<{ ok: true }> {
|
|
||||||
return invokeEdge<{ ok: true }>(EDGE.subjects_update_bibliografia, {
|
|
||||||
subjectId,
|
|
||||||
entries,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Documento SEP asignatura */
|
|
||||||
/* export type DocumentoResult = {
|
|
||||||
archivoId: UUID;
|
|
||||||
signedUrl: string;
|
|
||||||
mimeType?: string;
|
|
||||||
nombre?: string;
|
|
||||||
}; */
|
|
||||||
|
|
||||||
export async function subjects_generate_document(
|
|
||||||
subjectId: UUID,
|
|
||||||
): Promise<DocumentoResult> {
|
|
||||||
return invokeEdge<DocumentoResult>(EDGE.subjects_generate_document, {
|
|
||||||
subjectId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subjects_get_document(
|
|
||||||
subjectId: UUID,
|
|
||||||
): Promise<DocumentoResult | null> {
|
|
||||||
return invokeEdge<DocumentoResult | null>(EDGE.subjects_get_document, {
|
|
||||||
subjectId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function subjects_get_structure_catalog(): Promise<
|
|
||||||
Array<Database['public']['Tables']['estructuras_asignatura']['Row']>
|
|
||||||
> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('estructuras_asignatura')
|
|
||||||
.select('*')
|
|
||||||
.order('nombre', { ascending: true })
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function asignaturas_update(
|
|
||||||
asignaturaId: UUID,
|
|
||||||
patch: Partial<Asignatura>, // O tu tipo específico para el Patch de materias
|
|
||||||
): Promise<Asignatura> {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('asignaturas')
|
|
||||||
.update(patch)
|
|
||||||
.eq('id', asignaturaId)
|
|
||||||
.select() // Trae la materia actualizada
|
|
||||||
.single()
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
return requireData(data, 'No se pudo actualizar la asignatura.')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insertar una nueva línea
|
|
||||||
export async function lineas_insert(linea: {
|
|
||||||
nombre: string
|
|
||||||
plan_estudio_id: string
|
|
||||||
orden: number
|
|
||||||
area?: string
|
|
||||||
}) {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('lineas_plan') // Asegúrate que el nombre de la tabla sea correcto
|
|
||||||
.insert([linea])
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actualizar una línea existente
|
|
||||||
export async function lineas_update(
|
|
||||||
lineaId: string,
|
|
||||||
patch: { nombre?: string; orden?: number; area?: string },
|
|
||||||
) {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('lineas_plan')
|
|
||||||
.update(patch)
|
|
||||||
.eq('id', lineaId)
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function lineas_delete(lineaId: string) {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
// Nota: Si configuraste "ON DELETE SET NULL" en tu base de datos,
|
|
||||||
// las asignaturas se desvincularán solas. Si no, Supabase podría dar error.
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('lineas_plan')
|
|
||||||
.delete()
|
|
||||||
.eq('id', lineaId)
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
return lineaId
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function bibliografia_insert(entry: {
|
|
||||||
asignatura_id: string
|
|
||||||
tipo: 'BASICA' | 'COMPLEMENTARIA'
|
|
||||||
cita: string
|
|
||||||
tipo_fuente: 'MANUAL' | 'BIBLIOTECA'
|
|
||||||
biblioteca_item_id?: string | null
|
|
||||||
}) {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('bibliografia_asignatura')
|
|
||||||
.insert([entry])
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function bibliografia_update(
|
|
||||||
id: string,
|
|
||||||
updates: {
|
|
||||||
cita?: string
|
|
||||||
tipo?: 'BASICA' | 'COMPLEMENTARIA'
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('bibliografia_asignatura')
|
|
||||||
.update(updates) // Ahora 'updates' es compatible con lo que espera Supabase
|
|
||||||
.eq('id', id)
|
|
||||||
.select()
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function bibliografia_delete(id: string) {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('bibliografia_asignatura')
|
|
||||||
.delete()
|
|
||||||
.eq('id', id)
|
|
||||||
|
|
||||||
if (error) throw error
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { supabaseBrowser } from "../supabase/client";
|
|
||||||
import { throwIfError, getUserIdOrThrow, requireData } from "./_helpers";
|
|
||||||
import type { TareaRevision, UUID } from "../types/domain";
|
|
||||||
|
|
||||||
export async function tareas_mias_list(): Promise<TareaRevision[]> {
|
|
||||||
const supabase = supabaseBrowser();
|
|
||||||
const userId = await getUserIdOrThrow(supabase);
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("tareas_revision")
|
|
||||||
.select("id,plan_estudio_id,asignado_a,rol_id,estado_id,estatus,fecha_limite,creado_en,completado_en")
|
|
||||||
.eq("asignado_a", userId as UUID)
|
|
||||||
.order("creado_en", { ascending: false });
|
|
||||||
|
|
||||||
throwIfError(error);
|
|
||||||
return data ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function tareas_marcar_completada(tareaId: UUID): Promise<TareaRevision> {
|
|
||||||
const supabase = supabaseBrowser();
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("tareas_revision")
|
|
||||||
.update({ estatus: "COMPLETADA", completado_en: new Date().toISOString() })
|
|
||||||
.eq("id", tareaId)
|
|
||||||
.select("id,plan_estudio_id,asignado_a,rol_id,estado_id,estatus,fecha_limite,creado_en,completado_en")
|
|
||||||
.single();
|
|
||||||
|
|
||||||
throwIfError(error);
|
|
||||||
return requireData(data, "No se pudo marcar tarea.");
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import {
|
|
||||||
ai_plan_chat_v2,
|
|
||||||
ai_plan_improve,
|
|
||||||
ai_subject_chat,
|
|
||||||
ai_subject_improve,
|
|
||||||
create_conversation,
|
|
||||||
get_chat_history,
|
|
||||||
getConversationByPlan,
|
|
||||||
library_search,
|
|
||||||
update_conversation_status,
|
|
||||||
update_recommendation_applied_status,
|
|
||||||
update_conversation_title,
|
|
||||||
} from '../api/ai.api'
|
|
||||||
|
|
||||||
// eslint-disable-next-line node/prefer-node-protocol
|
|
||||||
import type { UUID } from 'crypto'
|
|
||||||
|
|
||||||
export function useAIPlanImprove() {
|
|
||||||
return useMutation({ mutationFn: ai_plan_improve })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAIPlanChat() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (payload: {
|
|
||||||
planId: UUID
|
|
||||||
content: string
|
|
||||||
campos?: Array<string>
|
|
||||||
conversacionId?: string
|
|
||||||
}) => {
|
|
||||||
let currentId = payload.conversacionId
|
|
||||||
|
|
||||||
// 1. Si no hay ID, creamos la conversación
|
|
||||||
if (!currentId) {
|
|
||||||
const response = await create_conversation(payload.planId)
|
|
||||||
|
|
||||||
// CAMBIO AQUÍ: Accedemos a la estructura correcta según tu consola
|
|
||||||
currentId = response.conversation_plan.id
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Ahora enviamos el mensaje con el ID garantizado
|
|
||||||
const result = await ai_plan_chat_v2({
|
|
||||||
conversacionId: currentId!,
|
|
||||||
content: payload.content,
|
|
||||||
campos: payload.campos,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Retornamos el resultado del chat y el ID para el estado del componente
|
|
||||||
return { ...result, conversacionId: currentId }
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChatHistory(conversacionId?: string) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['chat-history', conversacionId],
|
|
||||||
queryFn: async () => {
|
|
||||||
return get_chat_history(conversacionId!)
|
|
||||||
},
|
|
||||||
enabled: Boolean(conversacionId),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateConversationStatus() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
id,
|
|
||||||
estado,
|
|
||||||
}: {
|
|
||||||
id: string
|
|
||||||
estado: 'ARCHIVADA' | 'ACTIVA'
|
|
||||||
}) => update_conversation_status(id, estado),
|
|
||||||
onSuccess: () => {
|
|
||||||
// Esto refresca las listas automáticamente
|
|
||||||
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useConversationByPlan(planId: string | null) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['conversation-by-plan', planId],
|
|
||||||
queryFn: () => getConversationByPlan(planId!),
|
|
||||||
enabled: !!planId, // solo ejecuta si existe planId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateRecommendationApplied() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
conversacionId,
|
|
||||||
campoAfectado,
|
|
||||||
}: {
|
|
||||||
conversacionId: string
|
|
||||||
campoAfectado: string
|
|
||||||
}) => update_recommendation_applied_status(conversacionId, campoAfectado),
|
|
||||||
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
// Invalidamos la query para que useConversationByPlan refresque el JSON
|
|
||||||
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
|
|
||||||
console.log(
|
|
||||||
`Recomendación ${variables.campoAfectado} marcada como aplicada.`,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error('Error al actualizar el estado de la recomendación:', error)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAISubjectImprove() {
|
|
||||||
return useMutation({ mutationFn: ai_subject_improve })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAISubjectChat() {
|
|
||||||
return useMutation({ mutationFn: ai_subject_chat })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLibrarySearch() {
|
|
||||||
return useMutation({ mutationFn: library_search })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateConversationTitle() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ id, nombre }: { id: string; nombre: string }) =>
|
|
||||||
update_conversation_title(id, nombre),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
// Invalidamos para que la lista de chats se refresque
|
|
||||||
qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
|
|
||||||
import { throwIfError } from '../api/_helpers'
|
|
||||||
import { qk } from '../query/keys'
|
|
||||||
import { supabaseBrowser } from '../supabase/client'
|
|
||||||
|
|
||||||
export function useSession() {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: qk.session(),
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data, error } = await supabase.auth.getSession()
|
|
||||||
throwIfError(error)
|
|
||||||
return data.session ?? null
|
|
||||||
},
|
|
||||||
staleTime: Infinity,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const { data } = supabase.auth.onAuthStateChange(() => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.session() })
|
|
||||||
qc.invalidateQueries({ queryKey: qk.meProfile() })
|
|
||||||
qc.invalidateQueries({ queryKey: qk.meAccess() })
|
|
||||||
qc.invalidateQueries({ queryKey: qk.auth })
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => data.subscription.unsubscribe()
|
|
||||||
}, [supabase, qc])
|
|
||||||
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMeProfile() {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: qk.meProfile(),
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data: u, error: uErr } = await supabase.auth.getUser()
|
|
||||||
throwIfError(uErr)
|
|
||||||
const userId = u.user?.id
|
|
||||||
if (!userId) return null
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('usuarios_app')
|
|
||||||
.select('id,nombre_completo,email,externo,creado_en,actualizado_en')
|
|
||||||
.eq('id', userId)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
// si aún no existe perfil en usuarios_app, permite null (tu seed/trigger puede crearlo)
|
|
||||||
if (error && (error as any).code === 'PGRST116') return null
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
return data ?? null
|
|
||||||
},
|
|
||||||
staleTime: 60_000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MeAccessRole = {
|
|
||||||
assignmentId: string
|
|
||||||
rolId: string
|
|
||||||
clave: string
|
|
||||||
nombre: string
|
|
||||||
descripcion: string | null
|
|
||||||
facultadId: string | null
|
|
||||||
carreraId: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MeAccess = {
|
|
||||||
userId: string
|
|
||||||
roles: Array<MeAccessRole>
|
|
||||||
permissions: Array<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Database-first RBAC: obtiene roles del usuario desde tablas app (NO desde JWT).
|
|
||||||
*
|
|
||||||
* Nota: el esquema actual modela roles con `usuarios_roles` -> `roles`.
|
|
||||||
*/
|
|
||||||
export function useMeAccess() {
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: qk.meAccess(),
|
|
||||||
queryFn: async (): Promise<MeAccess | null> => {
|
|
||||||
const { data: u, error: uErr } = await supabase.auth.getUser()
|
|
||||||
throwIfError(uErr)
|
|
||||||
const userId = u.user?.id
|
|
||||||
if (!userId) return null
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('usuarios_roles')
|
|
||||||
.select(
|
|
||||||
'id,rol_id,facultad_id,carrera_id,roles(id,clave,nombre,descripcion)',
|
|
||||||
)
|
|
||||||
.eq('usuario_id', userId)
|
|
||||||
|
|
||||||
throwIfError(error)
|
|
||||||
|
|
||||||
const roles: Array<MeAccessRole> = (data ?? [])
|
|
||||||
.map((row: any) => {
|
|
||||||
const rol = row.roles
|
|
||||||
if (!rol) return null
|
|
||||||
return {
|
|
||||||
assignmentId: row.id,
|
|
||||||
rolId: rol.id,
|
|
||||||
clave: rol.clave,
|
|
||||||
nombre: rol.nombre,
|
|
||||||
descripcion: rol.descripcion ?? null,
|
|
||||||
facultadId: row.facultad_id ?? null,
|
|
||||||
carreraId: row.carrera_id ?? null,
|
|
||||||
} satisfies MeAccessRole
|
|
||||||
})
|
|
||||||
.filter(Boolean) as Array<MeAccessRole>
|
|
||||||
|
|
||||||
// Por ahora, los permisos granulares se derivan de claves de rol.
|
|
||||||
// Si luego existe una tabla `roles_permisos`, aquí se expande a permisos reales.
|
|
||||||
const permissions = Array.from(new Set(roles.map((r) => r.clave)))
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId,
|
|
||||||
roles,
|
|
||||||
permissions,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
staleTime: 30_000,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
const session = useSession()
|
|
||||||
const meProfile = useMeProfile()
|
|
||||||
const meAccess = useMeAccess()
|
|
||||||
|
|
||||||
return {
|
|
||||||
session,
|
|
||||||
meProfile,
|
|
||||||
meAccess,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { files_get_signed_url, files_list } from "../api/files.api";
|
|
||||||
import { openai_files_delete, openai_files_upload } from "../api/openaiFiles.api";
|
|
||||||
|
|
||||||
const qkFiles = {
|
|
||||||
list: (filters: any) => ["files", "list", filters] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useFilesList(filters?: { temporal?: boolean; search?: string; limit?: number }) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: qkFiles.list(filters ?? {}),
|
|
||||||
queryFn: () => files_list(filters),
|
|
||||||
staleTime: 15_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUploadOpenAIFile() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: openai_files_upload,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["files"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteOpenAIFile() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: openai_files_delete,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["files"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useFileSignedUrl() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: files_get_signed_url,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { qk } from "../query/keys";
|
|
||||||
import {
|
|
||||||
carreras_list,
|
|
||||||
estados_plan_list,
|
|
||||||
estructuras_asignatura_list,
|
|
||||||
estructuras_plan_list,
|
|
||||||
facultades_list,
|
|
||||||
} from "../api/meta.api";
|
|
||||||
|
|
||||||
export function useFacultades() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: qk.facultades(),
|
|
||||||
queryFn: facultades_list,
|
|
||||||
staleTime: 5 * 60_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCarreras(params?: { facultadId?: string | null }) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: qk.carreras(params?.facultadId ?? null),
|
|
||||||
queryFn: () => carreras_list(params),
|
|
||||||
staleTime: 5 * 60_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEstructurasPlan(params?: { nivel?: string | null }) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: qk.estructurasPlan(params?.nivel ?? null),
|
|
||||||
queryFn: () => estructuras_plan_list(params),
|
|
||||||
staleTime: 10 * 60_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEstructurasAsignatura() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: qk.estructurasAsignatura(),
|
|
||||||
queryFn: estructuras_asignatura_list,
|
|
||||||
staleTime: 10 * 60_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEstadosPlan() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: qk.estadosPlan(),
|
|
||||||
queryFn: estados_plan_list,
|
|
||||||
staleTime: 10 * 60_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { qk } from "../query/keys";
|
|
||||||
import { notificaciones_marcar_leida, notificaciones_mias_list } from "../api/notifications.api";
|
|
||||||
import { supabaseBrowser } from "../supabase/client";
|
|
||||||
|
|
||||||
export function useMisNotificaciones() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: qk.notificaciones(),
|
|
||||||
queryFn: notificaciones_mias_list,
|
|
||||||
staleTime: 10_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 🔥 Opcional: realtime (si tienes Realtime habilitado) */
|
|
||||||
export function useRealtimeNotificaciones(enable = true) {
|
|
||||||
const supabase = supabaseBrowser();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enable) return;
|
|
||||||
|
|
||||||
const channel = supabase
|
|
||||||
.channel("rt-notificaciones")
|
|
||||||
.on(
|
|
||||||
"postgres_changes",
|
|
||||||
{ event: "*", schema: "public", table: "notificaciones" },
|
|
||||||
() => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.notificaciones() });
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.subscribe();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
supabase.removeChannel(channel);
|
|
||||||
};
|
|
||||||
}, [enable, supabase, qc]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMarcarNotificacionLeida() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: notificaciones_marcar_leida,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.notificaciones() });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
import {
|
|
||||||
keepPreviousData,
|
|
||||||
useMutation,
|
|
||||||
useQuery,
|
|
||||||
useQueryClient,
|
|
||||||
} from '@tanstack/react-query'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
|
|
||||||
import {
|
|
||||||
ai_generate_plan,
|
|
||||||
getCatalogos,
|
|
||||||
plan_asignaturas_list,
|
|
||||||
plan_lineas_list,
|
|
||||||
plans_clone_from_existing,
|
|
||||||
plans_create_manual,
|
|
||||||
plans_delete,
|
|
||||||
plans_generate_document,
|
|
||||||
plans_get,
|
|
||||||
plans_get_document,
|
|
||||||
plans_history,
|
|
||||||
plans_import_from_files,
|
|
||||||
plans_list,
|
|
||||||
plans_persist_from_ai,
|
|
||||||
plans_transition_state,
|
|
||||||
plans_update_fields,
|
|
||||||
plans_update_map,
|
|
||||||
} from '../api/plans.api'
|
|
||||||
import { lineas_delete } from '../api/subjects.api'
|
|
||||||
import { qk } from '../query/keys'
|
|
||||||
import { supabaseBrowser } from '../supabase/client'
|
|
||||||
|
|
||||||
import type {
|
|
||||||
PlanListFilters,
|
|
||||||
PlanMapOperation,
|
|
||||||
PlansCreateManualInput,
|
|
||||||
PlansUpdateFieldsPatch,
|
|
||||||
} from '../api/plans.api'
|
|
||||||
import type { UUID } from '../types/domain'
|
|
||||||
|
|
||||||
export function usePlanes(filters: PlanListFilters) {
|
|
||||||
// 🧠 Tip: memoiza "filters" (useMemo) para que queryKey sea estable.
|
|
||||||
return useQuery({
|
|
||||||
// Usamos la factory de keys para consistencia
|
|
||||||
queryKey: qk.planesList(filters),
|
|
||||||
|
|
||||||
// La función fetch
|
|
||||||
queryFn: () => plans_list(filters),
|
|
||||||
|
|
||||||
// UX: Mantiene los datos viejos mientras carga la paginación nueva
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
|
|
||||||
// Opcional: Tiempo que la data se considera fresca
|
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutos
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePlan(planId: UUID | null | undefined) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: planId ? qk.plan(planId) : ['planes', 'detail', null],
|
|
||||||
queryFn: () => {
|
|
||||||
console.log('usePlan')
|
|
||||||
return plans_get(planId as UUID)
|
|
||||||
},
|
|
||||||
enabled: Boolean(planId),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePlanLineas(planId: UUID | null | undefined) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: planId ? qk.planLineas(planId) : ['planes', 'lineas', null],
|
|
||||||
queryFn: () => plan_lineas_list(planId as UUID),
|
|
||||||
enabled: Boolean(planId),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePlanAsignaturas(planId: UUID | null | undefined) {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: planId
|
|
||||||
? qk.planAsignaturas(planId)
|
|
||||||
: ['planes', 'asignaturas', null],
|
|
||||||
queryFn: () => plan_asignaturas_list(planId as UUID),
|
|
||||||
enabled: Boolean(planId),
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!planId) return
|
|
||||||
|
|
||||||
const supabase = supabaseBrowser()
|
|
||||||
const channel = supabase.channel(`plan-asignaturas-${planId}`)
|
|
||||||
|
|
||||||
channel.on(
|
|
||||||
'postgres_changes',
|
|
||||||
{
|
|
||||||
event: '*',
|
|
||||||
schema: 'public',
|
|
||||||
table: 'asignaturas',
|
|
||||||
filter: `plan_estudio_id=eq.${planId}`,
|
|
||||||
},
|
|
||||||
(payload: {
|
|
||||||
eventType?: 'INSERT' | 'UPDATE' | 'DELETE'
|
|
||||||
new?: any
|
|
||||||
old?: any
|
|
||||||
}) => {
|
|
||||||
const eventType = payload.eventType
|
|
||||||
|
|
||||||
if (eventType === 'DELETE') {
|
|
||||||
const oldRow: any = payload.old
|
|
||||||
const deletedId = oldRow?.id
|
|
||||||
if (!deletedId) return
|
|
||||||
|
|
||||||
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
|
|
||||||
if (!Array.isArray(prev)) return prev
|
|
||||||
return prev.filter((a: any) => String(a?.id) !== String(deletedId))
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const newRow: any = payload.new
|
|
||||||
if (!newRow?.id) return
|
|
||||||
|
|
||||||
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
|
|
||||||
if (!Array.isArray(prev)) return prev
|
|
||||||
|
|
||||||
const idx = prev.findIndex(
|
|
||||||
(a: any) => String(a?.id) === String(newRow.id),
|
|
||||||
)
|
|
||||||
if (idx === -1) return [...prev, newRow]
|
|
||||||
|
|
||||||
const next = [...prev]
|
|
||||||
next[idx] = { ...prev[idx], ...newRow }
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
channel.subscribe()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
try {
|
|
||||||
supabase.removeChannel(channel)
|
|
||||||
} catch {
|
|
||||||
// noop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [planId, qc])
|
|
||||||
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePlanHistorial(
|
|
||||||
planId: UUID | null | undefined,
|
|
||||||
page: number,
|
|
||||||
) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: planId
|
|
||||||
? [...qk.planHistorial(planId), page]
|
|
||||||
: ['planes', 'historial', null, page],
|
|
||||||
queryFn: () => plans_history(planId as UUID, page),
|
|
||||||
enabled: Boolean(planId),
|
|
||||||
placeholderData: (previousData) => previousData,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePlanDocumento(planId: UUID | null | undefined) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: planId ? qk.planDocumento(planId) : ['planes', 'documento', null],
|
|
||||||
queryFn: () => plans_get_document(planId as UUID),
|
|
||||||
enabled: Boolean(planId),
|
|
||||||
staleTime: 30_000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCatalogosPlanes() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: qk.estructurasPlan(),
|
|
||||||
queryFn: getCatalogos,
|
|
||||||
staleTime: 1000 * 60 * 60, // 1 hora de caché (estos datos casi no cambian)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------ Mutations ------------------ */
|
|
||||||
|
|
||||||
export function useCreatePlanManual() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (input: PlansCreateManualInput) => plans_create_manual(input),
|
|
||||||
onSuccess: (plan) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
|
||||||
qc.setQueryData(qk.plan(plan.id), plan)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGeneratePlanAI() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ai_generate_plan,
|
|
||||||
onSuccess: (data) => {
|
|
||||||
// Asumiendo que la Edge Function devuelve { ok: true, plan: { id: ... } }
|
|
||||||
console.log('success de ai_generate_plan')
|
|
||||||
|
|
||||||
const newPlan = data.plan
|
|
||||||
|
|
||||||
if (newPlan) {
|
|
||||||
// 1. Invalidar la lista para que aparezca el nuevo plan
|
|
||||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
|
||||||
|
|
||||||
// 2. (Opcional) Pre-cargar el dato individual para que la navegación sea instantánea
|
|
||||||
// qc.setQueryData(["planes", "detail", newPlan.id], newPlan);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Funcion obsoleta porque ahora el plan se persiste directamente en useGeneratePlanAI
|
|
||||||
export function usePersistPlanFromAI() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (payload: { jsonPlan: any }) => plans_persist_from_ai(payload),
|
|
||||||
onSuccess: (plan) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
|
||||||
qc.setQueryData(qk.plan(plan.id), plan)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useClonePlan() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: plans_clone_from_existing,
|
|
||||||
onSuccess: (plan) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
|
||||||
qc.setQueryData(qk.plan(plan.id), plan)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useImportPlanFromFiles() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: plans_import_from_files,
|
|
||||||
onSuccess: (plan) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
|
||||||
qc.setQueryData(qk.plan(plan.id), plan)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdatePlanFields() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (vars: { planId: UUID; patch: PlansUpdateFieldsPatch }) =>
|
|
||||||
plans_update_fields(vars.planId, vars.patch),
|
|
||||||
onSuccess: (updated) => {
|
|
||||||
qc.setQueryData(qk.plan(updated.id), updated)
|
|
||||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
|
||||||
qc.invalidateQueries({ queryKey: qk.planHistorial(updated.id) })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdatePlanMapa() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (vars: { planId: UUID; ops: Array<PlanMapOperation> }) =>
|
|
||||||
plans_update_map(vars.planId, vars.ops),
|
|
||||||
|
|
||||||
// ✅ Optimista (rápida) para el caso MOVE_ASIGNATURA
|
|
||||||
onMutate: async (vars) => {
|
|
||||||
await qc.cancelQueries({ queryKey: qk.planAsignaturas(vars.planId) })
|
|
||||||
const prev = qc.getQueryData<any>(qk.planAsignaturas(vars.planId))
|
|
||||||
|
|
||||||
// solo optimizamos MOVEs simples
|
|
||||||
const moves = vars.ops.filter((x) => x.op === 'MOVE_ASIGNATURA')
|
|
||||||
|
|
||||||
if (prev && Array.isArray(prev) && moves.length) {
|
|
||||||
const next = prev.map((a: any) => {
|
|
||||||
const m = moves.find((x) => x.asignaturaId === a.id)
|
|
||||||
if (!m) return a
|
|
||||||
return {
|
|
||||||
...a,
|
|
||||||
numero_ciclo: m.numero_ciclo,
|
|
||||||
linea_plan_id: m.linea_plan_id,
|
|
||||||
orden_celda: m.orden_celda ?? a.orden_celda,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
qc.setQueryData(qk.planAsignaturas(vars.planId), next)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { prev }
|
|
||||||
},
|
|
||||||
|
|
||||||
onError: (_err, vars, ctx) => {
|
|
||||||
if (ctx?.prev) qc.setQueryData(qk.planAsignaturas(vars.planId), ctx.prev)
|
|
||||||
},
|
|
||||||
|
|
||||||
onSuccess: (_ok, vars) => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.planAsignaturas(vars.planId) })
|
|
||||||
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTransitionPlanEstado() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: plans_transition_state,
|
|
||||||
onSuccess: (_ok, vars) => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.plan(vars.planId) })
|
|
||||||
qc.invalidateQueries({ queryKey: qk.planHistorial(vars.planId) })
|
|
||||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeletePlanEstudio() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (planId: UUID) => plans_delete(planId),
|
|
||||||
onSuccess: (_ok, planId) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
|
|
||||||
qc.removeQueries({ queryKey: qk.plan(planId) })
|
|
||||||
qc.removeQueries({ queryKey: qk.planMaybe(planId) })
|
|
||||||
qc.removeQueries({ queryKey: qk.planAsignaturas(planId) })
|
|
||||||
qc.removeQueries({ queryKey: qk.planLineas(planId) })
|
|
||||||
qc.removeQueries({ queryKey: qk.planHistorial(planId) })
|
|
||||||
qc.removeQueries({ queryKey: qk.planDocumento(planId) })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGeneratePlanDocumento() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (planId: UUID) => plans_generate_document(planId),
|
|
||||||
onSuccess: (_doc, planId) => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.planDocumento(planId) })
|
|
||||||
qc.invalidateQueries({ queryKey: qk.planHistorial(planId) })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteLinea() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: lineas_delete,
|
|
||||||
onSuccess: (_idEliminado) => {
|
|
||||||
// Invalidamos para que las materias y líneas se refresquen
|
|
||||||
qc.invalidateQueries({ queryKey: ['plan_lineas'] })
|
|
||||||
qc.invalidateQueries({ queryKey: ['plan_asignaturas'] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { repos_add_files, repos_create, repos_delete, repos_remove_files } from "../api/repositories.api";
|
|
||||||
|
|
||||||
export function useCreateRepository() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: repos_create,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["repos"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteRepository() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: repos_delete,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["repos"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRepoAddFiles() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: repos_add_files,
|
|
||||||
onSuccess: (_ok, vars) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["repos", vars.repoId] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRepoRemoveFiles() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: repos_remove_files,
|
|
||||||
onSuccess: (_ok, vars) => {
|
|
||||||
qc.invalidateQueries({ queryKey: ["repos", vars.repoId] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import {
|
|
||||||
ai_generate_subject,
|
|
||||||
asignaturas_update,
|
|
||||||
bibliografia_delete,
|
|
||||||
bibliografia_insert,
|
|
||||||
bibliografia_update,
|
|
||||||
lineas_insert,
|
|
||||||
lineas_update,
|
|
||||||
subjects_bibliografia_list,
|
|
||||||
subjects_clone_from_existing,
|
|
||||||
subjects_create_manual,
|
|
||||||
subjects_generate_document,
|
|
||||||
subjects_get,
|
|
||||||
subjects_get_document,
|
|
||||||
subjects_get_structure_catalog,
|
|
||||||
subjects_history,
|
|
||||||
subjects_import_from_file,
|
|
||||||
subjects_persist_from_ai,
|
|
||||||
subjects_update_bibliografia,
|
|
||||||
subjects_update_contenido,
|
|
||||||
subjects_update_fields,
|
|
||||||
} from '../api/subjects.api'
|
|
||||||
import { qk } from '../query/keys'
|
|
||||||
|
|
||||||
import type {
|
|
||||||
BibliografiaUpsertInput,
|
|
||||||
ContenidoApi,
|
|
||||||
SubjectsUpdateFieldsPatch,
|
|
||||||
} from '../api/subjects.api'
|
|
||||||
import type { UUID } from '../types/domain'
|
|
||||||
import type { TablesInsert } from '@/types/supabase'
|
|
||||||
|
|
||||||
export function useSubject(subjectId: UUID | null | undefined) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: subjectId
|
|
||||||
? qk.asignatura(subjectId)
|
|
||||||
: ['asignaturas', 'detail', null],
|
|
||||||
queryFn: () => subjects_get(subjectId as UUID),
|
|
||||||
enabled: Boolean(subjectId),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSubjectBibliografia(subjectId: UUID | null | undefined) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: subjectId
|
|
||||||
? qk.asignaturaBibliografia(subjectId)
|
|
||||||
: ['asignaturas', 'bibliografia', null],
|
|
||||||
queryFn: () => subjects_bibliografia_list(subjectId as UUID),
|
|
||||||
enabled: Boolean(subjectId),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSubjectHistorial(subjectId: UUID | null | undefined) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: subjectId
|
|
||||||
? qk.asignaturaHistorial(subjectId)
|
|
||||||
: ['asignaturas', 'historial', null],
|
|
||||||
queryFn: () => subjects_history(subjectId as UUID),
|
|
||||||
enabled: Boolean(subjectId),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSubjectDocumento(subjectId: UUID | null | undefined) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: subjectId
|
|
||||||
? qk.asignaturaDocumento(subjectId)
|
|
||||||
: ['asignaturas', 'documento', null],
|
|
||||||
queryFn: () => subjects_get_document(subjectId as UUID),
|
|
||||||
enabled: Boolean(subjectId),
|
|
||||||
staleTime: 30_000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSubjectEstructuras() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: qk.estructurasAsignatura(),
|
|
||||||
queryFn: () => subjects_get_structure_catalog(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------ Mutations ------------------ */
|
|
||||||
|
|
||||||
export function useCreateSubjectManual() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (payload: TablesInsert<'asignaturas'>) =>
|
|
||||||
subjects_create_manual(payload),
|
|
||||||
onSuccess: (subject) => {
|
|
||||||
qc.setQueryData(qk.asignatura(subject.id), subject)
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
|
|
||||||
})
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.planHistorial(subject.plan_estudio_id),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGenerateSubjectAI() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ai_generate_subject,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePersistSubjectFromAI() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (payload: { planId: UUID; jsonAsignatura: any }) =>
|
|
||||||
subjects_persist_from_ai(payload),
|
|
||||||
onSuccess: (subject) => {
|
|
||||||
qc.setQueryData(qk.asignatura(subject.id), subject)
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
|
|
||||||
})
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.planHistorial(subject.plan_estudio_id),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCloneSubject() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: subjects_clone_from_existing,
|
|
||||||
onSuccess: (subject) => {
|
|
||||||
qc.setQueryData(qk.asignatura(subject.id), subject)
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
|
|
||||||
})
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.planHistorial(subject.plan_estudio_id),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useImportSubjectFromFile() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: subjects_import_from_file,
|
|
||||||
onSuccess: (subject) => {
|
|
||||||
qc.setQueryData(qk.asignatura(subject.id), subject)
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.planAsignaturas(subject.plan_estudio_id),
|
|
||||||
})
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.planHistorial(subject.plan_estudio_id),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateSubjectFields() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (vars: { subjectId: UUID; patch: SubjectsUpdateFieldsPatch }) =>
|
|
||||||
subjects_update_fields(vars.subjectId, vars.patch),
|
|
||||||
onSuccess: (updated) => {
|
|
||||||
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
|
|
||||||
prev ? { ...(prev as any), ...(updated as any) } : updated,
|
|
||||||
)
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
|
|
||||||
})
|
|
||||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateSubjectContenido() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (vars: { subjectId: UUID; unidades: Array<ContenidoApi> }) =>
|
|
||||||
subjects_update_contenido(vars.subjectId, vars.unidades),
|
|
||||||
onSuccess: (updated) => {
|
|
||||||
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
|
|
||||||
prev ? { ...(prev as any), ...(updated as any) } : updated,
|
|
||||||
)
|
|
||||||
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
|
|
||||||
})
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.planHistorial(updated.plan_estudio_id),
|
|
||||||
})
|
|
||||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateSubjectBibliografia() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (vars: { subjectId: UUID; entries: BibliografiaUpsertInput }) =>
|
|
||||||
subjects_update_bibliografia(vars.subjectId, vars.entries),
|
|
||||||
onSuccess: (_ok, vars) => {
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.asignaturaBibliografia(vars.subjectId),
|
|
||||||
})
|
|
||||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(vars.subjectId) })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGenerateSubjectDocumento() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (subjectId: UUID) => subjects_generate_document(subjectId),
|
|
||||||
onSuccess: (_doc, subjectId) => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.asignaturaDocumento(subjectId) })
|
|
||||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(subjectId) })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateAsignatura() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (vars: {
|
|
||||||
asignaturaId: UUID
|
|
||||||
patch: Partial<SubjectsUpdateFieldsPatch>
|
|
||||||
}) => asignaturas_update(vars.asignaturaId, vars.patch),
|
|
||||||
|
|
||||||
onSuccess: (updated) => {
|
|
||||||
// ✅ Mantener consistencia con las query keys centralizadas (qk)
|
|
||||||
// 1) Actualiza el detalle (esto evita volver a entrar con caché vieja)
|
|
||||||
qc.setQueryData(qk.asignatura(updated.id), (prev) =>
|
|
||||||
prev ? { ...(prev as any), ...(updated as any) } : updated,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 2) Refresca vistas derivadas del plan
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.planAsignaturas(updated.plan_estudio_id),
|
|
||||||
})
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.planHistorial(updated.plan_estudio_id),
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3) Refresca historial de la asignatura si existe
|
|
||||||
qc.invalidateQueries({ queryKey: qk.asignaturaHistorial(updated.id) })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateLinea() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: lineas_insert,
|
|
||||||
onSuccess: (nuevaLinea) => {
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: ['plan_lineas', nuevaLinea.plan_estudio_id],
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateLinea() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (vars: { lineaId: string; patch: any }) =>
|
|
||||||
lineas_update(vars.lineaId, vars.patch),
|
|
||||||
onSuccess: (updated) => {
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: ['plan_lineas', updated.plan_estudio_id],
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateBibliografia() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: bibliografia_insert,
|
|
||||||
onSuccess: (data) => {
|
|
||||||
// USAR LA MISMA LLAVE QUE EL HOOK DE LECTURA
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: qk.asignaturaBibliografia(data.asignatura_id),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateBibliografia(asignaturaId: string) {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ id, updates }: { id: string; updates: any }) =>
|
|
||||||
bibliografia_update(id, updates),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: qk.asignaturaBibliografia(asignaturaId),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteBibliografia(asignaturaId: string) {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) => bibliografia_delete(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: qk.asignaturaBibliografia(asignaturaId),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { qk } from "../query/keys";
|
|
||||||
import { tareas_marcar_completada, tareas_mias_list } from "../api/tasks.api";
|
|
||||||
|
|
||||||
export function useMisTareas() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: qk.tareas(),
|
|
||||||
queryFn: tareas_mias_list,
|
|
||||||
staleTime: 15_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMarcarTareaCompletada() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: tareas_marcar_completada,
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.tareas() });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
export * from "./supabase/client";
|
|
||||||
export * from "./supabase/invokeEdge";
|
|
||||||
|
|
||||||
export * from "./query/queryClient";
|
|
||||||
export * from "./query/keys";
|
|
||||||
|
|
||||||
export * from "./types/domain";
|
|
||||||
|
|
||||||
export * from "./api/meta.api";
|
|
||||||
export * from "./api/plans.api";
|
|
||||||
export * from "./api/subjects.api";
|
|
||||||
export * from "./api/files.api";
|
|
||||||
export * from "./api/ai.api";
|
|
||||||
export * from "./api/tasks.api";
|
|
||||||
export * from "./api/notifications.api";
|
|
||||||
|
|
||||||
export * from "./hooks/useAuth";
|
|
||||||
export * from "./hooks/useMeta";
|
|
||||||
export * from "./hooks/usePlans";
|
|
||||||
export * from "./hooks/useSubjects";
|
|
||||||
export * from "./hooks/useAI";
|
|
||||||
export * from "./hooks/useTasks";
|
|
||||||
export * from "./hooks/useNotifications";
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
import type {
|
|
||||||
Asignatura,
|
|
||||||
AsignaturaStructure,
|
|
||||||
UnidadTematica,
|
|
||||||
BibliografiaEntry,
|
|
||||||
CambioAsignatura,
|
|
||||||
DocumentoAsignatura,
|
|
||||||
} from '@/types/asignatura'
|
|
||||||
|
|
||||||
export const mockAsignatura: Asignatura = {
|
|
||||||
id: '1',
|
|
||||||
nombre: 'Inteligencia Artificial Aplicada',
|
|
||||||
clave: 'IAA-401',
|
|
||||||
creditos: 8,
|
|
||||||
lineaCurricular: 'Sistemas Inteligentes',
|
|
||||||
ciclo: '7° Semestre',
|
|
||||||
planId: 'plan-1',
|
|
||||||
planNombre: 'Licenciatura en Ingeniería en Sistemas Computacionales 2024',
|
|
||||||
carrera: 'Ingeniería en Sistemas Computacionales',
|
|
||||||
facultad: 'Facultad de Ingeniería',
|
|
||||||
estructuraId: 'estructura-1',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mockEstructura: AsignaturaStructure = {
|
|
||||||
id: 'estructura-1',
|
|
||||||
nombre: 'Plantilla SEP Licenciatura',
|
|
||||||
campos: [
|
|
||||||
{
|
|
||||||
id: 'objetivo_general',
|
|
||||||
nombre: 'Objetivo General',
|
|
||||||
tipo: 'texto_largo',
|
|
||||||
obligatorio: true,
|
|
||||||
descripcion: 'Describe el propósito principal de la asignatura',
|
|
||||||
placeholder: 'Al finalizar el curso, el estudiante será capaz de...',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'competencias',
|
|
||||||
nombre: 'Competencias a Desarrollar',
|
|
||||||
tipo: 'texto_largo',
|
|
||||||
obligatorio: true,
|
|
||||||
descripcion: 'Competencias profesionales que se desarrollarán',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'justificacion',
|
|
||||||
nombre: 'Justificación',
|
|
||||||
tipo: 'texto_largo',
|
|
||||||
obligatorio: true,
|
|
||||||
descripcion: 'Relevancia de la asignatura en el plan de estudios',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'requisitos',
|
|
||||||
nombre: 'Requisitos / Seriación',
|
|
||||||
tipo: 'texto',
|
|
||||||
obligatorio: false,
|
|
||||||
descripcion: 'Asignaturas previas requeridas',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'estrategias_didacticas',
|
|
||||||
nombre: 'Estrategias Didácticas',
|
|
||||||
tipo: 'texto_largo',
|
|
||||||
obligatorio: true,
|
|
||||||
descripcion: 'Métodos de enseñanza-aprendizaje',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'evaluacion',
|
|
||||||
nombre: 'Sistema de Evaluación',
|
|
||||||
tipo: 'texto_largo',
|
|
||||||
obligatorio: true,
|
|
||||||
descripcion: 'Criterios y porcentajes de evaluación',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'perfil_docente',
|
|
||||||
nombre: 'Perfil del Docente',
|
|
||||||
tipo: 'texto_largo',
|
|
||||||
obligatorio: false,
|
|
||||||
descripcion: 'Características requeridas del profesor',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mockDatosGenerales: Record<string, any> = {
|
|
||||||
objetivo_general:
|
|
||||||
'Formar profesionales capaces de diseñar, implementar y evaluar sistemas de inteligencia artificial que resuelvan problemas complejos del mundo real, aplicando principios éticos y metodologías actuales en el campo.',
|
|
||||||
competencias:
|
|
||||||
'• Diseñar algoritmos de machine learning para clasificación y predicción\n• Implementar redes neuronales profundas para procesamiento de imágenes y texto\n• Evaluar y optimizar modelos de IA considerando métricas de rendimiento\n• Aplicar principios éticos en el desarrollo de sistemas inteligentes',
|
|
||||||
justificacion:
|
|
||||||
'La inteligencia artificial es una de las tecnologías más disruptivas del siglo XXI. Su integración en diversos sectores demanda profesionales con sólidas bases teóricas y prácticas. Esta asignatura proporciona las competencias necesarias para que el egresado pueda innovar y contribuir al desarrollo tecnológico del país.',
|
|
||||||
requisitos:
|
|
||||||
'Programación Avanzada (PAV-301), Matemáticas Discretas (MAT-201)',
|
|
||||||
estrategias_didacticas:
|
|
||||||
'• Aprendizaje basado en proyectos\n• Talleres prácticos con datasets reales\n• Exposiciones y discusiones grupales\n• Análisis de casos de estudio\n• Desarrollo de prototipo integrador',
|
|
||||||
evaluacion:
|
|
||||||
'• Exámenes parciales: 30%\n• Proyecto integrador: 35%\n• Prácticas de laboratorio: 20%\n• Participación y tareas: 15%',
|
|
||||||
perfil_docente:
|
|
||||||
'Profesional con maestría o doctorado en áreas afines a la inteligencia artificial, con experiencia mínima de 3 años en docencia y desarrollo de proyectos de IA.',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mockContenidoTematico: Array<UnidadTematica> = [
|
|
||||||
{
|
|
||||||
id: 'unidad-1',
|
|
||||||
nombre: 'Fundamentos de Inteligencia Artificial',
|
|
||||||
numero: 1,
|
|
||||||
temas: [
|
|
||||||
{
|
|
||||||
id: 'tema-1-1',
|
|
||||||
nombre: 'Historia y evolución de la IA',
|
|
||||||
descripcion: 'Desde los orígenes hasta la actualidad',
|
|
||||||
horasEstimadas: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tema-1-2',
|
|
||||||
nombre: 'Tipos de IA y aplicaciones',
|
|
||||||
descripcion: 'IA débil, fuerte y superinteligencia',
|
|
||||||
horasEstimadas: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tema-1-3',
|
|
||||||
nombre: 'Ética en IA',
|
|
||||||
descripcion: 'Consideraciones éticas y responsabilidad',
|
|
||||||
horasEstimadas: 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'unidad-2',
|
|
||||||
nombre: 'Machine Learning',
|
|
||||||
numero: 2,
|
|
||||||
temas: [
|
|
||||||
{
|
|
||||||
id: 'tema-2-1',
|
|
||||||
nombre: 'Aprendizaje supervisado',
|
|
||||||
descripcion: 'Regresión y clasificación',
|
|
||||||
horasEstimadas: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tema-2-2',
|
|
||||||
nombre: 'Aprendizaje no supervisado',
|
|
||||||
descripcion: 'Clustering y reducción de dimensionalidad',
|
|
||||||
horasEstimadas: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tema-2-3',
|
|
||||||
nombre: 'Evaluación de modelos',
|
|
||||||
descripcion: 'Métricas y validación cruzada',
|
|
||||||
horasEstimadas: 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'unidad-3',
|
|
||||||
nombre: 'Deep Learning',
|
|
||||||
numero: 3,
|
|
||||||
temas: [
|
|
||||||
{
|
|
||||||
id: 'tema-3-1',
|
|
||||||
nombre: 'Redes neuronales artificiales',
|
|
||||||
descripcion: 'Perceptrón y backpropagation',
|
|
||||||
horasEstimadas: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tema-3-2',
|
|
||||||
nombre: 'Redes convolucionales (CNN)',
|
|
||||||
descripcion: 'Procesamiento de imágenes',
|
|
||||||
horasEstimadas: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tema-3-3',
|
|
||||||
nombre: 'Redes recurrentes (RNN)',
|
|
||||||
descripcion: 'Procesamiento de secuencias',
|
|
||||||
horasEstimadas: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tema-3-4',
|
|
||||||
nombre: 'Transformers y atención',
|
|
||||||
descripcion: 'Arquitecturas modernas',
|
|
||||||
horasEstimadas: 6,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'unidad-4',
|
|
||||||
nombre: 'Aplicaciones Prácticas',
|
|
||||||
numero: 4,
|
|
||||||
temas: [
|
|
||||||
{
|
|
||||||
id: 'tema-4-1',
|
|
||||||
nombre: 'Procesamiento de lenguaje natural',
|
|
||||||
descripcion: 'NLP y chatbots',
|
|
||||||
horasEstimadas: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tema-4-2',
|
|
||||||
nombre: 'Visión por computadora',
|
|
||||||
descripcion: 'Detección y reconocimiento',
|
|
||||||
horasEstimadas: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tema-4-3',
|
|
||||||
nombre: 'Sistemas de recomendación',
|
|
||||||
descripcion: 'Filtrado colaborativo y contenido',
|
|
||||||
horasEstimadas: 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const mockBibliografia: Array<BibliografiaEntry> = [
|
|
||||||
{
|
|
||||||
id: 'bib-1',
|
|
||||||
tipo: 'BASICA',
|
|
||||||
cita: 'Russell, S., & Norvig, P. (2021). Artificial Intelligence: A Modern Approach (4th ed.). Pearson.',
|
|
||||||
fuenteBibliotecaId: 'lib-1',
|
|
||||||
fuenteBiblioteca: {
|
|
||||||
id: 'lib-1',
|
|
||||||
titulo: 'Artificial Intelligence: A Modern Approach',
|
|
||||||
autor: 'Stuart Russell, Peter Norvig',
|
|
||||||
editorial: 'Pearson',
|
|
||||||
anio: 2021,
|
|
||||||
isbn: '978-0134610993',
|
|
||||||
tipo: 'libro',
|
|
||||||
disponible: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bib-2',
|
|
||||||
tipo: 'BASICA',
|
|
||||||
cita: "Géron, A. (2022). Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow (3rd ed.). O'Reilly Media.",
|
|
||||||
fuenteBibliotecaId: 'lib-2',
|
|
||||||
fuenteBiblioteca: {
|
|
||||||
id: 'lib-2',
|
|
||||||
titulo:
|
|
||||||
'Hands-On Machine Learning with Scikit-Learn, Keras, and TensorFlow',
|
|
||||||
autor: 'Aurélien Géron',
|
|
||||||
editorial: "O'Reilly Media",
|
|
||||||
anio: 2022,
|
|
||||||
isbn: '978-1098125974',
|
|
||||||
tipo: 'libro',
|
|
||||||
disponible: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bib-3',
|
|
||||||
tipo: 'COMPLEMENTARIA',
|
|
||||||
cita: 'Goodfellow, I., Bengio, Y., & Courville, A. (2016). Deep Learning. MIT Press.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bib-4',
|
|
||||||
tipo: 'COMPLEMENTARIA',
|
|
||||||
cita: 'Chollet, F. (2021). Deep Learning with Python (2nd ed.). Manning Publications.',
|
|
||||||
fuenteBibliotecaId: 'lib-4',
|
|
||||||
fuenteBiblioteca: {
|
|
||||||
id: 'lib-4',
|
|
||||||
titulo: 'Deep Learning with Python',
|
|
||||||
autor: 'François Chollet',
|
|
||||||
editorial: 'Manning Publications',
|
|
||||||
anio: 2021,
|
|
||||||
isbn: '978-1617296864',
|
|
||||||
tipo: 'libro',
|
|
||||||
disponible: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const mockHistorial: Array<CambioAsignatura> = [
|
|
||||||
{
|
|
||||||
id: 'cambio-1',
|
|
||||||
tipo: 'datos',
|
|
||||||
descripcion: 'Actualización del objetivo general',
|
|
||||||
usuario: 'Dr. Carlos Méndez',
|
|
||||||
fecha: new Date('2024-12-10T14:30:00'),
|
|
||||||
detalles: { campo: 'objetivo_general' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cambio-2',
|
|
||||||
tipo: 'contenido',
|
|
||||||
descripcion: 'Agregada Unidad 4: Aplicaciones Prácticas',
|
|
||||||
usuario: 'Dr. Carlos Méndez',
|
|
||||||
fecha: new Date('2024-12-09T10:15:00'),
|
|
||||||
detalles: { unidad: 'Unidad 4' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cambio-3',
|
|
||||||
tipo: 'ia',
|
|
||||||
descripcion: 'IA mejoró las competencias a desarrollar',
|
|
||||||
usuario: 'Dra. María López',
|
|
||||||
fecha: new Date('2024-12-08T16:45:00'),
|
|
||||||
detalles: { campo: 'competencias', accion: 'mejora' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cambio-4',
|
|
||||||
tipo: 'bibliografia',
|
|
||||||
descripcion: 'Añadida referencia: Deep Learning with Python',
|
|
||||||
usuario: 'Biblioteca Central',
|
|
||||||
fecha: new Date('2024-12-07T09:00:00'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cambio-5',
|
|
||||||
tipo: 'documento',
|
|
||||||
descripcion: 'Documento SEP regenerado (versión 3)',
|
|
||||||
usuario: 'Sistema',
|
|
||||||
fecha: new Date('2024-12-06T11:30:00'),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const mockDocumentoSep: DocumentoAsignatura = {
|
|
||||||
id: 'doc-1',
|
|
||||||
asignaturaId: '1',
|
|
||||||
version: 3,
|
|
||||||
fechaGeneracion: new Date('2024-12-06T11:30:00'),
|
|
||||||
estado: 'listo',
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
export const qk = {
|
|
||||||
auth: ['auth'] as const,
|
|
||||||
session: () => ['auth', 'session'] as const,
|
|
||||||
meProfile: () => ['auth', 'meProfile'] as const,
|
|
||||||
meAccess: () => ['auth', 'meAccess'] as const,
|
|
||||||
|
|
||||||
facultades: () => ['meta', 'facultades'] as const,
|
|
||||||
carreras: (facultadId?: string | null) =>
|
|
||||||
['meta', 'carreras', { facultadId: facultadId ?? null }] as const,
|
|
||||||
estructurasPlan: (nivel?: string | null) =>
|
|
||||||
['meta', 'estructurasPlan', { nivel: nivel ?? null }] as const,
|
|
||||||
estructurasAsignatura: () => ['meta', 'estructurasAsignatura'] as const,
|
|
||||||
estadosPlan: () => ['meta', 'estadosPlan'] as const,
|
|
||||||
|
|
||||||
planesList: (filters: unknown) => ['planes', 'list', filters] as const,
|
|
||||||
plan: (planId: string) => ['planes', 'detail', planId] as const,
|
|
||||||
planMaybe: (planId: string) => ['planes', 'detail-maybe', planId] as const,
|
|
||||||
planLineas: (planId: string) => ['planes', planId, 'lineas'] as const,
|
|
||||||
planAsignaturas: (planId: string) =>
|
|
||||||
['planes', planId, 'asignaturas'] as const,
|
|
||||||
planHistorial: (planId: string) => ['planes', planId, 'historial'] as const,
|
|
||||||
planDocumento: (planId: string) => ['planes', planId, 'documento'] as const,
|
|
||||||
|
|
||||||
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
|
|
||||||
asignatura: (asignaturaId: string) =>
|
|
||||||
['asignaturas', 'detail', asignaturaId] as const,
|
|
||||||
asignaturaMaybe: (asignaturaId: string) =>
|
|
||||||
['asignaturas', 'detail-maybe', asignaturaId] as const,
|
|
||||||
asignaturaBibliografia: (asignaturaId: string) =>
|
|
||||||
['asignaturas', asignaturaId, 'bibliografia'] as const,
|
|
||||||
asignaturaHistorial: (asignaturaId: string) =>
|
|
||||||
['asignaturas', asignaturaId, 'historial'] as const,
|
|
||||||
asignaturaDocumento: (asignaturaId: string) =>
|
|
||||||
['asignaturas', asignaturaId, 'documento'] as const,
|
|
||||||
|
|
||||||
tareas: () => ['tareas', 'mias'] as const,
|
|
||||||
notificaciones: () => ['notificaciones', 'mias'] as const,
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import {
|
|
||||||
MutationCache,
|
|
||||||
QueryCache,
|
|
||||||
QueryClient,
|
|
||||||
QueryClientProvider,
|
|
||||||
} from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import { qk } from './keys'
|
|
||||||
|
|
||||||
import type React from 'react'
|
|
||||||
|
|
||||||
function isRlsViolationError(error: unknown): boolean {
|
|
||||||
const anyErr = error as any
|
|
||||||
const code = anyErr?.code
|
|
||||||
const status = anyErr?.status ?? anyErr?.response?.status
|
|
||||||
console.log('Checking RLS violation error:', { code, status })
|
|
||||||
// Supabase/PostgREST suele devolver 403 (Forbidden) o código PG 42501 (insufficient_privilege)
|
|
||||||
return status === 403 || code === '42501'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getContext() {
|
|
||||||
const queryClientRef: { current: QueryClient | null } = { current: null }
|
|
||||||
|
|
||||||
const handleAuthzDesync = (error: unknown) => {
|
|
||||||
if (!isRlsViolationError(error)) return
|
|
||||||
// Forzar resincronización “database-first” del rol/permisos
|
|
||||||
console.log('RLS violation detected, invalidating queries...')
|
|
||||||
queryClientRef.current?.invalidateQueries({ queryKey: qk.meAccess() })
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
queryCache: new QueryCache({
|
|
||||||
onError: (error) => {
|
|
||||||
handleAuthzDesync(error)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
mutationCache: new MutationCache({
|
|
||||||
onError: (error) => {
|
|
||||||
handleAuthzDesync(error)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
staleTime: 30_000,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
retry: (failureCount) => failureCount < 2,
|
|
||||||
},
|
|
||||||
mutations: {
|
|
||||||
retry: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
queryClientRef.current = queryClient
|
|
||||||
return {
|
|
||||||
queryClient,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Provider({
|
|
||||||
children,
|
|
||||||
queryClient,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
queryClient: QueryClient
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { createClient } from "@supabase/supabase-js";
|
|
||||||
|
|
||||||
import { getEnv } from "./env";
|
|
||||||
|
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
||||||
import type { Database } from "src/types/supabase";
|
|
||||||
|
|
||||||
let _client: SupabaseClient<Database> | null = null;
|
|
||||||
|
|
||||||
export function supabaseBrowser(): SupabaseClient<Database> {
|
|
||||||
if (_client) return _client;
|
|
||||||
|
|
||||||
const url = getEnv(
|
|
||||||
"VITE_SUPABASE_URL",
|
|
||||||
"NEXT_PUBLIC_SUPABASE_URL",
|
|
||||||
"SUPABASE_URL",
|
|
||||||
);
|
|
||||||
|
|
||||||
const anonKey = getEnv(
|
|
||||||
"VITE_SUPABASE_ANON_KEY",
|
|
||||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
|
||||||
"SUPABASE_ANON_KEY",
|
|
||||||
);
|
|
||||||
|
|
||||||
_client = createClient<Database>(url, anonKey, {
|
|
||||||
auth: {
|
|
||||||
persistSession: true,
|
|
||||||
autoRefreshToken: true,
|
|
||||||
detectSessionInUrl: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return _client;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export function getEnv(...keys: string[]): string {
|
|
||||||
for (const key of keys) {
|
|
||||||
const fromProcess =
|
|
||||||
typeof process !== "undefined" ? (process as any).env?.[key] : undefined;
|
|
||||||
|
|
||||||
// Vite / bundlers
|
|
||||||
const fromImportMeta =
|
|
||||||
typeof import.meta !== "undefined" ? (import.meta as any).env?.[key] : undefined;
|
|
||||||
|
|
||||||
const value = fromProcess ?? fromImportMeta;
|
|
||||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`Falta variable de entorno. Probé: ${keys.join(", ")}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import {
|
|
||||||
FunctionsFetchError,
|
|
||||||
FunctionsHttpError,
|
|
||||||
FunctionsRelayError,
|
|
||||||
} from '@supabase/supabase-js'
|
|
||||||
|
|
||||||
import { supabaseBrowser } from './client'
|
|
||||||
|
|
||||||
import type { Database } from '@/types/supabase'
|
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
||||||
|
|
||||||
export type EdgeInvokeOptions = {
|
|
||||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
|
||||||
headers?: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EdgeFunctionError extends Error {
|
|
||||||
constructor(
|
|
||||||
message: string,
|
|
||||||
public readonly functionName: string,
|
|
||||||
public readonly status?: number,
|
|
||||||
public readonly details?: unknown,
|
|
||||||
) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'EdgeFunctionError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function invokeEdge<TOut>(
|
|
||||||
functionName: string,
|
|
||||||
body?:
|
|
||||||
| string
|
|
||||||
| File
|
|
||||||
| Blob
|
|
||||||
| ArrayBuffer
|
|
||||||
| FormData
|
|
||||||
| ReadableStream<Uint8Array<ArrayBufferLike>>
|
|
||||||
| Record<string, unknown>
|
|
||||||
| undefined,
|
|
||||||
opts: EdgeInvokeOptions = {},
|
|
||||||
client?: SupabaseClient<Database>,
|
|
||||||
): Promise<TOut> {
|
|
||||||
const supabase = client ?? supabaseBrowser()
|
|
||||||
|
|
||||||
const { data, error } = await supabase.functions.invoke(functionName, {
|
|
||||||
body,
|
|
||||||
method: opts.method ?? 'POST',
|
|
||||||
headers: opts.headers,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
// Valores por defecto (por si falla el parseo o es otro tipo de error)
|
|
||||||
let message = error.message // El genérico "returned a non-2xx status code"
|
|
||||||
let status = undefined
|
|
||||||
let details: unknown = error
|
|
||||||
|
|
||||||
// 2. Verificamos si es un error HTTP (4xx o 5xx) que trae cuerpo JSON
|
|
||||||
if (error instanceof FunctionsHttpError) {
|
|
||||||
try {
|
|
||||||
// Obtenemos el status real (ej. 404, 400)
|
|
||||||
status = error.context.status
|
|
||||||
|
|
||||||
// ¡LA CLAVE! Leemos el JSON que tu Edge Function envió
|
|
||||||
const errorBody = await error.context.json()
|
|
||||||
details = errorBody
|
|
||||||
|
|
||||||
// Intentamos extraer el mensaje humano según tu estructura { error: { message: "..." } }
|
|
||||||
// o la estructura simple { error: "..." }
|
|
||||||
if (errorBody && typeof errorBody === 'object') {
|
|
||||||
// Caso 1: Estructura anidada (la que definimos hace poco: { error: { message: "..." } })
|
|
||||||
if (
|
|
||||||
'error' in errorBody &&
|
|
||||||
typeof errorBody.error === 'object' &&
|
|
||||||
errorBody.error !== null &&
|
|
||||||
'message' in errorBody.error
|
|
||||||
) {
|
|
||||||
message = (errorBody.error as { message: string }).message
|
|
||||||
}
|
|
||||||
// Caso 2: Estructura simple ({ error: "Mensaje de error" })
|
|
||||||
else if (
|
|
||||||
'error' in errorBody &&
|
|
||||||
typeof errorBody.error === 'string'
|
|
||||||
) {
|
|
||||||
message = errorBody.error
|
|
||||||
}
|
|
||||||
// Caso 3: Propiedad message directa ({ message: "..." })
|
|
||||||
else if (
|
|
||||||
'message' in errorBody &&
|
|
||||||
typeof errorBody.message === 'string'
|
|
||||||
) {
|
|
||||||
message = errorBody.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('No se pudo parsear el error JSON de la Edge Function', e)
|
|
||||||
}
|
|
||||||
} else if (error instanceof FunctionsRelayError) {
|
|
||||||
message = `Error de Relay Supabase: ${error.message}`
|
|
||||||
} else if (error instanceof FunctionsFetchError) {
|
|
||||||
message = `Error de conexión (Fetch): ${error.message}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Lanzamos tu error personalizado con los datos reales extraídos
|
|
||||||
throw new EdgeFunctionError(message, functionName, status, details)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as TOut
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import type { Enums, Tables } from "../../types/supabase";
|
|
||||||
|
|
||||||
export type UUID = string;
|
|
||||||
|
|
||||||
export type TipoEstructuraPlan = Enums<"tipo_estructura_plan">;
|
|
||||||
export type NivelPlanEstudio = Enums<"nivel_plan_estudio">;
|
|
||||||
export type TipoCiclo = Enums<"tipo_ciclo">;
|
|
||||||
|
|
||||||
export type TipoOrigen = Enums<"tipo_origen">;
|
|
||||||
|
|
||||||
export type TipoAsignatura = Enums<"tipo_asignatura">;
|
|
||||||
|
|
||||||
export type TipoBibliografia = Enums<"tipo_bibliografia">;
|
|
||||||
export type TipoFuenteBibliografia = Enums<"tipo_fuente_bibliografia">;
|
|
||||||
|
|
||||||
export type EstadoTareaRevision = Enums<"estado_tarea_revision">;
|
|
||||||
export type TipoNotificacion = Enums<"tipo_notificacion">;
|
|
||||||
|
|
||||||
export type TipoInteraccionIA = Enums<"tipo_interaccion_ia">;
|
|
||||||
|
|
||||||
export type ModalidadEducativa = "Escolar" | "No escolarizada" | "Mixta";
|
|
||||||
export type DisenoCurricular = "Rígido" | "Flexible";
|
|
||||||
|
|
||||||
/** Basado en tu schema JSON (va típicamente dentro de planes_estudio.datos) */
|
|
||||||
export type PlanDatosSep = {
|
|
||||||
nivel?: string;
|
|
||||||
nombre?: string;
|
|
||||||
modalidad_educativa?: ModalidadEducativa;
|
|
||||||
|
|
||||||
antecedente_academico?: string;
|
|
||||||
area_de_estudio?: string;
|
|
||||||
clave_del_plan_de_estudios?: string;
|
|
||||||
|
|
||||||
diseno_curricular?: DisenoCurricular;
|
|
||||||
|
|
||||||
total_de_ciclos_del_plan_de_estudios?: string;
|
|
||||||
duracion_del_ciclo_escolar?: string;
|
|
||||||
carga_horaria_a_la_semana?: number;
|
|
||||||
|
|
||||||
fines_de_aprendizaje_o_formacion?: string;
|
|
||||||
perfil_de_egreso?: string;
|
|
||||||
|
|
||||||
programa_de_investigacion?: string | null;
|
|
||||||
curso_propedeutico?: string | null;
|
|
||||||
|
|
||||||
perfil_de_ingreso?: string;
|
|
||||||
|
|
||||||
administracion_y_operatividad_del_plan_de_estudios?: string | null;
|
|
||||||
sustento_teorico_del_modelo_curricular?: string | null;
|
|
||||||
justificacion_de_la_propuesta_curricular?: string | null;
|
|
||||||
propuesta_de_evaluacion_periodica_del_plan_de_estudios?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PlanEstudioWithRel =
|
|
||||||
& Tables<"planes_estudio">
|
|
||||||
& {
|
|
||||||
carreras:
|
|
||||||
| Tables<"carreras"> & {
|
|
||||||
facultades: Tables<"facultades"> | null;
|
|
||||||
}
|
|
||||||
| null;
|
|
||||||
estados_plan: Tables<"estados_plan"> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Paged<T> = { data: Array<T>; count: number | null };
|
|
||||||
|
|
||||||
export type FacultadRow = Tables<"facultades">;
|
|
||||||
export type CarreraRow = Tables<"carreras">;
|
|
||||||
|
|
||||||
export type EstructuraPlanRow = Tables<"estructuras_plan">;
|
|
||||||
|
|
||||||
export type EstructuraAsignatura = Tables<"estructuras_asignatura">;
|
|
||||||
|
|
||||||
export type EstadoPlanRow = Tables<"estados_plan">;
|
|
||||||
export type PlanEstudioRow = Tables<"planes_estudio">;
|
|
||||||
|
|
||||||
export type PlanEstudio = PlanEstudioRow & {
|
|
||||||
carreras: (CarreraRow & { facultades: FacultadRow | null }) | null;
|
|
||||||
estructuras_plan: EstructuraPlanRow | null;
|
|
||||||
estados_plan: EstadoPlanRow | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LineaPlan = Tables<"lineas_plan">;
|
|
||||||
|
|
||||||
export type Asignatura = Tables<"asignaturas">;
|
|
||||||
|
|
||||||
export type BibliografiaAsignatura = Tables<"bibliografia_asignatura">;
|
|
||||||
|
|
||||||
export type CambioPlan = Tables<"cambios_plan">;
|
|
||||||
|
|
||||||
export type CambioAsignatura = Tables<"cambios_asignatura">;
|
|
||||||
|
|
||||||
export type InteraccionIA = Tables<"interacciones_ia">;
|
|
||||||
|
|
||||||
export type TareaRevision = Tables<"tareas_revision">;
|
|
||||||
|
|
||||||
export type Notificacion = Tables<"notificaciones">;
|
|
||||||
|
|
||||||
export type Archivo = Tables<"archivos">;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user