Compare commits
43 Commits
f92a3dae70
...
feat/reest
| Author | SHA1 | Date | |
|---|---|---|---|
| 1475a65938 | |||
| 9a1d8279a1 | |||
| 0456a1063d | |||
| a41136a224 | |||
| 102c21927e | |||
| 566e23ad34 | |||
| 872c495d30 | |||
| 7951f9d8c5 | |||
| 4894543c57 | |||
| efe7faa65f | |||
| c9d66ce2e5 | |||
| f7a29ad510 | |||
| e7a47f56f8 | |||
| 214d17cf98 | |||
| 8c890d76e0 | |||
| 7105b286bf | |||
| 0e884f20c5 | |||
| 8bb8399ec5 | |||
| 9462e25a20 | |||
| daac6f3f6d | |||
| 6d264a8214 | |||
| 4cf93ff1f4 | |||
| d25b8b0441 | |||
| bec6405c54 | |||
| 53502d927b | |||
| 6e2b3d72f1 | |||
| 0c5c3f935b | |||
| 8da08b6bf1 | |||
| 1fe8f2b6a8 | |||
| 78580df13b | |||
| ff82d0c364 | |||
| 14b188d3ca | |||
| 3fccdc0478 | |||
| d491100c73 | |||
| ce2cd6b397 | |||
| f2b3010ac9 | |||
| c49c0bbc0a | |||
| 101758da24 | |||
| e03d5f5e36 | |||
| b3ca317e5e | |||
| e12d0ad8b1 | |||
| 4be34e8d6a | |||
| da4cf5a5e0 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,6 +4,7 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
count.txt
|
||||
.env
|
||||
.env*
|
||||
.nitro
|
||||
.tanstack
|
||||
.cta.json
|
||||
|
||||
22
.vscode/launch.json
vendored
Normal file
22
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
// 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
|
||||
"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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
205
bun.lock
205
bun.lock
@@ -32,10 +32,13 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"gsap": "^3.13.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lucide-react": "^0.540.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.1.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
@@ -465,22 +468,42 @@
|
||||
|
||||
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
|
||||
|
||||
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
|
||||
|
||||
"@types/phoenix": ["@types/phoenix@1.6.6", "", {}, "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A=="],
|
||||
|
||||
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
|
||||
@@ -519,6 +542,10 @@
|
||||
|
||||
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.10", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
@@ -531,8 +558,20 @@
|
||||
|
||||
"canvas-confetti": ["canvas-confetti@1.9.3", "", {}, "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g=="],
|
||||
|
||||
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@5.3.1", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-48af6xm9gQK8rhIcOxWwdGzIervm8BVTin+yRp9HEvU20BtVZ2lBywlIJBzwaDtvo0FvjeL7QdCADoUoqIbV3A=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
|
||||
|
||||
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
@@ -545,10 +584,16 @@
|
||||
|
||||
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
|
||||
|
||||
"core-js": ["core-js@3.46.0", "", {}, "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA=="],
|
||||
|
||||
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
|
||||
|
||||
"cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
@@ -583,6 +628,8 @@
|
||||
|
||||
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
@@ -591,10 +638,14 @@
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"dompurify": ["dompurify@3.3.0", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.204", "", {}, "sha512-s9VbBXWxfDrl67PlO4avwh0/GU2vcwx8Fph3wlR8LJl7ySGYId59EFE17VWVcuC3sLWNPENm6Z/uGqKbkPCcXA=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||
@@ -611,14 +662,22 @@
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||
|
||||
"expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
@@ -637,8 +696,16 @@
|
||||
|
||||
"gsap": ["gsap@3.13.0", "", {}, "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw=="],
|
||||
|
||||
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
@@ -647,16 +714,30 @@
|
||||
|
||||
"immer": ["immer@10.1.1", "", {}, "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
|
||||
|
||||
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||
|
||||
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||
|
||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
|
||||
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"isbot": ["isbot@5.1.30", "", {}, "sha512-3wVJEonAns1OETX83uWsk5IAne2S5zfDcntD2hbtU23LelSqNXzXs9zKjMPOLMzroCgIjCfjYAEHrd2D6FOkiA=="],
|
||||
@@ -671,6 +752,10 @@
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jspdf": ["jspdf@3.0.3", "", { "dependencies": { "@babel/runtime": "^7.26.9", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.2.4", "html2canvas": "^1.0.0-rc.5" } }, "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ=="],
|
||||
|
||||
"jspdf-autotable": ["jspdf-autotable@5.0.2", "", { "peerDependencies": { "jspdf": "^2 || ^3" } }, "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||
@@ -693,6 +778,8 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"loupe": ["loupe@3.2.0", "", {}, "sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
@@ -703,6 +790,64 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
|
||||
|
||||
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
|
||||
|
||||
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
|
||||
|
||||
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
|
||||
|
||||
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="],
|
||||
|
||||
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
|
||||
|
||||
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
||||
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||
|
||||
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||
|
||||
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
|
||||
|
||||
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
|
||||
|
||||
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
|
||||
|
||||
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
|
||||
|
||||
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
|
||||
|
||||
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
|
||||
|
||||
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
|
||||
|
||||
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
|
||||
|
||||
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
|
||||
@@ -721,12 +866,18 @@
|
||||
|
||||
"nwsapi": ["nwsapi@2.2.21", "", {}, "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA=="],
|
||||
|
||||
"pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
|
||||
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
@@ -737,14 +888,20 @@
|
||||
|
||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
|
||||
|
||||
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="],
|
||||
|
||||
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
|
||||
|
||||
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
@@ -765,10 +922,18 @@
|
||||
|
||||
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||
|
||||
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
||||
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
|
||||
|
||||
"rollup": ["rollup@4.46.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.46.3", "@rollup/rollup-android-arm64": "4.46.3", "@rollup/rollup-darwin-arm64": "4.46.3", "@rollup/rollup-darwin-x64": "4.46.3", "@rollup/rollup-freebsd-arm64": "4.46.3", "@rollup/rollup-freebsd-x64": "4.46.3", "@rollup/rollup-linux-arm-gnueabihf": "4.46.3", "@rollup/rollup-linux-arm-musleabihf": "4.46.3", "@rollup/rollup-linux-arm64-gnu": "4.46.3", "@rollup/rollup-linux-arm64-musl": "4.46.3", "@rollup/rollup-linux-loongarch64-gnu": "4.46.3", "@rollup/rollup-linux-ppc64-gnu": "4.46.3", "@rollup/rollup-linux-riscv64-gnu": "4.46.3", "@rollup/rollup-linux-riscv64-musl": "4.46.3", "@rollup/rollup-linux-s390x-gnu": "4.46.3", "@rollup/rollup-linux-x64-gnu": "4.46.3", "@rollup/rollup-linux-x64-musl": "4.46.3", "@rollup/rollup-win32-arm64-msvc": "4.46.3", "@rollup/rollup-win32-ia32-msvc": "4.46.3", "@rollup/rollup-win32-x64-msvc": "4.46.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RZn2XTjXb8t5g13f5YclGoilU/kwT696DIkY3sywjdZidNSi3+vseaQov7D7BZXVJCPv3pDWUN69C78GGbXsKw=="],
|
||||
|
||||
"rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
|
||||
@@ -795,12 +960,24 @@
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
|
||||
|
||||
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-literal": ["strip-literal@3.0.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.18", "", { "dependencies": { "style-to-object": "1.0.11" } }, "sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.11", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow=="],
|
||||
|
||||
"svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
|
||||
@@ -811,6 +988,8 @@
|
||||
|
||||
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
|
||||
|
||||
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
|
||||
@@ -837,6 +1016,10 @@
|
||||
|
||||
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.20.4", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg=="],
|
||||
@@ -847,6 +1030,18 @@
|
||||
|
||||
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"unplugin": ["unplugin@2.3.6", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-+/MdXl8bLTXI2lJF22gUBeCFqZruEpL/oM9f8wxCuKh9+Mw9qeul3gTqgbKpMeOFlusCzc0s7x2Kax2xKW+FQg=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
|
||||
@@ -857,8 +1052,14 @@
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
|
||||
|
||||
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
|
||||
|
||||
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||
|
||||
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
|
||||
@@ -893,6 +1094,8 @@
|
||||
|
||||
"zod": ["zod@4.0.17", "", {}, "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"@supabase/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
@@ -921,6 +1124,8 @@
|
||||
|
||||
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
5833
package-lock.json
generated
Normal file
5833
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,14 +34,18 @@
|
||||
"@tanstack/router-plugin": "^1.121.2",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"carbone-sdk-js": "^1.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"gsap": "^3.13.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lucide-react": "^0.540.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.1.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
@@ -62,4 +66,4 @@
|
||||
"vitest": "^3.0.5",
|
||||
"web-vitals": "^4.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,26 +10,29 @@ export interface SupabaseAuthState {
|
||||
isAuthenticated: boolean
|
||||
user: User | null
|
||||
claims: UserClaims | null
|
||||
roles: RolCatalogo[] | null
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
type Role =
|
||||
| 'lci'
|
||||
| 'vicerrectoria'
|
||||
| 'director_facultad' // 👈 NEW
|
||||
| 'secretario_academico'
|
||||
| 'jefe_carrera'
|
||||
| 'planeacion'
|
||||
export interface RolCatalogo {
|
||||
id: string
|
||||
nombre: string
|
||||
icono: string
|
||||
nombre_clase: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type UserClaims = {
|
||||
claims_admin: boolean
|
||||
clave: string
|
||||
export type Role = string;
|
||||
|
||||
export type UserClaims = {
|
||||
id: string | null
|
||||
clave?: string
|
||||
nombre: string
|
||||
apellidos: string
|
||||
title: string
|
||||
avatar: string | null
|
||||
title?: string
|
||||
avatar?: string | null
|
||||
carrera_id?: string | null
|
||||
facultad_id?: string | null
|
||||
facultad_color?: string | null // 🎨 NEW
|
||||
@@ -41,28 +44,35 @@ const SupabaseAuthContext = createContext<SupabaseAuthState | undefined>(undefin
|
||||
export function SupabaseAuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [claims, setClaims] = useState<UserClaims | null>(null)
|
||||
const [roles, setRoles] = useState<RolCatalogo[] | null>(null)
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Función para manejar la sesión
|
||||
const handleSession = async (session: Session | null) => {
|
||||
const u = session?.user ?? null
|
||||
setUser(u)
|
||||
setIsAuthenticated(!!u)
|
||||
setClaims(await buildClaims(session))
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
// Carga inicial
|
||||
supabase.auth.getSession().then(async ({ data: { session } }) => {
|
||||
const u = session?.user ?? null
|
||||
setUser(u)
|
||||
setIsAuthenticated(!!u)
|
||||
setClaims(await buildClaims(session))
|
||||
setIsLoading(false)
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
handleSession(session)
|
||||
})
|
||||
|
||||
// Carga roles catálogo
|
||||
fetchRoles().then(fetchedRoles => {
|
||||
setRoles(fetchedRoles);
|
||||
});
|
||||
|
||||
// Suscripción a cambios de sesión
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => {
|
||||
const u = session?.user ?? null
|
||||
setUser(u)
|
||||
setIsAuthenticated(!!u)
|
||||
setClaims(await buildClaims(session))
|
||||
setIsLoading(false)
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
handleSession(session)
|
||||
})
|
||||
|
||||
|
||||
return () => subscription.unsubscribe()
|
||||
}, [])
|
||||
|
||||
@@ -80,7 +90,7 @@ export function SupabaseAuthProvider({ children }: { children: React.ReactNode }
|
||||
|
||||
return (
|
||||
<SupabaseAuthContext.Provider
|
||||
value={{ isAuthenticated, user, claims, login, logout, isLoading }}
|
||||
value={{ isAuthenticated, user, claims, roles, login, logout, isLoading }}
|
||||
>
|
||||
{children}
|
||||
</SupabaseAuthContext.Provider>
|
||||
@@ -99,49 +109,54 @@ export function useSupabaseAuth() {
|
||||
* Helpers
|
||||
* ===================== */
|
||||
|
||||
// Unifica extracción de metadatos y resuelve facultad_color si hay facultad_id
|
||||
// Obtiene los claims del usuario desde la base de datos a partir de una función en la BDD
|
||||
async function buildClaims(session: Session | null): Promise<UserClaims | null> {
|
||||
const u = session?.user
|
||||
if (!u) return null
|
||||
|
||||
const app = (u.app_metadata ?? {}) as Partial<UserClaims> & { role?: Role }
|
||||
const meta = (u.user_metadata ?? {}) as Partial<UserClaims>
|
||||
|
||||
// Mezcla segura: app_metadata > user_metadata (para campos de claims)
|
||||
const base: Partial<UserClaims> = {
|
||||
claims_admin: !!(app.claims_admin ?? (meta as any).claims_admin),
|
||||
role: (app.role as Role | undefined) ?? ('lci' as Role),
|
||||
facultad_id: app.facultad_id ?? meta.facultad_id ?? null,
|
||||
carrera_id: app.carrera_id ?? meta.carrera_id ?? null,
|
||||
clave: (meta.clave as string) ?? '',
|
||||
nombre: (meta.nombre as string) ?? '',
|
||||
apellidos: (meta.apellidos as string) ?? '',
|
||||
title: (meta.title as string) ?? '',
|
||||
avatar: (meta.avatar as string) ?? null,
|
||||
// Validar sesión
|
||||
if (!session || !session.user) {
|
||||
console.warn('No session or user found');
|
||||
return null;
|
||||
}
|
||||
const u = session.user;
|
||||
|
||||
let facultad_color: string | null = null
|
||||
if (base.facultad_id) {
|
||||
// Lee color desde public.facultades
|
||||
const { data, error } = await supabase
|
||||
.from('facultades')
|
||||
.select('color')
|
||||
.eq('id', base.facultad_id)
|
||||
.maybeSingle()
|
||||
|
||||
if (!error && data) facultad_color = (data as any)?.color ?? null
|
||||
}
|
||||
try{
|
||||
const result = await supabase.rpc('obtener_claims_usuario', {
|
||||
p_user_id: u.id,
|
||||
});
|
||||
|
||||
const data: UserClaims[] | null = result.data;
|
||||
const error = result.error;
|
||||
|
||||
return {
|
||||
claims_admin: !!base.claims_admin,
|
||||
role: (base.role ?? 'lci') as Role,
|
||||
clave: base.clave ?? '',
|
||||
nombre: base.nombre ?? '',
|
||||
apellidos: base.apellidos ?? '',
|
||||
title: base.title ?? '',
|
||||
avatar: base.avatar ?? null,
|
||||
facultad_id: (base.facultad_id as string | null) ?? null,
|
||||
carrera_id: (base.carrera_id as string | null) ?? null,
|
||||
facultad_color, // 🎨
|
||||
if (error) {
|
||||
console.error('Error al obtener la información:', error);
|
||||
throw new Error('Error al obtener la información del usuario');
|
||||
}
|
||||
|
||||
console.log(data);
|
||||
if (!data || data.length === 0) {
|
||||
console.warn('No se encontró información para el usuario');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...data[0],
|
||||
id: null
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Error inesperado:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRoles(): Promise<RolCatalogo[]> {
|
||||
const { data, error } = await supabase
|
||||
.from("roles_catalogo")
|
||||
.select("id, nombre, icono, nombre_clase, label");
|
||||
|
||||
if (error) {
|
||||
console.error("Error al obtener los roles:", error.message);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data || [];
|
||||
}
|
||||
|
||||
585
src/components/ai/AIChatModal.jsx
Normal file
585
src/components/ai/AIChatModal.jsx
Normal file
@@ -0,0 +1,585 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { supabase } from "@/auth/supabase";
|
||||
import ReactMarkdown from "react-markdown"
|
||||
|
||||
/* ---------- UI Mocks (sin cambios) ---------- */
|
||||
const Paperclip = (props) => (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Dialog = ({ open, onOpenChange, children }) =>
|
||||
open ? <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onOpenChange}>{children}</div> : null;
|
||||
const DialogContent = ({ className, children }) =>
|
||||
<div className={`bg-white rounded-xl shadow-2xl transform transition-all max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}>{children}</div>;
|
||||
const DialogHeader = ({ children }) => <div className="pb-4 border-b border-gray-200">{children}</div>;
|
||||
const DialogTitle = ({ className, children }) => <h2 className={`text-xl font-bold ${className}`}>{children}</h2>;
|
||||
const Button = ({ onClick, disabled, className, variant, children }) => (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
${variant === "outline"
|
||||
? "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700"}
|
||||
${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
const Card = ({ className, children }) => <div className={`bg-white rounded-2xl shadow-md ${className}`}>{children}</div>;
|
||||
const CardContent = ({ className, children }) => <div className={`p-4 ${className}`}>{children}</div>;
|
||||
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
|
||||
|
||||
/* ------------- COMPONENT ------------- */
|
||||
export default function AIChatModal({ open, onClose, context, onAccept }) {
|
||||
const [vectorStores, setVectorStores] = useState([]);
|
||||
const [vectorFiles, setVectorFiles] = useState([]);
|
||||
const [selectedVectorFile, setSelectedVectorFile] = useState(null);
|
||||
|
||||
const [attachedFiles, setAttachedFiles] = useState([]);
|
||||
const [attachedPreviews, setAttachedPreviews] = useState([]);
|
||||
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||
const [loadingVectors, setLoadingVectors] = useState(false);
|
||||
|
||||
const [conversationId, setConversationId] = useState(null);
|
||||
const [creatingConversation, setCreatingConversation] = useState(false); // control para esperar
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
useEffect(scrollToBottom, [messages]);
|
||||
|
||||
const normalizeInvokeResponse = (resp) => {
|
||||
if (!resp) return null;
|
||||
|
||||
// cuando invocas funciones, Supabase siempre regresa:
|
||||
// { data: "...string...", error: null, response: {} }
|
||||
const raw = resp.data;
|
||||
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
console.warn("❗ No se pudo parsear resp.data:", raw);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// si ya viene como objeto
|
||||
if (typeof raw === "object" && raw !== null) return raw;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
// Al abrir: reset o crear conversación
|
||||
useEffect(() => {
|
||||
console.log(context.cont_conversation);
|
||||
console.log(context);
|
||||
|
||||
if (!open) {
|
||||
// si ya existe una conversación la eliminamos
|
||||
if (conversationId) {
|
||||
deleteConversation(conversationId).catch((e) => console.error(e));
|
||||
}
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
setSelectedVectorFile(null);
|
||||
setAttachedFiles([]);
|
||||
setAttachedPreviews([]);
|
||||
setConversationId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// inyectar contexto como system message
|
||||
if (context) {
|
||||
setMessages([
|
||||
{
|
||||
role: "system",
|
||||
content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}`
|
||||
}
|
||||
]);
|
||||
} else {
|
||||
setMessages(prev => prev); // no hacer nada si no hay contexto
|
||||
}
|
||||
|
||||
// crear conversación y esperar a que termine antes de permitir enviar
|
||||
(async () => {
|
||||
await createConversation();
|
||||
// tras crear podemos también cargar vector stores
|
||||
fetchVectorStores();
|
||||
})();
|
||||
|
||||
}, [open]);
|
||||
|
||||
// --------- CREATE CONVERSATION (robusto) ----------
|
||||
const createConversation = async () => {
|
||||
try {
|
||||
setCreatingConversation(true);
|
||||
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
// llamada
|
||||
const resp = await supabase.functions.invoke("modal-conversation", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: { action: "start" , role:"system", content:context.cont_conversation, }
|
||||
});
|
||||
|
||||
console.log("createConversation -> raw resp:", resp);
|
||||
|
||||
// resp puede ser { data: "...json string..." } o { data: { ... } }
|
||||
let parsed = null;
|
||||
|
||||
if (typeof resp?.data === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(resp.data);
|
||||
} catch (e) {
|
||||
console.warn("No se pudo parsear resp.data como JSON:", e, resp.data);
|
||||
parsed = null;
|
||||
}
|
||||
} else if (typeof resp?.data === "object" && resp.data !== null) {
|
||||
parsed = resp.data;
|
||||
} else {
|
||||
// fallback: quizá la respuesta viene en resp (sin data)
|
||||
parsed = resp;
|
||||
}
|
||||
|
||||
console.log("createConversation -> parsed payload:", parsed);
|
||||
|
||||
// buscar el id en varios lugares (robusto)
|
||||
const convId =
|
||||
parsed?.conversationId ||
|
||||
parsed?.data?.conversationId ||
|
||||
parsed?.data?.id ||
|
||||
parsed?.id ||
|
||||
parsed?.conversation_id ||
|
||||
parsed?.data?.conversation_id;
|
||||
|
||||
if (!convId) {
|
||||
console.warn("No se encontró conversationId en la respuesta parseada:", parsed);
|
||||
setCreatingConversation(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setConversationId(convId);
|
||||
console.log("🟢 Conversación creada y guardada:", convId);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error creando conversación:", err);
|
||||
} finally {
|
||||
setCreatingConversation(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --------- DELETE CONVERSATION (robusto) ----------
|
||||
const deleteConversation = async (convIdParam) => {
|
||||
try {
|
||||
const convIdToUse = convIdParam ?? conversationId;
|
||||
if (!convIdToUse) return;
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
// algunas implementations esperan { action: "end", conversationId }, otras { action: "end", id }
|
||||
const { data, error } = await supabase.functions.invoke("modal-conversation", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: { action: "end", conversationId: convIdToUse }
|
||||
});
|
||||
|
||||
console.log("deleteConversation -> response:", data);
|
||||
setConversationId(null);
|
||||
} catch (err) {
|
||||
console.error("Error eliminando conversación:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- CONVERT FILE TO BASE64 ----------
|
||||
const fileToBase64 = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = (e) => reject(e);
|
||||
reader.onload = () => resolve(reader.result.split(",")[1]);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// ---------- SEND MESSAGE (usa conversationId) ----------
|
||||
const handleConversation = async ({ text }) => {
|
||||
if (!conversationId) {
|
||||
console.warn("No hay conversación activa todavía. conversationId:", conversationId);
|
||||
// si no hay conv, opcionalmente intentar crear una sin que el usuario note
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
let filesInput = [];
|
||||
|
||||
if (attachedFiles.length > 0) {
|
||||
for (const file of attachedFiles) {
|
||||
const base64 = await fileToBase64(file);
|
||||
filesInput.push({
|
||||
type: "input_file",
|
||||
filename: file.name,
|
||||
file_data: `data:${file.type};base64,${base64}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedVectorFile) {
|
||||
// si el archivo del vector viene sólo con id
|
||||
filesInput.push({
|
||||
type: "input_file",
|
||||
file_id: selectedVectorFile.id
|
||||
});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
action: "message",
|
||||
conversationId,
|
||||
vectorStoreId: selectedVectorFile?.vector_store_id ?? null,
|
||||
fileIds: selectedVectorFile ? [selectedVectorFile.id] : [],
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text },
|
||||
...filesInput
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
const { data: invokeData, error } = await supabase.functions.invoke(
|
||||
"modal-conversation",
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: payload
|
||||
}
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
console.log("handleConversation -> RAW invokeData:", invokeData);
|
||||
|
||||
const parsed = normalizeInvokeResponse({ data: invokeData });
|
||||
console.log("handleConversation -> PARSED:", parsed);
|
||||
|
||||
// 🔥 EXTRACTOR DEFINITIVO
|
||||
let assistantText = null;
|
||||
|
||||
// 1) directo
|
||||
if (parsed?.data?.output_text) {
|
||||
assistantText = parsed.data.output_text;
|
||||
}
|
||||
|
||||
// 2) buscar el message
|
||||
if (!assistantText && Array.isArray(parsed?.data?.output)) {
|
||||
const msgBlock = parsed.data.output.find(o => o.type === "message");
|
||||
if (msgBlock?.content?.[0]?.text) {
|
||||
assistantText = msgBlock.content[0].text;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) fallback
|
||||
assistantText = assistantText || "Sin respuesta del modelo.";
|
||||
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ role: "assistant", content: cleanAssistantResponse(assistantText) }
|
||||
]);
|
||||
|
||||
|
||||
setAttachedFiles([]);
|
||||
setAttachedPreviews([]);
|
||||
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error en handleConversation:", err);
|
||||
setMessages(prev => [...prev, { role: "assistant", content: "Ocurrió un error al procesar tu mensaje." }]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- VECTORES ----------
|
||||
const fetchVectorStores = async () => {
|
||||
try {
|
||||
setLoadingVectors(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: { module: "vectorStores", action: "list" }
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
setVectorStores(Array.isArray(data) ? data : (data?.data ?? []));
|
||||
} catch (err) {
|
||||
console.error("Error loading vector stores:", err);
|
||||
setVectorStores([]);
|
||||
} finally {
|
||||
setLoadingVectors(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) fetchVectorStores();
|
||||
}, [open]);
|
||||
|
||||
const loadFilesForVector = async (vectorStoreId) => {
|
||||
try {
|
||||
setLoadingFiles(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: { module: "vectorStoreFiles", action: "list", params: { vector_store_id: vectorStoreId } }
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
setVectorFiles(Array.isArray(data) ? data : (data?.data ?? []));
|
||||
} catch (err) {
|
||||
console.error("Error loading vector files:", err);
|
||||
setVectorFiles([]);
|
||||
} finally {
|
||||
setLoadingFiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- UI helpers ----------
|
||||
const handleAttach = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (!files.length) return;
|
||||
|
||||
setAttachedFiles(prev => [...prev, ...files]);
|
||||
setAttachedPreviews(prev => [...prev, ...files.map(f => f.name)]);
|
||||
};
|
||||
|
||||
|
||||
const handleSelectVectorFile = (file) => {
|
||||
setSelectedVectorFile(file);
|
||||
};
|
||||
|
||||
// ---------- Send flow ----------
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile) return;
|
||||
|
||||
// esperar si aún se está creando la conversación
|
||||
if (creatingConversation) {
|
||||
console.log("Esperando a que se cree la conversación...");
|
||||
// opcional: podrías mostrar un toast; aquí simplemente retornamos
|
||||
return;
|
||||
}
|
||||
|
||||
if (!conversationId) {
|
||||
console.warn("No hay conversationId — intentaremos crear una ahora.");
|
||||
await createConversation();
|
||||
if (!conversationId) {
|
||||
setMessages(prev => [...prev, { role: "assistant", content: "No se pudo crear la conversación. Intenta de nuevo." }]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const userText = input.trim() || (selectedVectorFile ? `Consultar archivo vector: ${selectedVectorFile.filename || selectedVectorFile.id}` : "");
|
||||
setMessages(prev => [...prev, { role: "user", content: userText }]);
|
||||
setInput("");
|
||||
|
||||
await handleConversation({ text: userText });
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const last = [...messages].reverse().find(m => m.role === "assistant");
|
||||
if (last && onAccept) {
|
||||
onAccept(last.content);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const cleanAssistantResponse = (text) => {
|
||||
if (!text) return text;
|
||||
|
||||
// Frases que quieres eliminar (puedes agregar más)
|
||||
const patterns = [
|
||||
/^claro[, ]*/i,
|
||||
/^por supuesto[, ]*/i,
|
||||
/^aquí tienes[, ]*/i,
|
||||
/^con gusto[, ]*/i,
|
||||
/^hola[, ]*/i,
|
||||
/^perfecto[, ]*/i,
|
||||
/^entendido[, ]*/i,
|
||||
/^muy bien[, ]*/i,
|
||||
/^ok[, ]*/i,
|
||||
];
|
||||
|
||||
let cleaned = text.trim();
|
||||
|
||||
for (const p of patterns) {
|
||||
cleaned = cleaned.replace(p, "").trim();
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col relative">
|
||||
{/* Botón siempre visible */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 transition z-50"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Asistente Inteligente</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 pt-4 min-h-0">
|
||||
<div className="flex gap-6 h-full min-h-0">
|
||||
|
||||
{/* Left: vectors */}
|
||||
<Card className="w-1/3 min-w-[250px] max-w-sm flex flex-col bg-muted/20 border border-gray-200 rounded-2xl">
|
||||
<CardContent className="flex flex-col flex-1 p-4">
|
||||
<h3 className="font-semibold text-sm mb-3">Vector Stores</h3>
|
||||
<ScrollArea className="flex-1">
|
||||
{loadingVectors ? (
|
||||
<p className="text-gray-500 text-sm text-center mt-10">Cargando vector stores...</p>
|
||||
) : vectorStores.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm text-center mt-10">No hay vector stores.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{vectorStores.map((vector) => (
|
||||
<li key={vector.id}
|
||||
onClick={() => loadFilesForVector(vector.id)}
|
||||
className="border cursor-pointer hover:bg-blue-50 p-2 rounded-lg bg-white"
|
||||
>
|
||||
<strong className="truncate">{vector.name || vector.id}</strong>
|
||||
<p className="text-xs text-gray-400 truncate">{vector.description || vector.id}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="font-semibold text-sm mb-2">Archivos del Vector</h4>
|
||||
{loadingFiles ? (
|
||||
<p className="text-sm text-gray-500">Cargando archivos...</p>
|
||||
) : selectedVectorFile ? (
|
||||
<div className="text-sm text-gray-700 mb-2">
|
||||
Seleccionado: <strong>{selectedVectorFile.filename ?? selectedVectorFile.id}</strong>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Selecciona un archivo del vector</p>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2 max-h-40 overflow-auto mt-2">
|
||||
{vectorFiles.map((file) => (
|
||||
<li key={file.id}
|
||||
onClick={() => handleSelectVectorFile(file)}
|
||||
className={`p-2 rounded-lg cursor-pointer border ${selectedVectorFile?.id === file.id ? "bg-blue-50 border-blue-300" : "bg-white"}`}
|
||||
>
|
||||
<div className="text-sm font-medium">{file.filename}</div>
|
||||
<div className="text-xs text-gray-400">{file.id}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex-shrink-0">
|
||||
<Button variant="outline" className="w-full" onClick={() => alert("Funcionalidad Subir a vector store no implementada aquí")}>Subir archivo (vector)</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Right: Chat */}
|
||||
<Card className="flex-1 flex flex-col min-w-[350px] bg-background border border-gray-200 rounded-2xl">
|
||||
<CardContent className="flex flex-col flex-1 p-4 min-h-0">
|
||||
|
||||
<h3 className="font-semibold text-sm mb-3 flex-shrink-0">Chat con IA</h3>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* CONTENEDOR SCROLL DE LOS MENSAJES */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 border border-gray-200 rounded-lg p-3 space-y-3 bg-gray-50 break-words whitespace-pre-wrap">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm text-center mt-10">Inicia una conversación...</p>
|
||||
) : (
|
||||
messages.map((m, i) => (
|
||||
<div key={i} className={`break-words whitespace-pre-wrap p-3 rounded-xl shadow-sm max-w-[85%] ${m.role === "user" ? "bg-blue-50 text-blue-800 ml-auto" : m.role === "assistant" ? "bg-white text-gray-800 mr-auto border border-gray-200" : "bg-gray-100 text-gray-700 mr-auto"}`}>
|
||||
<strong className="font-bold">{m.role === "user" ? "Tú:" : m.role === "assistant" ? "IA:" : "Sistema:"}</strong>{" "}
|
||||
<ReactMarkdown>{m.content}</ReactMarkdown>
|
||||
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2 p-3 bg-white border border-gray-200 rounded-xl mr-auto max-w-fit shadow-sm flex-shrink-0">
|
||||
<svg className="animate-spin h-4 w-4 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">La IA está respondiendo...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{attachedPreviews.length > 0 && (
|
||||
<ul className="text-xs text-gray-600 mt-2">
|
||||
{attachedPreviews.map((name, i) => (
|
||||
<li key={i}>📄 {name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-4 items-end flex-shrink-0">
|
||||
<label className="cursor-pointer text-gray-600 hover:text-blue-600 self-center">
|
||||
<Paperclip className="w-5 h-5" />
|
||||
<input type="file" accept=".pdf,.txt,.doc,.docx" multiple className="hidden" onChange={handleAttach} />
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Escribe tu pregunta..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border border-gray-300 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-32 overflow-y-auto bg-white shadow-inner"
|
||||
style={{ minHeight: "38px" }}
|
||||
/>
|
||||
|
||||
<Button onClick={handleSend} disabled={loading || creatingConversation || (!input.trim() && attachedFiles.length === 0 && !selectedVectorFile)} className="shadow-md">
|
||||
{creatingConversation ? "Preparando..." : loading ? "Enviando..." : "Enviar"}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleApply} disabled={!messages.some((m) => m.role === "assistant")} className="shadow-md">
|
||||
Aplicar mejora
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import * as Icons from "lucide-react"
|
||||
import type { RefRow } from "@/types/RefRow"
|
||||
|
||||
// POST -> recibe blob PDF y (opcional) Content-Disposition
|
||||
async function fetchPdfBlob(url: string, body: { s3_file_path: string }) {
|
||||
async function fetchPdfBlob(url: string, body: { documentos_id: string }) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -35,6 +35,7 @@ export function DetailDialog({
|
||||
onClose: () => void
|
||||
pdfUrl?: string
|
||||
}) {
|
||||
console.log("DetailDialog render", { row })
|
||||
const [viewerUrl, setViewerUrl] = useState<string | null>(null)
|
||||
const [currentBlob, setCurrentBlob] = useState<Blob | null>(null)
|
||||
const [filename, setFilename] = useState<string>("archivo.pdf")
|
||||
@@ -48,13 +49,15 @@ export function DetailDialog({
|
||||
const ctrl = new AbortController()
|
||||
|
||||
async function load() {
|
||||
if (!row?.s3_file_path) {
|
||||
console.log(row)
|
||||
if (!row?.documentos_id) {
|
||||
setViewerUrl(null)
|
||||
setCurrentBlob(null)
|
||||
console.warn("No hay documentos_id en el row")
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { blob, disposition } = await fetchPdfBlob(`${import.meta.env.VITE_BACK_ORIGIN}/api/get/documento`, { s3_file_path: row.s3_file_path })
|
||||
const { blob, disposition } = await fetchPdfBlob(`${import.meta.env.VITE_BACK_ORIGIN}/api/get/documento`, { documentos_id: row.documentos_id })
|
||||
if (ctrl.signal.aborted) return
|
||||
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
|
||||
setFilename(name)
|
||||
@@ -94,8 +97,8 @@ export function DetailDialog({
|
||||
}
|
||||
|
||||
// Si no, vuelve a pedirlo (p. ej., si el user abre y descarga sin render previo)
|
||||
if (!row?.s3_file_path) throw new Error("No hay contenido para descargar.")
|
||||
const { blob, disposition } = await fetchPdfBlob(`${import.meta.env.VITE_BACK_ORIGIN}/api/get/documento`, { s3_file_path: row.s3_file_path })
|
||||
if (!row?.documentos_id) throw new Error("No hay contenido para descargar.")
|
||||
const { blob, disposition } = await fetchPdfBlob(`${import.meta.env.VITE_BACK_ORIGIN}/api/get/documento`, { documentos_id: row.documentos_id })
|
||||
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
|
||||
const link = document.createElement("a")
|
||||
const href = URL.createObjectURL(blob)
|
||||
@@ -111,7 +114,7 @@ export function DetailDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogContent className="max-w-fit">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono">{row?.titulo_archivo ?? "(Sin título)"}</DialogTitle>
|
||||
<DialogDescription>{row?.descripcion || "Sin descripción"}</DialogDescription>
|
||||
@@ -131,13 +134,13 @@ export function DetailDialog({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags
|
||||
{row.tags?.length ? (
|
||||
<div className="text-xs text-neutral-600">
|
||||
<span className="font-medium">Tags: </span>
|
||||
{row.tags.join(", ")}
|
||||
</div>
|
||||
) : null}
|
||||
) : null} */}
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-neutral-600">Instrucciones</Label>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import { supabase,useSupabaseAuth } from "@/auth/supabase"
|
||||
import { Button } from "../ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -35,11 +35,13 @@ export function EditBibliografiaButton({
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [text, setText] = useState("")
|
||||
const auth = useSupabaseAuth()
|
||||
|
||||
const initialTextRef = useRef("")
|
||||
const lines = useMemo(() => parseLines(text), [text])
|
||||
const dirty = useMemo(() => initialTextRef.current !== text, [text])
|
||||
|
||||
// 🔹 Abre el editor y carga los valores actuales
|
||||
function openEditor() {
|
||||
const start = (value ?? []).join("\n")
|
||||
setText(start)
|
||||
@@ -47,52 +49,110 @@ export function EditBibliografiaButton({
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
// ✅ Función para generar diferencias tipo JSON Patch
|
||||
function generateDiff(oldRefs: string[], newRefs: string[]) {
|
||||
const changes: any[] = []
|
||||
|
||||
// Si son distintos en contenido o longitud
|
||||
if (JSON.stringify(oldRefs) !== JSON.stringify(newRefs)) {
|
||||
changes.push({
|
||||
op: "replace",
|
||||
path: "/bibliografia",
|
||||
from: oldRefs,
|
||||
value: newRefs,
|
||||
})
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
try {
|
||||
setSaving(true)
|
||||
const refs = parseLines(text)
|
||||
// 1️⃣ Obtener bibliografía anterior
|
||||
const { data: oldData, error: oldError } = await supabase
|
||||
.from("asignaturas")
|
||||
.select("bibliografia")
|
||||
.eq("id", asignaturaId)
|
||||
.maybeSingle()
|
||||
|
||||
if (oldError) throw oldError
|
||||
|
||||
const oldRefs = oldData?.bibliografia ?? []
|
||||
const newRefs = parseLines(text)
|
||||
|
||||
// 2️⃣ Generar diferencias
|
||||
const diff = generateDiff(oldRefs, newRefs)
|
||||
|
||||
// 3️⃣ Guardar respaldo si hay cambios
|
||||
if (diff.length > 0) {
|
||||
const { error: backupError } = await supabase
|
||||
.from("historico_cambios_asignaturas") // misma tabla de respaldo
|
||||
.insert({
|
||||
id_asignatura: asignaturaId,
|
||||
json_cambios: diff, // jsonb
|
||||
user_id: auth.user?.id,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (backupError) throw backupError
|
||||
}
|
||||
|
||||
// 4️⃣ Actualizar bibliografía en asignaturas
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.update({ bibliografia: refs })
|
||||
.update({ bibliografia: newRefs })
|
||||
.eq("id", asignaturaId)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
|
||||
onSaved((data as any)?.bibliografia ?? refs)
|
||||
initialTextRef.current = refs.join("\n")
|
||||
toast.success(`${refs.length} referencia(s) guardada(s).`)
|
||||
// 5️⃣ Refrescar estado local
|
||||
onSaved((data as any)?.bibliografia ?? newRefs)
|
||||
initialTextRef.current = newRefs.join("\n")
|
||||
toast.success(`${newRefs.length} referencia(s) guardada(s).`)
|
||||
setOpen(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "No se pudo guardar")
|
||||
} catch (err: any) {
|
||||
toast.error(err.message ?? "No se pudo guardar la bibliografía")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Acciones
|
||||
// 🔧 Acciones extra
|
||||
function actionTrim() {
|
||||
const next = parseLines(text).map((s) => s.replace(/\s+/g, " ").trim())
|
||||
setText(next.join("\n"))
|
||||
}
|
||||
|
||||
function actionDedupe() {
|
||||
const seen = new Set<string>()
|
||||
const next: string[] = []
|
||||
for (const l of parseLines(text)) {
|
||||
const k = l.toLowerCase()
|
||||
if (!seen.has(k)) { seen.add(k); next.push(l) }
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k)
|
||||
next.push(l)
|
||||
}
|
||||
}
|
||||
setText(next.join("\n"))
|
||||
}
|
||||
|
||||
function actionSort() {
|
||||
const next = [...parseLines(text)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
const next = [...parseLines(text)].sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { sensitivity: "base" }),
|
||||
)
|
||||
setText(next.join("\n"))
|
||||
}
|
||||
|
||||
async function actionImportClipboard() {
|
||||
try {
|
||||
const clip = await navigator.clipboard.readText()
|
||||
if (!clip) { toast("Portapapeles vacío"); return }
|
||||
if (!clip) {
|
||||
toast("Portapapeles vacío")
|
||||
return
|
||||
}
|
||||
const next = [...parseLines(text), ...parseLines(clip)]
|
||||
setText(next.join("\n"))
|
||||
toast.success("Texto importado")
|
||||
@@ -100,6 +160,7 @@ export function EditBibliografiaButton({
|
||||
toast.error(e?.message ?? "No se pudo leer el portapapeles")
|
||||
}
|
||||
}
|
||||
|
||||
async function actionExportClipboard() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(parseLines(text).join("\n"))
|
||||
@@ -109,7 +170,7 @@ export function EditBibliografiaButton({
|
||||
}
|
||||
}
|
||||
|
||||
// Atajo guardar
|
||||
// ⌨️ Atajo Ctrl+S
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (!open) return
|
||||
@@ -120,7 +181,6 @@ export function EditBibliografiaButton({
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, saving, dirty, text])
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useDeleteCarreraDialog(carreraId: string, onDeleted?: () => void
|
||||
|
||||
const dialog = (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent className="bg-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono" >¿Eliminar carrera?</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
92
src/components/historico/HistorialCambiosModal.tsx
Normal file
92
src/components/historico/HistorialCambiosModal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import { useSupabaseAuth } from "@/auth/supabase"
|
||||
|
||||
export function HistorialCambiosModal({
|
||||
open,
|
||||
onClose,
|
||||
planId,
|
||||
onRestore, // 🔥 recibiremos una función del padre para restaurar
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
planId: string
|
||||
onRestore: (key: string, value: string) => void
|
||||
}) {
|
||||
const auth = useSupabaseAuth()
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["historico_cambios", planId, auth.user?.id],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("historico_cambios")
|
||||
.select("id, json_cambios, user_id, created_at")
|
||||
.eq("id_plan_estudios", planId)
|
||||
.eq("user_id", auth.user?.id) // ✅ filtro por usuario actual
|
||||
.order("created_at", { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
enabled: !!auth.user?.id, // ✅ solo corre si hay usuario autenticado
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Histórico de cambios</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading && <p className="text-sm text-gray-500">Cargando historial...</p>}
|
||||
{error && <p className="text-red-500 text-sm">Error al cargar: {String(error)}</p>}
|
||||
{!isLoading && !error && (!data || data.length === 0) && (
|
||||
<p className="text-gray-500 text-sm">No hay cambios registrados.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
{data?.map((item) => {
|
||||
const diff = item.json_cambios?.[0]
|
||||
const key = diff?.path?.replace("/", "")
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border p-3 bg-white/70 dark:bg-neutral-900/50"
|
||||
>
|
||||
<div className="flex justify-between text-xs text-neutral-500 mb-2">
|
||||
<span>Usuario: {item.user_id || "Desconocido"}</span>
|
||||
<span>{new Date(item.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-700 font-mono whitespace-pre-wrap">
|
||||
<p><strong>Campo:</strong> {key}</p>
|
||||
<p><strong>Antes:</strong> {diff?.from || "—"}</p>
|
||||
<p><strong>Después:</strong> {diff?.value || "—"}</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
onClick={() => onRestore(key, diff.from)}
|
||||
>
|
||||
Restaurar
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-right">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cerrar
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -7,13 +7,16 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import { AuroraButton } from "@/components/effect/aurora-button"
|
||||
import confetti from "canvas-confetti"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||
import { Field } from "./Field"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
||||
import { asignaturaKeys } from "./planQueries"
|
||||
import { useRouter } from "@tanstack/react-router"
|
||||
|
||||
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const router = useRouter()
|
||||
const supabaseAuth = useSupabaseAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [mode, setMode] = useState<"manual" | "ia">("manual")
|
||||
@@ -42,32 +45,47 @@ export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdd
|
||||
horas_teoricas: toNum(f.horas_teoricas),
|
||||
horas_practicas: toNum(f.horas_practicas),
|
||||
objetivos: toNull(f.objetivos),
|
||||
contenidos: {}, bibliografia: [], criterios_evaluacion: null,
|
||||
contenidos: [], bibliografia: [], criterios_evaluacion: null,
|
||||
}
|
||||
const { error } = await supabase.from("asignaturas").insert([payload])
|
||||
const { error,data } = await supabase.from("asignaturas").insert([payload]).select().single()
|
||||
console.log(data);
|
||||
router.invalidate()
|
||||
router.navigate({
|
||||
to: "/asignatura/$asignaturaId",
|
||||
params: { asignaturaId: data.id },
|
||||
})
|
||||
setSaving(false)
|
||||
if (error) { alert(error.message); return }
|
||||
setOpen(false)
|
||||
onAdded?.()
|
||||
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
||||
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
||||
}
|
||||
|
||||
async function createWithAI() {
|
||||
if (!canIA) return
|
||||
setSaving(true)
|
||||
// inserte la asignatura generada directamente
|
||||
// obtengas el uuid que se insertó
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/generar/asignatura`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true }),
|
||||
body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true, uuid: supabaseAuth.user?.id }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const data = await res.json()
|
||||
console.log("Asignatura generada:", data)
|
||||
const asignaturaId = data.asignaturaId || data.insertResult?.id
|
||||
if (!asignaturaId) throw new Error("No se recibió el ID de la asignatura generada")
|
||||
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
|
||||
setOpen(false)
|
||||
router.invalidate()
|
||||
router.navigate({
|
||||
to: "/asignatura/$asignaturaId",
|
||||
params: { asignaturaId },
|
||||
})
|
||||
onAdded?.()
|
||||
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
||||
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
||||
// qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
||||
// qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Error al generar la asignatura")
|
||||
} finally { setSaving(false) }
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { useRouter } from "@tanstack/react-router"
|
||||
import { useSupabaseAuth } from "@/auth/supabase"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { postAPI } from "@/lib/api"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import { DetailDialog } from "@/components/archivos/DetailDialog"
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { useSupabaseAuth } from "@/auth/supabase";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
CarreraCombobox,
|
||||
FacultadCombobox,
|
||||
} from "@/components/users/procedencia-combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { postAPI } from "@/lib/api";
|
||||
import { supabase } from "@/auth/supabase";
|
||||
import { DetailDialog } from "@/components/archivos/DetailDialog";
|
||||
import type { RefRow } from "@/types/RefRow";
|
||||
|
||||
// ————————————————————————————————————————————————————————————————
|
||||
@@ -50,62 +59,78 @@ function extIcon(ext: string) {
|
||||
// ————————————————————————————————————————————————————————————————
|
||||
// Componente principal
|
||||
// ————————————————————————————————————————————————————————————————
|
||||
export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||
const router = useRouter()
|
||||
const auth = useSupabaseAuth()
|
||||
const role = auth.claims?.role
|
||||
export function CreatePlanDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const auth = useSupabaseAuth();
|
||||
const role = auth.claims?.role;
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const [facultadId, setFacultadId] = useState(auth.claims?.facultad_id ?? "")
|
||||
const [carreraId, setCarreraId] = useState(auth.claims?.carrera_id ?? "")
|
||||
const [nivel, setNivel] = useState("")
|
||||
const [facultadId, setFacultadId] = useState(auth.claims?.facultad_id ?? "");
|
||||
const [carreraId, setCarreraId] = useState(auth.claims?.carrera_id ?? "");
|
||||
const [nivel, setNivel] = useState("");
|
||||
const [prompt, setPrompt] = useState(
|
||||
"Genera un plan de estudios claro y realista: "
|
||||
)
|
||||
);
|
||||
|
||||
const [dbFiles, setDbFiles] = useState<{
|
||||
id: string;
|
||||
titulo: string;
|
||||
s3_file_path: string;
|
||||
fecha_subida?: string;
|
||||
tags?: string[];
|
||||
}[]>([])
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
||||
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const itemsPerPage = 10
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300)
|
||||
const [dbFiles, setDbFiles] = useState<
|
||||
{
|
||||
id: string;
|
||||
titulo: string;
|
||||
s3_file_path: string;
|
||||
fecha_subida?: string;
|
||||
tags?: string[];
|
||||
}[]
|
||||
>([]);
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
||||
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||
const totalPages = Math.ceil(dbFiles.length / itemsPerPage);
|
||||
|
||||
const [previewRow, setPreviewRow] = useState<RefRow | null>(null);
|
||||
|
||||
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
|
||||
const lockCarrera = role === "jefe_carrera"
|
||||
const lockFacultad =
|
||||
role === "secretario_academico" || role === "jefe_carrera";
|
||||
const lockCarrera = role === "jefe_carrera";
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDbFiles() {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from("fine_tuning_referencias")
|
||||
.select("fine_tuning_referencias_id, titulo_archivo, s3_file_path, fecha_subida, tags")
|
||||
.from("documentos")
|
||||
.select("documentos_id, titulo_archivo, fecha_subida, tags")
|
||||
.ilike("titulo_archivo", `%${debouncedSearchTerm}%`)
|
||||
.range((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage - 1);
|
||||
.range(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage - 1
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching files from database:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
setDbFiles((data || []).map((file: any) => ({
|
||||
id: file.fine_tuning_referencias_id,
|
||||
titulo: file.titulo_archivo,
|
||||
s3_file_path: file.s3_file_path,
|
||||
fecha_subida: file.fecha_subida,
|
||||
tags: file.tags || [],
|
||||
})));
|
||||
setDbFiles(
|
||||
(data || []).map((file: any) => ({
|
||||
id: file.documentos_id,
|
||||
titulo: file.titulo_archivo,
|
||||
s3_file_path: `prueba-referencias/documento_${file.documentos_id}.pdf`,
|
||||
fecha_subida: file.fecha_subida,
|
||||
tags: file.tags || [],
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Unexpected error fetching files:", err);
|
||||
}
|
||||
@@ -114,41 +139,59 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
if (open) fetchDbFiles();
|
||||
}, [open, debouncedSearchTerm, currentPage]);
|
||||
|
||||
const isSelected = useCallback((path: string) => selectedFiles.includes(path), [selectedFiles]);
|
||||
const isSelected = useCallback(
|
||||
(path: string) => selectedFiles.includes(path),
|
||||
[selectedFiles]
|
||||
);
|
||||
|
||||
const toggleSelected = useCallback((path: string) => {
|
||||
setSelectedFiles(prev => prev.includes(path) ? prev.filter(p => p !== path) : [...prev, path]);
|
||||
const toggleSelected = useCallback((id: string) => {
|
||||
setSelectedFiles((prev) =>
|
||||
prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const replaceSelection = useCallback((path: string) => {
|
||||
setSelectedFiles([path]);
|
||||
const replaceSelection = useCallback((id: string) => {
|
||||
setSelectedFiles([id]);
|
||||
}, []);
|
||||
|
||||
const rangeSelect = useCallback((start: number, end: number) => {
|
||||
const [s, e] = start < end ? [start, end] : [end, start];
|
||||
const paths = dbFiles.slice(s, e + 1).map(f => f.s3_file_path);
|
||||
setSelectedFiles(prev => Array.from(new Set([...prev, ...paths])));
|
||||
}, [dbFiles]);
|
||||
const rangeSelect = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const [s, e] = start < end ? [start, end] : [end, start];
|
||||
const ids = dbFiles.slice(s, e + 1).map((f) => f.id);
|
||||
setSelectedFiles((prev) => Array.from(new Set([...prev, ...ids])));
|
||||
},
|
||||
[dbFiles]
|
||||
);
|
||||
|
||||
const handleCardClick = useCallback((e: React.MouseEvent, index: number, file: { s3_file_path: string }) => {
|
||||
const path = file.s3_file_path;
|
||||
const handleCardClick = useCallback(
|
||||
(e: React.MouseEvent, index: number, file: { id: string }) => {
|
||||
const id = file.id;
|
||||
|
||||
if (e.shiftKey && lastSelectedIndex !== null) {
|
||||
rangeSelect(lastSelectedIndex, index);
|
||||
} else if (e.metaKey || e.ctrlKey) {
|
||||
toggleSelected(path);
|
||||
setLastSelectedIndex(index);
|
||||
} else {
|
||||
if (isSelected(path) && selectedFiles.length === 1) {
|
||||
// si ya es el único seleccionado, des-selecciona
|
||||
setSelectedFiles([]);
|
||||
setLastSelectedIndex(null);
|
||||
} else {
|
||||
replaceSelection(path);
|
||||
if (e.shiftKey && lastSelectedIndex !== null) {
|
||||
rangeSelect(lastSelectedIndex, index);
|
||||
} else if (e.metaKey || e.ctrlKey) {
|
||||
toggleSelected(id);
|
||||
setLastSelectedIndex(index);
|
||||
} else {
|
||||
if (isSelected(id) && selectedFiles.length === 1) {
|
||||
// si ya es el único seleccionado, des-selecciona
|
||||
setSelectedFiles([]);
|
||||
setLastSelectedIndex(null);
|
||||
} else {
|
||||
replaceSelection(id);
|
||||
setLastSelectedIndex(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isSelected, lastSelectedIndex, rangeSelect, replaceSelection, selectedFiles.length, toggleSelected]);
|
||||
},
|
||||
[
|
||||
isSelected,
|
||||
lastSelectedIndex,
|
||||
rangeSelect,
|
||||
replaceSelection,
|
||||
selectedFiles.length,
|
||||
toggleSelected,
|
||||
]
|
||||
);
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedFiles([]);
|
||||
@@ -156,29 +199,51 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
};
|
||||
|
||||
async function crearConIA() {
|
||||
setErr(null)
|
||||
if (!carreraId) { setErr("Selecciona una carrera."); return }
|
||||
setSaving(true)
|
||||
setErr(null);
|
||||
if (!carreraId) {
|
||||
setErr("Selecciona una carrera.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await postAPI("/api/generar/plan", {
|
||||
carreraId,
|
||||
prompt,
|
||||
insert: true,
|
||||
files: selectedFiles,
|
||||
})
|
||||
const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id
|
||||
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
"crear-plan-estudios",
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: {
|
||||
carrera_id: carreraId,
|
||||
prompt_usuario: prompt,
|
||||
insert: true,
|
||||
archivos_a_usar: [],
|
||||
},
|
||||
}
|
||||
);
|
||||
if (error) throw error;
|
||||
|
||||
const res = data;
|
||||
|
||||
const newId =
|
||||
(res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id;
|
||||
if (newId) {
|
||||
onOpenChange(false)
|
||||
router.invalidate()
|
||||
router.navigate({ to: "/plan/$planId", params: { planId: newId } })
|
||||
onOpenChange(false);
|
||||
router.invalidate();
|
||||
router.navigate({ to: "/plan/$planId", params: { planId: newId } });
|
||||
} else {
|
||||
onOpenChange(false)
|
||||
router.invalidate()
|
||||
onOpenChange(false);
|
||||
router.invalidate();
|
||||
}
|
||||
} catch (e: any) {
|
||||
setErr(typeof e?.message === "string" ? e.message : "Error al generar el plan.")
|
||||
setErr(
|
||||
typeof e?.message === "string" ? e.message : "Error al generar el plan."
|
||||
);
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +254,9 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="min-w-[65vw] max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono">Nuevo plan de estudios (IA)</DialogTitle>
|
||||
<DialogTitle className="font-mono">
|
||||
Nuevo plan de estudios (IA)
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
@@ -212,7 +279,10 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
<Label>Facultad</Label>
|
||||
<FacultadCombobox
|
||||
value={facultadId}
|
||||
onChange={(id) => { setFacultadId(id); setCarreraId("") }}
|
||||
onChange={(id) => {
|
||||
setFacultadId(id);
|
||||
setCarreraId("");
|
||||
}}
|
||||
disabled={lockFacultad}
|
||||
placeholder="Elige una facultad…"
|
||||
/>
|
||||
@@ -225,7 +295,11 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
value={carreraId}
|
||||
onChange={setCarreraId}
|
||||
disabled={!facultadId || lockCarrera}
|
||||
placeholder={facultadId ? "Elige una carrera…" : "Selecciona una facultad primero"}
|
||||
placeholder={
|
||||
facultadId
|
||||
? "Elige una carrera…"
|
||||
: "Selecciona una facultad primero"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -243,11 +317,19 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
<div className="text-sm text-neutral-600">
|
||||
{selectedFiles.length > 0 ? (
|
||||
<span>
|
||||
{selectedFiles.length} seleccionado{selectedFiles.length > 1 ? 's' : ''}
|
||||
<button className="ml-3 underline hover:no-underline" onClick={clearSelection}>Limpiar</button>
|
||||
{selectedFiles.length} seleccionado
|
||||
{selectedFiles.length > 1 ? "s" : ""}
|
||||
<button
|
||||
className="ml-3 underline hover:no-underline"
|
||||
onClick={clearSelection}
|
||||
>
|
||||
Limpiar
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span>Tip: ⇧ para seleccionar rango, ⌘/Ctrl para múltiples.</span>
|
||||
<span>
|
||||
Tip: ⇧ para seleccionar rango, ⌘/Ctrl para múltiples.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,13 +337,17 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
{/* Grid de archivos con selección tipo file manager */}
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<Label>Archivos de referencia (opcional)</Label>
|
||||
<div role="grid" className="grid gap-4 xs:grid-cols-2 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
<div
|
||||
role="grid"
|
||||
className="grid gap-4 xs:grid-cols-2 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
|
||||
>
|
||||
{dbFiles.map((file, index) => {
|
||||
const ext = fileExt(file.titulo);
|
||||
const selected = isSelected(file.s3_file_path);
|
||||
const selected = isSelected(file.id);
|
||||
console.log(file);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
key={file.id}
|
||||
role="gridcell"
|
||||
aria-selected={selected}
|
||||
@@ -269,7 +355,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewRow({
|
||||
fine_tuning_referencias_id: file.id,
|
||||
documentos_id: file.id,
|
||||
created_by: "unknown",
|
||||
s3_file_path: file.s3_file_path,
|
||||
titulo_archivo: file.titulo,
|
||||
@@ -281,10 +367,10 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
fecha_subida: file.fecha_subida ?? null,
|
||||
tags: file.tags ?? null,
|
||||
instrucciones: "",
|
||||
})
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleCardClick(e as any, index, file);
|
||||
}
|
||||
@@ -292,30 +378,51 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
className={[
|
||||
"group relative rounded-2xl border bg-white p-4 text-left shadow-sm transition",
|
||||
"hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||
selected ? "border-blue-500 ring-2 ring-blue-500 shadow-md" : "border-neutral-200 hover:border-neutral-300",
|
||||
].join(' ')}
|
||||
selected
|
||||
? "border-blue-500 ring-2 ring-blue-500 shadow-md"
|
||||
: "border-neutral-200 hover:border-neutral-300",
|
||||
].join(" ")}
|
||||
>
|
||||
{/* Outline animado tipo file manager */}
|
||||
<span className={[
|
||||
"pointer-events-none absolute inset-0 rounded-2xl ring-2 ring-blue-500",
|
||||
"opacity-0 group-aria-selected:opacity-100 group-focus:opacity-100 transition-opacity",
|
||||
].join(' ')} />
|
||||
<span
|
||||
className={[
|
||||
"pointer-events-none absolute inset-0 rounded-2xl ring-2 ring-blue-500",
|
||||
"opacity-0 group-aria-selected:opacity-100 group-focus:opacity-100 transition-opacity",
|
||||
].join(" ")}
|
||||
/>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border bg-neutral-50">
|
||||
<span className="text-lg" aria-hidden>{extIcon(ext)}</span>
|
||||
<span className="text-lg" aria-hidden>
|
||||
{extIcon(ext)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-sm md:text-base truncate" title={file.titulo}>{file.titulo}</h3>
|
||||
<h3
|
||||
className="font-semibold text-sm md:text-base truncate"
|
||||
title={file.titulo}
|
||||
>
|
||||
{file.titulo}
|
||||
</h3>
|
||||
{file.fecha_subida ? (
|
||||
<p className="text-xs text-neutral-600">{new Date(file.fecha_subida).toLocaleDateString()}</p>
|
||||
<p className="text-xs text-neutral-600">
|
||||
{new Date(file.fecha_subida).toLocaleDateString()}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-neutral-500">Fecha desconocida</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Fecha desconocida
|
||||
</p>
|
||||
)}
|
||||
|
||||
{file.tags && file.tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{file.tags.map((tag, i) => (
|
||||
<span key={i} className="text-[10px] px-2 py-0.5 bg-neutral-100 text-neutral-700 rounded-full">#{tag}</span>
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] px-2 py-0.5 bg-neutral-100 text-neutral-700 rounded-full"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -330,7 +437,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewRow({
|
||||
fine_tuning_referencias_id: file.id,
|
||||
documentos_id: file.id,
|
||||
created_by: "unknown",
|
||||
s3_file_path: file.s3_file_path,
|
||||
titulo_archivo: file.titulo,
|
||||
@@ -342,50 +449,69 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
fecha_subida: file.fecha_subida ?? null,
|
||||
tags: file.tags ?? null,
|
||||
instrucciones: "",
|
||||
})
|
||||
});
|
||||
}}
|
||||
>Previsualizar</Button>
|
||||
>
|
||||
Previsualizar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Footer compacto */}
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-neutral-600">
|
||||
<span className="truncate">{ext.toUpperCase()}</span>
|
||||
{selected ? <span className="font-medium">Seleccionado</span> : <span className="opacity-60">Click para seleccionar</span>}
|
||||
{selected ? (
|
||||
<span className="font-medium">Seleccionado</span>
|
||||
) : (
|
||||
<span className="opacity-60">
|
||||
Click para seleccionar
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{dbFiles.length === 0 && (
|
||||
<p className="text-sm text-neutral-500">No se encontraron archivos.</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
No se encontraron archivos.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paginación mejorada */}
|
||||
{dbFiles.length > itemsPerPage && (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm text-neutral-700">Página {currentPage} de {totalPages}</div>
|
||||
<div className="text-sm text-neutral-700">
|
||||
Página {currentPage} de {totalPages}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
||||
>Anterior</Button>
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Input
|
||||
className="h-8 w-16 text-center"
|
||||
value={currentPage}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value || '1', 10);
|
||||
if (!isNaN(v)) setCurrentPage(Math.min(Math.max(v, 1), totalPages));
|
||||
const v = parseInt(e.target.value || "1", 10);
|
||||
if (!isNaN(v))
|
||||
setCurrentPage(Math.min(Math.max(v, 1), totalPages));
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
|
||||
>Siguiente</Button>
|
||||
onClick={() =>
|
||||
setCurrentPage((p) => Math.min(p + 1, totalPages))
|
||||
}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -395,19 +521,26 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
{err && <div className="text-sm text-red-600">{err}</div>}
|
||||
|
||||
<DialogFooter className="flex flex-col-reverse sm:flex-row gap-2">
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
||||
<Button className="w-full sm:w-auto" onClick={crearConIA} disabled={saving}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
onClick={crearConIA}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Generando…" : "Generar y crear"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
{previewRow && (
|
||||
<DetailDialog
|
||||
row={previewRow}
|
||||
onClose={() => setPreviewRow(null)}
|
||||
/>
|
||||
<DetailDialog row={previewRow} onClose={() => setPreviewRow(null)} />
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,10 +28,10 @@ export function DeletePlanButton({ planId, onDeleted }: { planId: string; onDele
|
||||
|
||||
return confirm ? (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setConfirm(false)} disabled={loading}>Cancelar</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
|
||||
{loading ? "Eliminando…" : "Confirmar eliminación"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setConfirm(false)} disabled={loading}>Cancelar</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setConfirm(true)}>
|
||||
|
||||
145
src/components/planes/DownloadPlanPDF.tsx
Normal file
145
src/components/planes/DownloadPlanPDF.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { supabase } from "@/auth/supabase";
|
||||
import { Button } from "../ui/button";
|
||||
import { Download } from "lucide-react";
|
||||
|
||||
export type PlanLike = Record<
|
||||
string,
|
||||
string | number | object | null | undefined
|
||||
>;
|
||||
|
||||
export function DownloadPlanPDF({ plan }: { plan: Record<string, any> }) {
|
||||
async function fetchPDF() {
|
||||
const planObj = {
|
||||
...plan,
|
||||
nivel_y_nombre_del_plan_de_estudios: `${plan["nivel"]} en ${plan["nombre"]}`,
|
||||
nivel: undefined,
|
||||
nombre: undefined,
|
||||
};
|
||||
const fileName = `Plan_${planObj.nivel_y_nombre_del_plan_de_estudios || "Desconocido"}.pdf`;
|
||||
// const jsonData = JSON.stringify(planObj);
|
||||
|
||||
const triggerDownload = (blob: Blob, name: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.setAttribute("download", name);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const fetchBinaryFallback = async () => {
|
||||
// Intenta construir la URL del runtime de Functions
|
||||
const anyClient = supabase as any;
|
||||
const baseUrl =
|
||||
anyClient?.functions?.url ||
|
||||
`${(anyClient?.supabaseUrl || "").replace(/\/$/, "")}/functions/v1`;
|
||||
const { data: sess } = await supabase.auth.getSession();
|
||||
const token = sess?.session?.access_token;
|
||||
|
||||
console.log(JSON.stringify(planObj, null, 2));
|
||||
console.log(planObj);
|
||||
|
||||
|
||||
|
||||
const resp = await fetch(`${baseUrl}/carbone-io-api`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/pdf",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "downloadReport",
|
||||
templateId: "1302213091201757023",
|
||||
fileName,
|
||||
convertTo: "pdf",
|
||||
data: planObj,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const blob = await resp.blob();
|
||||
triggerDownload(blob, fileName);
|
||||
};
|
||||
|
||||
try {
|
||||
// const { data, error } = await supabase.functions.invoke(
|
||||
// "carbone-io-api",
|
||||
// {
|
||||
// method: "POST",
|
||||
// headers: { Accept: "application/octet-stream" }, // preferir binario
|
||||
// body: {
|
||||
// action: "downloadReport",
|
||||
// templateId: "1302213091201757023",
|
||||
// fileName,
|
||||
// convertTo: "pdf",
|
||||
// data: planObj,
|
||||
// },
|
||||
// }
|
||||
// );
|
||||
|
||||
// if (error) throw error;
|
||||
|
||||
// // Si ya viene binario, descargar directo
|
||||
// if (typeof Blob !== "undefined" && data instanceof Blob) {
|
||||
// triggerDownload(data, fileName);
|
||||
// return;
|
||||
// }
|
||||
// if (data instanceof ArrayBuffer) {
|
||||
// triggerDownload(
|
||||
// new Blob([data], { type: "application/pdf" }),
|
||||
// fileName
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // Si vino como string (ej. empieza con %PDF), usa el fallback binario
|
||||
// if (typeof data === "string") {
|
||||
// await fetchBinaryFallback();
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // Si vino JSON con base64, decodificar y descargar
|
||||
// if (data && typeof data === "object") {
|
||||
// const b64 =
|
||||
// (data as any).file || (data as any).buffer || (data as any).base64;
|
||||
// if (typeof b64 === "string") {
|
||||
// const clean = b64.replace(/^data:.*;base64,/, "");
|
||||
// const binary = atob(clean);
|
||||
// const bytes = new Uint8Array(binary.length);
|
||||
// for (let i = 0; i < binary.length; i++)
|
||||
// bytes[i] = binary.charCodeAt(i);
|
||||
// triggerDownload(
|
||||
// new Blob([bytes], { type: "application/pdf" }),
|
||||
// fileName
|
||||
// );
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
|
||||
// console.warn("Respuesta no reconocida para descarga de PDF.", {
|
||||
// type: typeof data,
|
||||
// });
|
||||
|
||||
await fetchBinaryFallback();
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error("Error al obtener PDF:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => void fetchPDF()}
|
||||
>
|
||||
Descargar PDF
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default DownloadPlanPDF;
|
||||
33
src/components/planes/GenerarPdfButton.tsx
Normal file
33
src/components/planes/GenerarPdfButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
export function DescargarPdfButton({planId, opcion}: {planId: string, opcion: "plan" | "asignaturas"}) {
|
||||
return (
|
||||
<Button variant="outline" className="flex items-center gap-2 " onClick={() => descargarPdf(planId, opcion)}>
|
||||
Descargar {opcion === "plan" ? "Plan" : "Asignaturas"} PDF
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function descargarPdf(planId: string, opcion: "plan" | "asignaturas") {
|
||||
// Lógica para generar y descargar el PDF del plan de estudios
|
||||
try {
|
||||
// Usa la variable de entorno para construir la URL completa
|
||||
const pdfUrl = opcion === "plan"
|
||||
? `${import.meta.env.VITE_BACK_ORIGIN}/api/planes/${planId}/descargar-pdf-plan`
|
||||
: `${import.meta.env.VITE_BACK_ORIGIN}/api/planes/${planId}/descargar-pdf-asignaturas`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = pdfUrl;
|
||||
link.download = opcion === "plan"
|
||||
? `plan_estudios_${planId}.pdf`
|
||||
: `asignaturas_plan_estudios_${planId}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
console.error("Error al descargar el PDF:", error);
|
||||
alert("Hubo un error al descargar el PDF. Por favor, inténtalo de nuevo.");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,26 @@
|
||||
import * as Icons from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useSuspenseQuery, useMutation, useQueryClient, queryOptions } from "@tanstack/react-query"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import { toast } from "sonner"
|
||||
import * as Icons from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
useSuspenseQuery,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
queryOptions,
|
||||
} from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { supabase, useSupabaseAuth } from "@/auth/supabase";
|
||||
import { toast } from "sonner";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { HistorialCambiosModal } from "../historico/HistorialCambiosModal";
|
||||
// @ts-ignore
|
||||
import AIChatModal from "../ai/AIChatModal";
|
||||
|
||||
/* =====================================================
|
||||
Query keys & fetcher
|
||||
@@ -13,32 +28,29 @@ import { toast } from "sonner"
|
||||
export const planKeys = {
|
||||
root: ["plan"] as const,
|
||||
byId: (id: string) => [...planKeys.root, id] as const,
|
||||
}
|
||||
};
|
||||
|
||||
export type PlanTextFields = {
|
||||
objetivo_general?: string | string[] | null
|
||||
sistema_evaluacion?: string | string[] | null
|
||||
perfil_ingreso?: string | string[] | null
|
||||
perfil_egreso?: string | string[] | null
|
||||
competencias_genericas?: string | string[] | null
|
||||
competencias_especificas?: string | string[] | null
|
||||
indicadores_desempeno?: string | string[] | null
|
||||
pertinencia?: string | string[] | null
|
||||
prompt?: string | null
|
||||
}
|
||||
objetivo_general?: string | string[] | null;
|
||||
sistema_evaluacion?: string | string[] | null;
|
||||
perfil_ingreso?: string | string[] | null;
|
||||
perfil_egreso?: string | string[] | null;
|
||||
competencias_genericas?: string | string[] | null;
|
||||
competencias_especificas?: string | string[] | null;
|
||||
indicadores_desempeno?: string | string[] | null;
|
||||
pertinencia?: string | string[] | null;
|
||||
prompt?: string | null;
|
||||
historico?: string | null;
|
||||
};
|
||||
|
||||
async function fetchPlanText(planId: string): Promise<PlanTextFields> {
|
||||
const { data, error } = await supabase
|
||||
.from("plan_estudios")
|
||||
.select(
|
||||
`objetivo_general, sistema_evaluacion, perfil_ingreso, perfil_egreso,
|
||||
competencias_genericas, competencias_especificas, indicadores_desempeno,
|
||||
pertinencia, prompt`
|
||||
)
|
||||
.select(`*`)
|
||||
.eq("id", planId)
|
||||
.single()
|
||||
if (error) throw error
|
||||
return (data ?? {}) as PlanTextFields
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return (data ?? {}) as PlanTextFields;
|
||||
}
|
||||
|
||||
export const planTextOptions = (planId: string) =>
|
||||
@@ -46,174 +58,503 @@ export const planTextOptions = (planId: string) =>
|
||||
queryKey: planKeys.byId(planId),
|
||||
queryFn: () => fetchPlanText(planId),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
});
|
||||
|
||||
/* =====================================================
|
||||
Color helpers
|
||||
===================================================== */
|
||||
function hexToRgb(hex?: string | null): [number, number, number] {
|
||||
if (!hex) return [37, 99, 235]
|
||||
const h = hex.replace("#", "")
|
||||
const v = h.length === 3 ? h.split("").map((c) => c + c).join("") : h
|
||||
const n = parseInt(v, 16)
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||||
if (!hex) return [37, 99, 235];
|
||||
const h = hex.replace("#", "");
|
||||
const v =
|
||||
h.length === 3
|
||||
? h
|
||||
.split("")
|
||||
.map((c) => c + c)
|
||||
.join("")
|
||||
: h;
|
||||
const n = parseInt(v, 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`
|
||||
const rgba = (rgb: [number, number, number], a: number) =>
|
||||
`rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`;
|
||||
|
||||
/* =====================================================
|
||||
Expandable text
|
||||
===================================================== */
|
||||
function ExpandableText({ text, mono = false }: { text?: string | string[] | null; mono?: boolean }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
function ExpandableText({
|
||||
text,
|
||||
mono = false,
|
||||
}: {
|
||||
text?: string | string[] | null;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!text || (Array.isArray(text) && text.length === 0)) {
|
||||
return <span className="text-neutral-400">—</span>
|
||||
return <span className="text-neutral-400">—</span>;
|
||||
}
|
||||
const content = Array.isArray(text) ? text.join("\n• ") : text
|
||||
const rendered = Array.isArray(text) ? `• ${content}` : content
|
||||
const content = Array.isArray(text) ? text.join("\n• ") : text;
|
||||
const rendered = Array.isArray(text) ? `• ${content}` : content;
|
||||
return (
|
||||
<div>
|
||||
<div className={`${mono ? "font-mono whitespace-pre-wrap" : ""} text-sm ${open ? "" : "line-clamp-10"}`}>{rendered}</div>
|
||||
<ReactMarkdown>{rendered}</ReactMarkdown>
|
||||
{String(rendered).length > 220 && (
|
||||
<button onClick={() => setOpen((v) => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="mt-2 text-xs font-medium text-neutral-600 hover:underline"
|
||||
>
|
||||
{open ? "Ver menos" : "Ver más"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
Section panel
|
||||
===================================================== */
|
||||
function SectionPanel({ title, icon: Icon, color, children, id }: { title: string; icon: any; color?: string | null; children: React.ReactNode; id: string }) {
|
||||
const rgb = hexToRgb(color)
|
||||
function SectionPanel({
|
||||
title,
|
||||
icon: Icon,
|
||||
color,
|
||||
children,
|
||||
id,
|
||||
}: {
|
||||
title: string;
|
||||
icon: any;
|
||||
color?: string | null;
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
}) {
|
||||
const rgb = hexToRgb(color);
|
||||
return (
|
||||
<section id={id} className="relative rounded-3xl border overflow-hidden shadow-sm bg-white/70 dark:bg-neutral-900/60">
|
||||
<section
|
||||
id={id}
|
||||
className="relative rounded-3xl border overflow-hidden shadow-sm bg-white/70 dark:bg-neutral-900/60"
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 -z-0">
|
||||
<div className="absolute -top-20 -left-20 w-[28rem] h-[28rem] rounded-full blur-3xl" style={{ background: `radial-gradient(circle, ${rgba(rgb, 0.2)}, transparent 60%)` }} />
|
||||
<div className="absolute -bottom-24 -right-16 w-[24rem] h-[24rem] rounded-full blur-[60px]" style={{ background: `radial-gradient(circle, ${rgba(rgb, 0.14)}, transparent 60%)` }} />
|
||||
<div
|
||||
className="absolute -top-20 -left-20 w-[28rem] h-[28rem] rounded-full blur-3xl"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${rgba(rgb, 0.2)}, transparent 60%)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-24 -right-16 w-[24rem] h-[24rem] rounded-full blur-[60px]"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${rgba(rgb, 0.14)}, transparent 60%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-10 px-4 py-3 flex items-center gap-2 border-b" style={{ background: `linear-gradient(180deg, ${rgba(rgb, 0.1)}, transparent)` }}>
|
||||
<span className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2 bg-white/80" style={{ borderColor: rgba(rgb, 0.25) }}>
|
||||
<div
|
||||
className="relative z-10 px-4 py-3 flex items-center gap-2 border-b"
|
||||
style={{
|
||||
background: `linear-gradient(180deg, ${rgba(rgb, 0.1)}, transparent)`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2 bg-white/80"
|
||||
style={{ borderColor: rgba(rgb, 0.25) }}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
</div>
|
||||
<div className="relative z-10 p-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
AcademicSections (con React Query)
|
||||
===================================================== */
|
||||
export function AcademicSections({ planId, color }: { planId: string; color?: string | null }) {
|
||||
const qc = useQueryClient()
|
||||
const { data: plan } = useSuspenseQuery(planTextOptions(planId))
|
||||
export function AcademicSections({
|
||||
planId,
|
||||
color,
|
||||
}: {
|
||||
planId: string;
|
||||
color?: string | null;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const auth = useSupabaseAuth();
|
||||
const [openHistorial, setOpenHistorial] = useState(false);
|
||||
const [openModalIa, setopenModalIa] = useState(false);
|
||||
if (!planId) return <div>Cargando…</div>;
|
||||
const { data: plan } = useSuspenseQuery(planTextOptions(planId));
|
||||
|
||||
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
|
||||
const [draft, setDraft] = useState("")
|
||||
const [editing, setEditing] = useState<null | {
|
||||
key: keyof PlanTextFields;
|
||||
title: string;
|
||||
}>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
// --- mutation con actualización optimista ---
|
||||
const updateField = useMutation({
|
||||
mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => {
|
||||
const payload: Record<string, any> = { [key]: value }
|
||||
const { error } = await supabase.from("plan_estudios").update(payload).eq("id", planId)
|
||||
if (error) throw error
|
||||
return payload
|
||||
mutationFn: async ({
|
||||
key,
|
||||
value,
|
||||
}: {
|
||||
key: keyof PlanTextFields;
|
||||
value: string | string[] | null;
|
||||
}) => {
|
||||
const payload: Record<string, any> = { [key]: value };
|
||||
const { error } = await supabase
|
||||
.from("plan_estudios")
|
||||
.update(payload)
|
||||
.eq("id", planId);
|
||||
if (error) throw error;
|
||||
return payload;
|
||||
},
|
||||
onMutate: async ({ key, value }) => {
|
||||
await qc.cancelQueries({ queryKey: planKeys.byId(planId) })
|
||||
const prev = qc.getQueryData<PlanTextFields>(planKeys.byId(planId))
|
||||
qc.setQueryData<PlanTextFields>(planKeys.byId(planId), (old) => ({ ...(old ?? {}), [key]: value }))
|
||||
return { prev }
|
||||
await qc.cancelQueries({ queryKey: planKeys.byId(planId) });
|
||||
const prev = qc.getQueryData<PlanTextFields>(planKeys.byId(planId));
|
||||
qc.setQueryData<PlanTextFields>(planKeys.byId(planId), (old) => ({
|
||||
...(old ?? {}),
|
||||
[key]: value,
|
||||
}));
|
||||
return { prev };
|
||||
},
|
||||
onError: (e, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(planKeys.byId(planId), ctx.prev)
|
||||
toast.error((e as any)?.message || "No se pudo guardar 😓")
|
||||
if (ctx?.prev) qc.setQueryData(planKeys.byId(planId), ctx.prev);
|
||||
toast.error((e as any)?.message || "No se pudo guardar 😓");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Guardado ✅")
|
||||
toast.success("Guardado ✅");
|
||||
},
|
||||
onSettled: async () => {
|
||||
await qc.invalidateQueries({ queryKey: planKeys.byId(planId) })
|
||||
await qc.invalidateQueries({ queryKey: planKeys.byId(planId) });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const sections = useMemo(
|
||||
() => [
|
||||
{ id: "sec-obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false },
|
||||
{ id: "sec-eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false },
|
||||
{ id: "sec-ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false },
|
||||
{ id: "sec-egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false },
|
||||
{ id: "sec-cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false },
|
||||
{ id: "sec-ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" as const, mono: false },
|
||||
{ id: "sec-ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false },
|
||||
{ id: "sec-per", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false },
|
||||
{ id: "sec-prm", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true },
|
||||
{
|
||||
id: "sec-clave",
|
||||
title: "Clave del plan",
|
||||
icon: Icons.Key,
|
||||
key: "clave_del_plan_de_estudios" as const,
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
id: "sec-area",
|
||||
title: "Área de estudio",
|
||||
icon: Icons.Library,
|
||||
key: "area_de_estudio" as const,
|
||||
mono: false,
|
||||
},
|
||||
|
||||
// --- Estructura Temporal ---
|
||||
{
|
||||
id: "sec-ciclos",
|
||||
title: "Total de ciclos",
|
||||
icon: Icons.CalendarRange,
|
||||
key: "total_de_ciclos_del_plan_de_estudios" as const,
|
||||
mono: false,
|
||||
},
|
||||
{
|
||||
id: "sec-duracion-ciclo",
|
||||
title: "Duración del ciclo (semanas)",
|
||||
icon: Icons.CalendarDays,
|
||||
key: "duracion_del_ciclo_escolar" as const,
|
||||
mono: false,
|
||||
},
|
||||
{
|
||||
id: "sec-carga",
|
||||
title: "Carga horaria semanal",
|
||||
icon: Icons.Clock,
|
||||
key: "carga_horaria_a_la_semana" as const,
|
||||
mono: false,
|
||||
},
|
||||
|
||||
// --- Perfiles y Fines ---
|
||||
{
|
||||
id: "sec-antecedente",
|
||||
title: "Antecedente académico",
|
||||
icon: Icons.BookOpen,
|
||||
key: "antecedente_academico" as const,
|
||||
mono: false,
|
||||
},
|
||||
{
|
||||
id: "sec-ingreso",
|
||||
title: "Perfil de ingreso",
|
||||
icon: Icons.UserPlus,
|
||||
key: "perfil_de_ingreso" as const,
|
||||
mono: false,
|
||||
},
|
||||
{
|
||||
id: "sec-fines",
|
||||
title: "Fines de aprendizaje",
|
||||
icon: Icons.Target,
|
||||
key: "fines_de_aprendizaje_o_formacion" as const,
|
||||
mono: false,
|
||||
},
|
||||
{
|
||||
id: "sec-egreso",
|
||||
title: "Perfil de egreso",
|
||||
icon: Icons.UserCheck,
|
||||
key: "perfil_de_egreso" as const,
|
||||
mono: false,
|
||||
},
|
||||
|
||||
// --- Operatividad y Modelo ---
|
||||
{
|
||||
id: "sec-admin",
|
||||
title: "Administración y operatividad",
|
||||
icon: Icons.Briefcase,
|
||||
key: "administracion_y_operatividad_del_plan_de_estudios" as const,
|
||||
mono: false,
|
||||
},
|
||||
{
|
||||
id: "sec-sustento",
|
||||
title: "Sustento teórico",
|
||||
icon: Icons.Book,
|
||||
key: "sustento_teorico_del_modelo_curricular" as const,
|
||||
mono: false,
|
||||
},
|
||||
{
|
||||
id: "sec-justificacion",
|
||||
title: "Justificación curricular",
|
||||
icon: Icons.MessageSquareText,
|
||||
key: "justificacion_de_la_propuesta_curricular" as const,
|
||||
mono: false,
|
||||
},
|
||||
{
|
||||
id: "sec-evaluacion",
|
||||
title: "Evaluación periódica",
|
||||
icon: Icons.CheckCircle2,
|
||||
key: "propuesta_de_evaluacion_periodica_del_plan_de_estudios" as const,
|
||||
mono: false,
|
||||
},
|
||||
|
||||
// --- Específicos / Opcionales ---
|
||||
{
|
||||
id: "sec-investigacion",
|
||||
title: "Programa de investigación",
|
||||
icon: Icons.Microscope,
|
||||
key: "programa_de_investigacion" as const,
|
||||
mono: false,
|
||||
},
|
||||
{
|
||||
id: "sec-propedeutico",
|
||||
title: "Curso propedéutico",
|
||||
icon: Icons.School,
|
||||
key: "curso_propedeutico" as const,
|
||||
mono: false,
|
||||
},
|
||||
|
||||
// --- Meta / Sistema ---
|
||||
{
|
||||
id: "sec-prm",
|
||||
title: "Prompt (origen)",
|
||||
icon: Icons.Code2,
|
||||
key: "prompt" as const,
|
||||
mono: true,
|
||||
},
|
||||
{
|
||||
id: "sec-hist",
|
||||
title: "Histórico de cambios",
|
||||
icon: Icons.History,
|
||||
key: "historico" as const,
|
||||
mono: false,
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
);
|
||||
const [iaContext, setIaContext] = useState<{
|
||||
key: keyof PlanTextFields;
|
||||
title: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{sections.map((s) => {
|
||||
const text = plan[s.key] ?? null
|
||||
const text = String(plan[s.key]) ?? null;
|
||||
return (
|
||||
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
|
||||
<ExpandableText text={text} mono={s.mono} />
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!text || (Array.isArray(text) && text.length === 0)}
|
||||
onClick={() => {
|
||||
const toCopy = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||
if (toCopy) navigator.clipboard.writeText(toCopy)
|
||||
}}
|
||||
>
|
||||
Copiar
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||
setEditing({ key: s.key, title: s.title })
|
||||
setDraft(current)
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
</div>
|
||||
<SectionPanel
|
||||
key={s.id}
|
||||
id={s.id}
|
||||
title={s.title}
|
||||
icon={s.icon}
|
||||
color={color}
|
||||
>
|
||||
{s.key === "historico" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOpenHistorial(true)}
|
||||
>
|
||||
Ver historial
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setopenModalIa(true)}
|
||||
>
|
||||
Promt
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExpandableText text={text} mono={s.mono} />
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={
|
||||
!text || (Array.isArray(text) && text.length === 0)
|
||||
}
|
||||
onClick={() => {
|
||||
const toCopy = Array.isArray(text)
|
||||
? text.join("\n")
|
||||
: (text ?? "");
|
||||
if (toCopy) navigator.clipboard.writeText(toCopy);
|
||||
}}
|
||||
>
|
||||
Copiar
|
||||
</Button>
|
||||
{s.key !== "prompt" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const current = Array.isArray(text)
|
||||
? text.join("\n")
|
||||
: (text ?? "");
|
||||
setEditing({ key: s.key, title: s.title });
|
||||
setDraft(current);
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SectionPanel>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Diálogo de edición */}
|
||||
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
|
||||
<Dialog
|
||||
open={!!editing}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setEditing(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono" >{editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}</DialogTitle>
|
||||
<DialogTitle className="font-mono">
|
||||
{editing
|
||||
? `Editar: ${sections.find((x) => x.key === editing.key)?.title}`
|
||||
: ""}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`} placeholder="Escribe aquí…" />
|
||||
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`}
|
||||
placeholder="Escribe aquí…"
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!editing) return
|
||||
updateField.mutate({ key: editing.key, value: draft })
|
||||
setEditing(null)
|
||||
onClick={async () => {
|
||||
if (!editing) return;
|
||||
|
||||
// 1️⃣ Obtener el valor anterior (por ejemplo, desde 'plan' o 'section')
|
||||
const oldValue = (plan as any)[editing.key];
|
||||
|
||||
// 2️⃣ Crear un diff tipo JSON Patch
|
||||
const diff = [
|
||||
{
|
||||
op: "replace",
|
||||
path: `/${editing.key}`,
|
||||
from: oldValue,
|
||||
value: draft,
|
||||
},
|
||||
];
|
||||
|
||||
// 3️⃣ Guardar respaldo antes de actualizar
|
||||
const { error: backupError } = await supabase
|
||||
.from("historico_cambios")
|
||||
.insert({
|
||||
id_plan_estudios: planId, // asegúrate de tener `plan.id` disponible en este contexto
|
||||
json_cambios: diff,
|
||||
user_id: auth.user?.id,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (backupError) {
|
||||
console.error("Error al guardar respaldo:", backupError);
|
||||
alert("No se pudo guardar el respaldo de los cambios");
|
||||
return;
|
||||
}
|
||||
|
||||
// 4️⃣ Ejecutar la mutación original
|
||||
updateField.mutate({ key: editing.key, value: draft });
|
||||
|
||||
// 5️⃣ Cerrar el diálogo
|
||||
setEditing(null);
|
||||
}}
|
||||
disabled={updateField.isPending}
|
||||
>
|
||||
{updateField.isPending ? "Guardando…" : "Guardar"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (!editing) return;
|
||||
const current = draft;
|
||||
setIaContext({
|
||||
key: editing.key,
|
||||
title: editing.title,
|
||||
content: current,
|
||||
});
|
||||
setopenModalIa(true);
|
||||
setEditing(null); // 🔹 Cierra el modal de edición
|
||||
}}
|
||||
>
|
||||
Mejorar con IA
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<HistorialCambiosModal
|
||||
open={openHistorial}
|
||||
onClose={() => setOpenHistorial(false)}
|
||||
planId={planId}
|
||||
onRestore={async (key, value) => {
|
||||
updateField.mutate({ key, value });
|
||||
}}
|
||||
/>
|
||||
|
||||
<AIChatModal
|
||||
open={openModalIa}
|
||||
onClose={() => setopenModalIa(false)}
|
||||
context={{
|
||||
section: iaContext?.title,
|
||||
fieldKey: iaContext?.key,
|
||||
originalText: iaContext?.content,
|
||||
cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId} que se encuentra en la tabla plan_estudios con el mcp para realizar los cambios que se te soliciten Responde únicamente con la información solicitada.
|
||||
No uses frases como “claro”, “por supuesto”, “aquí tienes”, “con gusto”, “hola”, “perfecto”.
|
||||
No uses introducciones, despedidas ni texto de relleno.
|
||||
Entrega solo el contenido útil.`,
|
||||
}}
|
||||
onAccept={(newText: string) => {
|
||||
if (iaContext) {
|
||||
updateField.mutate({ key: iaContext.key, value: newText });
|
||||
setIaContext(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,15 +26,17 @@ export function planByIdOptions(planId: string) {
|
||||
queryKey: planKeys.byId(planId),
|
||||
queryFn: async (): Promise<PlanFull> => {
|
||||
const { data, error } = await supabase
|
||||
.from("plan_estudios")
|
||||
.select(`
|
||||
id, nombre, nivel, objetivo_general, perfil_ingreso, perfil_egreso, duracion, total_creditos,
|
||||
competencias_genericas, competencias_especificas, sistema_evaluacion, indicadores_desempeno,
|
||||
pertinencia, prompt, estado, fecha_creacion,
|
||||
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
|
||||
`)
|
||||
.eq("id", planId)
|
||||
.maybeSingle()
|
||||
.from("plan_estudios")
|
||||
.select(`
|
||||
*,
|
||||
carreras (
|
||||
id,
|
||||
nombre,
|
||||
facultades ( id, nombre, color, icon )
|
||||
)
|
||||
`)
|
||||
.eq("id", planId)
|
||||
.maybeSingle();
|
||||
if (error || !data) throw error ?? new Error("Plan no encontrado")
|
||||
return data as unknown as PlanFull
|
||||
},
|
||||
|
||||
200
src/components/ui/prompt-input.tsx
Normal file
200
src/components/ui/prompt-input.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
type PromptInputContextType = {
|
||||
isLoading: boolean
|
||||
value: string
|
||||
setValue: (value: string) => void
|
||||
maxHeight: number | string
|
||||
onSubmit?: () => void
|
||||
disabled?: boolean
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
}
|
||||
|
||||
const PromptInputContext = createContext<PromptInputContextType>({
|
||||
isLoading: false,
|
||||
value: "",
|
||||
setValue: () => {},
|
||||
maxHeight: 240,
|
||||
onSubmit: undefined,
|
||||
disabled: false,
|
||||
textareaRef: React.createRef<HTMLTextAreaElement>(),
|
||||
})
|
||||
|
||||
function usePromptInput() {
|
||||
const context = useContext(PromptInputContext)
|
||||
if (!context) {
|
||||
throw new Error("usePromptInput must be used within a PromptInput")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
type PromptInputProps = {
|
||||
isLoading?: boolean
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
maxHeight?: number | string
|
||||
onSubmit?: () => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function PromptInput({
|
||||
className,
|
||||
isLoading = false,
|
||||
maxHeight = 240,
|
||||
value,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
children,
|
||||
}: PromptInputProps) {
|
||||
const [internalValue, setInternalValue] = useState(value || "")
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
setInternalValue(newValue)
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<PromptInputContext.Provider
|
||||
value={{
|
||||
isLoading,
|
||||
value: value ?? internalValue,
|
||||
setValue: onValueChange ?? handleChange,
|
||||
maxHeight,
|
||||
onSubmit,
|
||||
textareaRef,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-input bg-background cursor-text rounded-3xl border p-2 shadow-xs",
|
||||
className
|
||||
)}
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</PromptInputContext.Provider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export type PromptInputTextareaProps = {
|
||||
disableAutosize?: boolean
|
||||
} & React.ComponentProps<typeof Textarea>
|
||||
|
||||
function PromptInputTextarea({
|
||||
className,
|
||||
onKeyDown,
|
||||
disableAutosize = false,
|
||||
...props
|
||||
}: PromptInputTextareaProps) {
|
||||
const { value, setValue, maxHeight, onSubmit, disabled, textareaRef } =
|
||||
usePromptInput()
|
||||
|
||||
useEffect(() => {
|
||||
if (disableAutosize) return
|
||||
|
||||
if (!textareaRef.current) return
|
||||
|
||||
if (textareaRef.current.scrollTop === 0) {
|
||||
textareaRef.current.style.height = "auto"
|
||||
}
|
||||
|
||||
textareaRef.current.style.height =
|
||||
typeof maxHeight === "number"
|
||||
? `${Math.min(textareaRef.current.scrollHeight, maxHeight)}px`
|
||||
: `min(${textareaRef.current.scrollHeight}px, ${maxHeight})`
|
||||
}, [value, maxHeight, disableAutosize])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSubmit?.()
|
||||
}
|
||||
onKeyDown?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"text-primary min-h-[44px] w-full resize-none border-none bg-transparent shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
className
|
||||
)}
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type PromptInputActionsProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
function PromptInputActions({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: PromptInputActionsProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PromptInputActionProps = {
|
||||
className?: string
|
||||
tooltip: React.ReactNode
|
||||
children: React.ReactNode
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
} & React.ComponentProps<typeof Tooltip>
|
||||
|
||||
function PromptInputAction({
|
||||
tooltip,
|
||||
children,
|
||||
className,
|
||||
side = "top",
|
||||
...props
|
||||
}: PromptInputActionProps) {
|
||||
const { disabled } = usePromptInput()
|
||||
|
||||
return (
|
||||
<Tooltip {...props}>
|
||||
<TooltipTrigger asChild disabled={disabled} onClick={event => event.stopPropagation()}>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} className={className}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
PromptInput,
|
||||
PromptInputTextarea,
|
||||
PromptInputActions,
|
||||
PromptInputAction,
|
||||
}
|
||||
@@ -44,13 +44,13 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
|
||||
67
src/hooks/useSupabaseUpdateWithHistory.ts
Normal file
67
src/hooks/useSupabaseUpdateWithHistory.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/auth/supabase";
|
||||
|
||||
/**
|
||||
* Hook genérico para actualizar una tabla y guardar respaldo en historico_cambios
|
||||
*
|
||||
* @param tableName Nombre de la tabla a actualizar
|
||||
* @param idKey Campo que se usa para hacer eq (por defecto 'id')
|
||||
*/
|
||||
export function useSupabaseUpdateWithHistory<T extends Record<string, any>>(
|
||||
tableName: string,
|
||||
idKey: keyof T = "id" as keyof T
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Generar diferencias tipo JSON Patch
|
||||
function generateDiff(oldData: T, newData: Partial<T>) {
|
||||
const changes: any[] = [];
|
||||
for (const key of Object.keys(newData)) {
|
||||
const oldValue = (oldData as any)[key];
|
||||
const newValue = (newData as any)[key];
|
||||
if (newValue !== undefined && newValue !== oldValue) {
|
||||
changes.push({
|
||||
op: "replace",
|
||||
path: `/${key}`,
|
||||
from: oldValue,
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
oldData,
|
||||
newData,
|
||||
}: {
|
||||
oldData: T;
|
||||
newData: Partial<T>;
|
||||
}) => {
|
||||
const diff = generateDiff(oldData, newData);
|
||||
|
||||
if (diff.length > 0) {
|
||||
const { error: backupError } = await supabase
|
||||
.from("historico_cambios")
|
||||
.insert({
|
||||
id_plan_estudios: oldData.id ?? null, // ajusta si es otra tabla
|
||||
tabla_afectada: tableName,
|
||||
json_cambios: diff,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (backupError) throw backupError;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from(tableName)
|
||||
.update(newData)
|
||||
.eq(idKey as string, oldData[idKey]);
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
});
|
||||
|
||||
return { mutation };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// dummy test
|
||||
import { StrictMode } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
|
||||
@@ -73,7 +73,6 @@ function useUserDisplay() {
|
||||
avatar: claims?.avatar ?? null,
|
||||
initials: getInitials([nombre, apellidos].filter(Boolean).join(" ")),
|
||||
role,
|
||||
isAdmin: Boolean(claims?.claims_admin),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +149,7 @@ function Layout() {
|
||||
|
||||
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { claims } = useSupabaseAuth()
|
||||
const isAdmin = Boolean(claims?.claims_admin)
|
||||
const isAdmin = claims?.role === 'lci' || claims?.role === 'vicerrectoria'
|
||||
|
||||
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')
|
||||
|
||||
@@ -189,18 +188,18 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<Link
|
||||
to="/facultades"
|
||||
key='facultades'
|
||||
activeOptions={{ exact: true }}
|
||||
activeProps={{ className: "bg-primary/10 text-foreground" }}
|
||||
className="group inline-flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-muted-foreground hover:bg-primary/10 hover:text-foreground"
|
||||
>
|
||||
<KeySquare className="h-4 w-4" />
|
||||
<span className="truncate">Facultades</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/facultades"
|
||||
key='facultades'
|
||||
activeOptions={{ exact: true }}
|
||||
activeProps={{ className: "bg-primary/10 text-foreground" }}
|
||||
className="group inline-flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-muted-foreground hover:bg-primary/10 hover:text-foreground"
|
||||
>
|
||||
<KeySquare className="h-4 w-4" />
|
||||
<span className="truncate">Facultades</span>
|
||||
</Link>
|
||||
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
<Separator className="mt-auto" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// routes/_authenticated/archivos.tsx
|
||||
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||
import { useMemo, useState } from "react"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import { use, useMemo, useState } from "react"
|
||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||
import * as Icons from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -16,12 +16,13 @@ import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@
|
||||
import { DetailDialog } from "@/components/archivos/DetailDialog"
|
||||
|
||||
import type { RefRow } from "@/types/RefRow"
|
||||
import { uuid } from "zod"
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/archivos")({
|
||||
component: RouteComponent,
|
||||
loader: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("fine_tuning_referencias")
|
||||
.from("documentos")
|
||||
.select("*")
|
||||
.order("fecha_subida", { ascending: false })
|
||||
.limit(200)
|
||||
@@ -67,10 +68,25 @@ function RouteComponent() {
|
||||
async function remove(id: string) {
|
||||
if (!confirm("¿Eliminar archivo de referencia?")) return
|
||||
const { error } = await supabase
|
||||
.from("fine_tuning_referencias")
|
||||
.from("documentos")
|
||||
.delete()
|
||||
.eq("fine_tuning_referencias_id", id)
|
||||
.eq("documentos_id", id)
|
||||
if (error) return alert(error.message)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/eliminar/documento`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentos_id: id }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Se falló al eliminar el documento")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error al eliminar el documento:", err)
|
||||
}
|
||||
|
||||
router.invalidate()
|
||||
}
|
||||
|
||||
@@ -123,7 +139,7 @@ function RouteComponent() {
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered.map((r) => (
|
||||
<article
|
||||
key={r.fine_tuning_referencias_id}
|
||||
key={r.documentos_id}
|
||||
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
|
||||
>
|
||||
<header className="min-w-0">
|
||||
@@ -151,6 +167,7 @@ function RouteComponent() {
|
||||
<p className="text-sm text-neutral-700 line-clamp-3">{r.descripcion}</p>
|
||||
)}
|
||||
|
||||
{/* Tags
|
||||
{r.tags && r.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{r.tags.map((t, i) => (
|
||||
@@ -159,13 +176,13 @@ function RouteComponent() {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setViewing(r)}>
|
||||
<Icons.Eye className="w-4 h-4 mr-1" /> Ver
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => remove(r.fine_tuning_referencias_id)}>
|
||||
<Button variant="ghost" size="sm" onClick={() => remove(r.documentos_id)}>
|
||||
<Icons.Trash2 className="w-4 h-4 mr-1" /> Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
@@ -192,6 +209,7 @@ function RouteComponent() {
|
||||
function UploadDialog({
|
||||
open, onOpenChange, onDone,
|
||||
}: { open: boolean; onOpenChange: (o: boolean) => void; onDone: () => void }) {
|
||||
const supabaseAuth = useSupabaseAuth()
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [instrucciones, setInstrucciones] = useState("")
|
||||
const [tags, setTags] = useState("")
|
||||
@@ -222,6 +240,7 @@ function UploadDialog({
|
||||
prompt: instrucciones,
|
||||
fileBase64,
|
||||
insert: true,
|
||||
uuid: supabaseAuth.user?.id ?? null,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
@@ -234,21 +253,21 @@ function UploadDialog({
|
||||
try {
|
||||
const payload = await res.json()
|
||||
createdId =
|
||||
payload?.fine_tuning_referencias_id ||
|
||||
payload?.documentos_id ||
|
||||
payload?.id ||
|
||||
payload?.data?.fine_tuning_referencias_id ||
|
||||
payload?.data?.documentos_id ||
|
||||
null
|
||||
} catch { /* noop */ }
|
||||
|
||||
if (createdId && (tags.trim() || fuente.trim() || typeof interno === "boolean")) {
|
||||
await supabase
|
||||
.from("fine_tuning_referencias")
|
||||
.from("documentos")
|
||||
.update({
|
||||
tags: tags.trim() ? tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
|
||||
fuente_autoridad: fuente.trim() || undefined,
|
||||
interno,
|
||||
})
|
||||
.eq("fine_tuning_referencias_id", createdId)
|
||||
.eq("documentos_id", createdId)
|
||||
}
|
||||
|
||||
onOpenChange(false)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// routes/_authenticated/asignatura/$asignaturaId.tsx
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
|
||||
import * as Icons from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -134,7 +135,7 @@ function Page() {
|
||||
{/* ===== Hero ===== */}
|
||||
<div className="relative overflow-hidden rounded-3xl border shadow-sm">
|
||||
<div className={`absolute inset-0 bg-gradient-to-br ${style.halo} via-white to-transparent`} />
|
||||
<div className="relative p-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="relative p-6 flex flex-col grid grid-cols-1 gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="inline-flex items-center gap-2 text-xs text-neutral-600">
|
||||
<Icons.BookOpen className="h-4 w-4" /> Asignatura
|
||||
@@ -165,6 +166,7 @@ function Page() {
|
||||
</Button>
|
||||
<EditAsignaturaButton asignatura={a} onUpdate={setA} />
|
||||
<MejorarAIButton asignaturaId={a.id} onApply={(nuevo) => setA(nuevo)} />
|
||||
<BorrarAsignaturaButton asignatura_id={a.id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -191,7 +193,7 @@ function Page() {
|
||||
)}
|
||||
|
||||
{/* Syllabus */}
|
||||
{unidades.length > 0 && (
|
||||
|
||||
<Section id="syllabus" title="Programa / Contenidos" icon={Icons.ListTree}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="relative flex-1">
|
||||
@@ -285,7 +287,7 @@ function Page() {
|
||||
)
|
||||
})()}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Bibliografía */}
|
||||
@@ -401,29 +403,79 @@ function EditAsignaturaButton({ asignatura, onUpdate }: {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState<Partial<Asignatura>>({})
|
||||
const auth = useSupabaseAuth()
|
||||
|
||||
const openAndFill = () => { setForm(asignatura); setOpen(true) }
|
||||
|
||||
// ✅ Función que genera las diferencias entre los datos anteriores y los nuevos
|
||||
function generateDiff(oldData: Asignatura, newData: Partial<Asignatura>) {
|
||||
const changes: any[] = []
|
||||
for (const key of Object.keys(newData)) {
|
||||
const oldValue = (oldData as any)[key]
|
||||
const newValue = (newData as any)[key]
|
||||
if (newValue !== undefined && newValue !== oldValue) {
|
||||
changes.push({
|
||||
op: "replace",
|
||||
path: `/${key}`,
|
||||
from: oldValue,
|
||||
value: newValue
|
||||
})
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
const payload = {
|
||||
nombre: form.nombre ?? asignatura.nombre,
|
||||
clave: form.clave ?? asignatura.clave,
|
||||
tipo: form.tipo ?? asignatura.tipo,
|
||||
semestre: form.semestre ?? asignatura.semestre,
|
||||
creditos: form.creditos ?? asignatura.creditos,
|
||||
horas_teoricas: form.horas_teoricas ?? asignatura.horas_teoricas,
|
||||
horas_practicas: form.horas_practicas ?? asignatura.horas_practicas,
|
||||
try {
|
||||
// 1️⃣ Preparar el payload final
|
||||
const payload = {
|
||||
nombre: form.nombre ?? asignatura.nombre,
|
||||
clave: form.clave ?? asignatura.clave,
|
||||
tipo: form.tipo ?? asignatura.tipo,
|
||||
semestre: form.semestre ?? asignatura.semestre,
|
||||
creditos: form.creditos ?? asignatura.creditos,
|
||||
horas_teoricas: form.horas_teoricas ?? asignatura.horas_teoricas,
|
||||
horas_practicas: form.horas_practicas ?? asignatura.horas_practicas,
|
||||
}
|
||||
|
||||
// 2️⃣ Detectar cambios
|
||||
const diff = generateDiff(asignatura, payload)
|
||||
|
||||
// 3️⃣ Guardar respaldo si hubo cambios
|
||||
if (diff.length > 0) {
|
||||
const { error: backupError } = await supabase
|
||||
.from("historico_cambios_asignaturas") // 👈 usa el nombre real de tu tabla
|
||||
.insert({
|
||||
id_asignatura: asignatura.id,
|
||||
json_cambios: diff, // jsonb
|
||||
user_id: auth.user?.id,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
if (backupError) throw backupError
|
||||
}
|
||||
|
||||
// 4️⃣ Actualizar el registro principal
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.update(payload)
|
||||
.eq("id", asignatura.id)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// 5️⃣ Actualizar vista local
|
||||
if (data) {
|
||||
onUpdate(data as Asignatura)
|
||||
setOpen(false)
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(err.message ?? "Error al guardar")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.update(payload)
|
||||
.eq("id", asignatura.id)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
setSaving(false)
|
||||
if (!error && data) { onUpdate(data as Asignatura); setOpen(false) }
|
||||
else alert(error?.message ?? "Error al guardar")
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -578,6 +630,51 @@ function MejorarAIButton({ asignaturaId, onApply }: {
|
||||
)
|
||||
}
|
||||
|
||||
function BorrarAsignaturaButton({ asignatura_id, onDeleted }: { asignatura_id: string; onDeleted?: () => void }) {
|
||||
const [confirm, setConfirm] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
async function handleDelete() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const { error, status, statusText } = await supabase.from("asignaturas").delete().eq("id", asignatura_id)
|
||||
console.log({ status, statusText });
|
||||
|
||||
|
||||
if (error) throw error
|
||||
setConfirm(false)
|
||||
queryClient.invalidateQueries({ queryKey: ["asignaturas"] })
|
||||
if (onDeleted) onDeleted()
|
||||
router.navigate({ to: "/asignaturas", search: {
|
||||
q: "", // Término de búsqueda vacío
|
||||
planId: "", // ID del plan (vacío si no aplica)
|
||||
carreraId: "", // ID de la carrera (vacío si no aplica)
|
||||
facultadId: "", // ID de la facultad (vacío si no aplica)
|
||||
f: "", // Filtro vacío
|
||||
}})
|
||||
} catch (e: any) {
|
||||
alert(e?.message || "Error al eliminar la asignatura")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return confirm ? (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setConfirm(false)} disabled={loading}>Cancelar</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
|
||||
{loading ? "Eliminando…" : "Confirmar eliminación"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setConfirm(true)}>
|
||||
Eliminar asignatura
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
@@ -603,6 +700,7 @@ export function EditContenidosButton({
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [units, setUnits] = useState<UnitDraft[]>([])
|
||||
const [initialUnits, setInitialUnits] = useState<UnitDraft[]>([])
|
||||
const auth = useSupabaseAuth() // 👈 para registrar el usuario que edita
|
||||
|
||||
// --- Normaliza entrada flexible a estructura estable
|
||||
const normalize = useCallback((v: any): UnitDraft[] => {
|
||||
@@ -632,13 +730,13 @@ export function EditContenidosButton({
|
||||
}
|
||||
return { title, temas }
|
||||
})
|
||||
return entries.length ? entries : [{ title: "Unidad 1", temas: [] }]
|
||||
return entries.length ? entries : [{ title: "", temas: [] }]
|
||||
} catch {
|
||||
return [{ title: "Unidad 1", temas: [] }]
|
||||
return [{ title: "", temas: [] }]
|
||||
}
|
||||
}, [])
|
||||
|
||||
// --- Construye payload consistente { "1": { titulo, subtemas:{ "1": "t1" } } }
|
||||
// --- Construye payload consistente
|
||||
const buildPayload = useCallback((us: UnitDraft[]) => {
|
||||
const out: Record<string, any> = {}
|
||||
us.forEach((u, idx) => {
|
||||
@@ -650,14 +748,14 @@ export function EditContenidosButton({
|
||||
.forEach((t, i) => {
|
||||
sub[String(i + 1)] = t
|
||||
})
|
||||
out[k] = { titulo: (u.title || "").trim() || `Unidad ${k}`, subtemas: sub }
|
||||
out[k] = { titulo: (u.title || "").trim(), subtemas: sub }
|
||||
})
|
||||
return out
|
||||
}, [])
|
||||
|
||||
// --- Limpia UI: recorta espacios, elimina líneas vacías/duplicadas (case-insensitive)
|
||||
// --- Limpia UI
|
||||
const cleanUnits = useCallback((us: UnitDraft[]) => {
|
||||
return us.map((u, idx) => {
|
||||
return us.map((u) => {
|
||||
const seen = new Set<string>()
|
||||
const temas = u.temas
|
||||
.map((t) => t.trim())
|
||||
@@ -668,10 +766,7 @@ export function EditContenidosButton({
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
return {
|
||||
title: (u.title || "").trim() || `Unidad ${idx + 1}`,
|
||||
temas,
|
||||
}
|
||||
return { title: (u.title || "").trim(), temas }
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -687,7 +782,7 @@ export function EditContenidosButton({
|
||||
[units, initialUnits, cleanUnits],
|
||||
)
|
||||
|
||||
// --- Atajos: Guardar con Ctrl/Cmd + Enter
|
||||
// --- Atajos: Ctrl/Cmd + Enter
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -699,7 +794,6 @@ export function EditContenidosButton({
|
||||
}
|
||||
window.addEventListener("keydown", handler)
|
||||
return () => window.removeEventListener("keydown", handler)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, units, saving])
|
||||
|
||||
// --- Acciones por unidad
|
||||
@@ -707,15 +801,17 @@ export function EditContenidosButton({
|
||||
if (!confirm("¿Eliminar esta unidad?")) return
|
||||
setUnits((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const moveUnit = (idx: number, dir: -1 | 1) => {
|
||||
setUnits((prev) => {
|
||||
const next = [...prev]
|
||||
const j = idx + dir
|
||||
if (j < 0 || j >= next.length) return prev
|
||||
;[next[idx], next[j]] = [next[j], next[idx]]
|
||||
;[next[idx], next[j]] = [next[j], next[idx]]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const duplicateUnit = (idx: number) => {
|
||||
setUnits((prev) => {
|
||||
const next = [...prev]
|
||||
@@ -727,24 +823,54 @@ export function EditContenidosButton({
|
||||
})
|
||||
}
|
||||
|
||||
// ✅ Función para guardar con respaldo histórico
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
const cleaned = cleanUnits(units)
|
||||
const contenidos = buildPayload(cleaned)
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.update({ contenidos })
|
||||
.eq("id", asignaturaId)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
setSaving(false)
|
||||
if (error) {
|
||||
alert(error.message || "No se pudo guardar")
|
||||
return
|
||||
try {
|
||||
const cleaned = cleanUnits(units)
|
||||
const contenidos = buildPayload(cleaned)
|
||||
|
||||
// 1️⃣ Generar diff entre valor anterior y nuevo
|
||||
const diff = [
|
||||
{
|
||||
op: "replace",
|
||||
path: "/contenidos",
|
||||
from: value,
|
||||
value: contenidos,
|
||||
},
|
||||
]
|
||||
|
||||
// 2️⃣ Guardar respaldo en tabla de histórico (solo si hay cambios)
|
||||
if (JSON.stringify(value) !== JSON.stringify(contenidos)) {
|
||||
const { error: backupError } = await supabase
|
||||
.from("historico_cambios_asignaturas") // 👈 nombre de tu tabla de respaldo
|
||||
.insert({
|
||||
id_asignatura: asignaturaId,
|
||||
json_cambios: diff,
|
||||
user_id: auth.user?.id,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
if (backupError) throw backupError
|
||||
}
|
||||
|
||||
// 3️⃣ Actualizar campo contenidos
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.update({ contenidos })
|
||||
.eq("id", asignaturaId)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
|
||||
setInitialUnits(cleaned)
|
||||
onSaved((data as any)?.contenidos ?? contenidos)
|
||||
setOpen(false)
|
||||
} catch (err: any) {
|
||||
alert(err.message || "Error al guardar contenidos")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
setInitialUnits(cleaned)
|
||||
onSaved((data as any)?.contenidos ?? contenidos)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
@@ -839,7 +965,7 @@ export function EditContenidosButton({
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setUnits((prev) => [...prev, { title: `Unidad ${prev.length + 1}`, temas: [] }])
|
||||
setUnits((prev) => [...prev, { title: "", temas: [] }])
|
||||
}
|
||||
>
|
||||
<Icons.Plus className="w-4 h-4 mr-2" /> Agregar unidad
|
||||
@@ -850,7 +976,7 @@ export function EditContenidosButton({
|
||||
|
||||
<DialogFooter className="px-6 pb-5">
|
||||
<Button variant="outline" onClick={cancel}>Cancelar</Button>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
<Button onClick={save} disabled={saving || !hasChanges || units.some(u => !u.title.trim())}>
|
||||
{saving ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Icons.Loader2 className="h-4 w-4 animate-spin" /> Guardando…
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
||||
import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { supabase } from '@/auth/supabase'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
|
||||
@@ -10,6 +10,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { toast } from 'sonner'
|
||||
import { InfoChip } from '@/components/planes/InfoChip'
|
||||
|
||||
/* ================== Tipos ================== */
|
||||
type FacMini = { id: string; nombre: string; color?: string | null; icon?: string | null }
|
||||
@@ -79,7 +80,8 @@ async function fetchPlanIdsByScope(search: Pick<SearchState, 'planId' | 'carrera
|
||||
async function fetchAsignaturas(search: SearchState): Promise<Asignatura[]> {
|
||||
const planIds = await fetchPlanIdsByScope(search)
|
||||
if (planIds && planIds.length === 0) return []
|
||||
|
||||
console.log(AsignaturaCard);
|
||||
|
||||
let query = supabase
|
||||
.from('asignaturas')
|
||||
.select(`
|
||||
@@ -169,6 +171,60 @@ function RouteComponent() {
|
||||
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
|
||||
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
|
||||
|
||||
const [facultad, setFacultad] = useState("todas")
|
||||
const [carrera, setCarrera] = useState("todas")
|
||||
|
||||
/* useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
router.navigate({
|
||||
to: '/asignaturas',
|
||||
search: { ...search, q },
|
||||
replace: true,
|
||||
})
|
||||
}, 400)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [q]) */
|
||||
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const value = e.target.value
|
||||
setQ(value)
|
||||
router.navigate({
|
||||
to: '/asignaturas',
|
||||
search: {
|
||||
...search,
|
||||
q: value,
|
||||
},
|
||||
replace: true, // evita recargar o empujar al historial
|
||||
})
|
||||
}
|
||||
|
||||
// 🟣 Lista única de facultades
|
||||
const facultadesList = useMemo(() => {
|
||||
const unique = new Map<string, string>()
|
||||
planes?.forEach((p) => {
|
||||
const fac = p.carrera?.facultad
|
||||
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
|
||||
})
|
||||
return Array.from(unique.entries())
|
||||
}, [planes])
|
||||
|
||||
// 🎓 Lista de carreras según la facultad seleccionada
|
||||
const carrerasList = useMemo(() => {
|
||||
const unique = new Map<string, string>()
|
||||
planes?.forEach((p) => {
|
||||
if (
|
||||
p.carrera?.id &&
|
||||
p.carrera?.nombre &&
|
||||
(!facultad || facultad === "todas" || p.carrera?.facultad?.id === facultad)
|
||||
) {
|
||||
unique.set(p.carrera.id, p.carrera.nombre)
|
||||
}
|
||||
})
|
||||
return Array.from(unique.entries())
|
||||
}, [planes, facultad])
|
||||
|
||||
|
||||
// NEW: Clonado individual
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [cloneTarget, setCloneTarget] = useState<Asignatura | null>(null)
|
||||
@@ -217,28 +273,31 @@ function RouteComponent() {
|
||||
return { sinBibliografia, sinCriterios, sinContenidos }
|
||||
}, [asignaturas])
|
||||
|
||||
// Filtrado
|
||||
const filtered = useMemo(() => {
|
||||
const t = q.trim().toLowerCase()
|
||||
return asignaturas.filter(a => {
|
||||
const matchesQ =
|
||||
!t ||
|
||||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
|
||||
.filter(Boolean)
|
||||
.some(v => String(v).toLowerCase().includes(t))
|
||||
const t = q.trim().toLowerCase()
|
||||
return asignaturas.filter(a => {
|
||||
const matchesQ =
|
||||
!t ||
|
||||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
|
||||
.filter(Boolean)
|
||||
.some(v => String(v).toLowerCase().includes(t))
|
||||
|
||||
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
|
||||
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
|
||||
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
|
||||
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
|
||||
const carreraOK = carrera === 'todas' || a.plan?.carrera?.id === carrera
|
||||
const facultadOK = facultad === 'todas' || a.plan?.carrera?.facultad?.id === facultad
|
||||
const planOK = !search.planId || a.plan?.id === search.planId
|
||||
|
||||
const flagOK =
|
||||
!flag ||
|
||||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
|
||||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
|
||||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
|
||||
const flagOK =
|
||||
!flag ||
|
||||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
|
||||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
|
||||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
|
||||
|
||||
return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK && planOK
|
||||
})
|
||||
}, [q, sem, tipo, flag, carrera, facultad, asignaturas])
|
||||
|
||||
return matchesQ && semOK && tipoOK && flagOK
|
||||
})
|
||||
}, [q, sem, tipo, flag, asignaturas])
|
||||
|
||||
// Agrupación
|
||||
const groups = useMemo(() => {
|
||||
@@ -257,7 +316,19 @@ function RouteComponent() {
|
||||
}, [filtered, groupBy])
|
||||
|
||||
// Helpers
|
||||
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setFlag('') }
|
||||
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag('') ; setFacultad('todas')
|
||||
// Actualiza la URL limpiando todos los query params
|
||||
router.navigate({
|
||||
to: '/asignaturas',
|
||||
search: {
|
||||
q: '',
|
||||
planId: '',
|
||||
carreraId: '',
|
||||
facultadId: '',
|
||||
f: ''
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// NEW: util para clonar 1 asignatura
|
||||
async function cloneOne(src: Asignatura, overrides: {
|
||||
@@ -292,6 +363,8 @@ function RouteComponent() {
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
|
||||
|
||||
// NEW: abrir modal clon individual
|
||||
function openClone(a: Asignatura) {
|
||||
setCloneTarget(a)
|
||||
@@ -320,6 +393,8 @@ function RouteComponent() {
|
||||
setCart([])
|
||||
}
|
||||
|
||||
|
||||
|
||||
// NEW: clonado en lote
|
||||
async function cloneBulk() {
|
||||
if (!bulk.plan_destino_id) { toast.error('Selecciona un plan de destino'); return }
|
||||
@@ -394,51 +469,111 @@ function RouteComponent() {
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<div className="grid gap-4 sm:grid-cols-5 items-end">
|
||||
{/* 🔍 Búsqueda */}
|
||||
<div>
|
||||
<Label>Búsqueda</Label>
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onChange={handleChange}
|
||||
placeholder="Nombre, clave, plan, carrera, facultad…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 📘 Semestre */}
|
||||
<div>
|
||||
<Label>Semestre</Label>
|
||||
<Select value={sem} onValueChange={setSem}>
|
||||
<SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Todos" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
{semestres.map(s => <SelectItem key={s} value={s}>Semestre {s}</SelectItem>)}
|
||||
{semestres.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
Semestre {s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 🏛️ Facultad */}
|
||||
<div>
|
||||
<Label>Tipo</Label>
|
||||
<Select value={tipo} onValueChange={setTipo}>
|
||||
<SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
{tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
||||
<Label>Facultad</Label>
|
||||
<Select
|
||||
value={facultad ?? "todas"}
|
||||
onValueChange={(val) => {
|
||||
setFacultad(val)
|
||||
setCarrera("todas")
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Filtrar por facultad" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todas">Todas las facultades</SelectItem>
|
||||
{facultadesList.map(([id, nombre]) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Agrupación</Label>
|
||||
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
{/* 🎓 Carrera */}
|
||||
<div className={!facultad || facultad === "todas" ? "invisible" : ""}>
|
||||
<Label>Carrera</Label>
|
||||
<Select
|
||||
value={carrera ?? "todas"}
|
||||
onValueChange={(val) => setCarrera(val)}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Filtrar por carrera" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="semestre">Por semestre</SelectItem>
|
||||
<SelectItem value="ninguno">Sin agrupación</SelectItem>
|
||||
<SelectItem value="todas">Todas las carreras</SelectItem>
|
||||
{carrerasList.map(([id, nombre]) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 📜 Plan */}
|
||||
<div className={!carrera || carrera === "todas" ? "invisible" : ""}>
|
||||
<Label>Plan</Label>
|
||||
<Select
|
||||
value={search.planId ?? "todos"}
|
||||
onValueChange={(val) => {
|
||||
router.navigate({
|
||||
search: { ...search, planId: val === "todos" ? "" : val },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Filtrar por plan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todos los planes</SelectItem>
|
||||
{planes
|
||||
.filter((p) => p.carrera?.id === carrera)
|
||||
.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* Chips de salud */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<HealthChip
|
||||
@@ -462,7 +597,7 @@ function RouteComponent() {
|
||||
label="Sin contenidos"
|
||||
value={salud.sinContenidos}
|
||||
/>
|
||||
{(q || sem !== 'todos' || tipo !== 'todos' || flag) && (
|
||||
{/*(q || sem !== 'todos' || tipo !== 'todos' || flag || carrera=='todos')*/ true && (
|
||||
<Button variant="ghost" className="h-7 px-3" onClick={clearFilters}>
|
||||
Limpiar filtros
|
||||
</Button>
|
||||
@@ -694,9 +829,15 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
|
||||
const horasP = a.horas_practicas ?? 0
|
||||
const meta = tipoMeta(a.tipo)
|
||||
const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2
|
||||
|
||||
console.log(a);
|
||||
|
||||
return (
|
||||
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm hover:shadow-md transition-all">
|
||||
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm hover:shadow-md transition-all"
|
||||
style={{
|
||||
borderColor: a.plan?.carrera?.facultad?.color ?? '#ccc',
|
||||
backgroundColor: `${a.plan?.carrera?.facultad?.color}15`, // 15 = transparencia HEX
|
||||
}}
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border bg-white/80">
|
||||
@@ -747,14 +888,17 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
|
||||
<Icons.ScrollText className="w-3.5 h-3.5" /> <strong>Plan:</strong>{a.plan.nombre}
|
||||
</span>
|
||||
{a.plan.carrera && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icons.GraduationCap className="w-3.5 h-3.5" /> <strong>Carrera:</strong> {a.plan.carrera.nombre}
|
||||
</span>
|
||||
<InfoChip
|
||||
icon={<Icons.GraduationCap className="h-3 w-3" />}
|
||||
label={a.plan.carrera.nombre}
|
||||
/>
|
||||
)}
|
||||
{a.plan.carrera?.facultad && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<FacIcon className="w-3.5 h-3.5" /> {a.plan.carrera.facultad.nombre}
|
||||
</span>
|
||||
<InfoChip
|
||||
icon={<Icons.Building2 className="h-3 w-3" />}
|
||||
label={a.plan.carrera.facultad.nombre}
|
||||
tint={a.plan.carrera.facultad.color}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -126,6 +126,17 @@ function RouteComponent() {
|
||||
const [detail, setDetail] = useState<CarreraRow | null>(null)
|
||||
const [editCarrera, setEditCarrera] = useState<CarreraRow | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<CarreraRow | null>(null)
|
||||
|
||||
// ✅ Se declara UNA SOLA VEZ
|
||||
const { setOpen: setDeleteOpen, dialog: deleteDialog } = useDeleteCarreraDialog(
|
||||
deleteTarget?.id ?? "",
|
||||
async () => {
|
||||
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
|
||||
router.invalidate()
|
||||
// setDeleteTarget(null)
|
||||
}
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = q.trim().toLowerCase()
|
||||
@@ -198,10 +209,7 @@ function RouteComponent() {
|
||||
const border = tint(fac?.color, 0.28)
|
||||
const chip = tint(fac?.color, 0.1)
|
||||
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
|
||||
const { setOpen: setDeleteOpen, dialog: deleteDialog } = useDeleteCarreraDialog(c.id, async () => {
|
||||
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
|
||||
router.invalidate()
|
||||
})
|
||||
|
||||
return (
|
||||
<ContextMenu key={c.id}>
|
||||
<ContextMenuTrigger onClick={(e) => openContextMenu(e)}>
|
||||
@@ -233,11 +241,14 @@ function RouteComponent() {
|
||||
<ContextMenuItem onClick={() => setEditCarrera(c)}>
|
||||
<Icons.Pencil className="w-4 h-4 mr-2" /> Editar
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => setDeleteOpen(true)}>
|
||||
<ContextMenuItem onClick={() => {
|
||||
setDeleteTarget(c)
|
||||
setDeleteOpen(true)
|
||||
}}>
|
||||
<Icons.Trash className="w-4 h-4 mr-2" /> Eliminar
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
{deleteDialog}
|
||||
|
||||
</ContextMenu>
|
||||
)
|
||||
})}
|
||||
@@ -247,6 +258,8 @@ function RouteComponent() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{deleteDialog}
|
||||
|
||||
{/* Crear / Editar */}
|
||||
<CarreraFormDialog
|
||||
open={createOpen}
|
||||
|
||||
@@ -49,7 +49,7 @@ async function fetchDashboard(): Promise<LoaderData> {
|
||||
supabase
|
||||
.from('plan_estudios')
|
||||
.select(
|
||||
'id, nombre, fecha_creacion, objetivo_general, perfil_ingreso, perfil_egreso, sistema_evaluacion, total_creditos'
|
||||
'*'
|
||||
),
|
||||
supabase
|
||||
.from('asignaturas')
|
||||
@@ -175,7 +175,7 @@ function RouteComponent() {
|
||||
|
||||
const name = auth.user?.user_metadata?.nombre || auth.user?.email?.split('@')[0] || '¡Hola!'
|
||||
|
||||
const isAdmin = !!auth.claims?.claims_admin
|
||||
const isAdmin = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
|
||||
const role = auth.claims?.role as 'lci' | 'vicerrectoria' | 'secretario_academico' | 'jefe_carrera' | 'planeacion' | undefined
|
||||
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
|
||||
@@ -19,8 +19,10 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import { AuroraButton } from "@/components/effect/aurora-button"
|
||||
import { DeletePlanButton } from "@/components/planes/DeletePlan"
|
||||
import { AddAsignaturaButton } from "@/components/planes/AddAsignaturaButton"
|
||||
import { DescargarPdfButton } from "@/components/planes/GenerarPdfButton"
|
||||
import { DownloadPlanPDF } from "@/components/planes/DownloadPlanPDF"
|
||||
|
||||
type LoaderData = { planId: string }
|
||||
type LoaderData = { plan: PlanFull; asignaturas: AsignaturaLite[] }
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
||||
component: RouteComponent,
|
||||
@@ -33,24 +35,27 @@ export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
||||
|
||||
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
|
||||
const { planId } = params
|
||||
await Promise.all([
|
||||
if (!planId) throw new Error("planId is required")
|
||||
console.log("Cargando planId", planId)
|
||||
const [plan, asignaturas] = await Promise.all([
|
||||
queryClient.ensureQueryData(planByIdOptions(planId)),
|
||||
queryClient.ensureQueryData(asignaturasCountOptions(planId)),
|
||||
// queryClient.ensureQueryData(asignaturasCountOptions(planId)),
|
||||
queryClient.ensureQueryData(asignaturasPreviewOptions(planId)),
|
||||
])
|
||||
return { planId }
|
||||
|
||||
return { plan, asignaturas }
|
||||
},
|
||||
})
|
||||
|
||||
// ...existing code...
|
||||
function RouteComponent() {
|
||||
const qc = useQueryClient()
|
||||
const { planId } = Route.useLoaderData() as LoaderData
|
||||
const auth = useSupabaseAuth()
|
||||
//const { plan, asignaturas: asignaturasPreview } = Route.useLoaderData() as LoaderData
|
||||
const { plan } = Route.useLoaderData() as LoaderData
|
||||
|
||||
const { data: plan } = useSuspenseQuery(planByIdOptions(planId))
|
||||
const { data: asignaturasCount } = useSuspenseQuery(asignaturasCountOptions(planId))
|
||||
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(planId))
|
||||
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(plan.id))
|
||||
const auth = useSupabaseAuth()
|
||||
const asignaturasCount = asignaturasPreview.length
|
||||
|
||||
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
|
||||
const showCarrera = auth.claims?.role === 'secretario_academico'
|
||||
@@ -78,7 +83,7 @@ function RouteComponent() {
|
||||
</nav>
|
||||
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
|
||||
<div className="absolute inset-0 -z-0" style={accent} />
|
||||
<CardHeader className="relative z-10 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardHeader className="relative z-10 flex flex-col grid grid-cols-1 gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="hdr-icon inline-flex items-center justify-center rounded-2xl border px-3 py-2 bg-white/70"
|
||||
style={{ borderColor: accent.borderColor as string }}>
|
||||
@@ -98,18 +103,22 @@ function RouteComponent() {
|
||||
{plan.estado}
|
||||
</Badge>
|
||||
)}
|
||||
<div className='flex gap-2'>
|
||||
{/* <div className='flex gap-2'> */}
|
||||
<EditPlanButton plan={plan} />
|
||||
<AdjustAIButton plan={plan} />
|
||||
{/* <DescargarPdfButton planId={plan.id} opcion="plan" /> */}
|
||||
<DownloadPlanPDF plan={plan} />
|
||||
<DescargarPdfButton planId={plan.id} opcion="asignaturas" />
|
||||
<DeletePlanButton planId={plan.id} />
|
||||
</div>
|
||||
{/* </div> */}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent ref={statsRef}>
|
||||
<div className="academics relative z-10 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
|
||||
<StatCard label="Nivel" value={plan.nivel ?? "—"} Icon={Icons.GraduationCap} accent={facColor} />
|
||||
<StatCard label="Duración" value={plan.duracion ?? "—"} Icon={Icons.Clock} accent={facColor} />
|
||||
<StatCard label="Créditos" value={fmt(plan.total_creditos)} Icon={Icons.Coins} accent={facColor} />
|
||||
<StatCard label="Duración" value={plan.total_de_ciclos_del_plan_de_estudios ?? "—"} Icon={Icons.Clock} accent={facColor} />
|
||||
<StatCard label="Modalidad educativa" value={plan.modalidad_educativa ?? "—"} Icon={Icons.Layers} accent={facColor} />
|
||||
<StatCard label="Diseño curricular" value={plan.diseno_curricular ?? "—"} Icon={Icons.Layout} accent={facColor} />
|
||||
<StatCard label="Creado" value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : "—"} Icon={Icons.CalendarDays} accent={facColor} />
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -197,33 +206,77 @@ function StatCard({ label, value = "—", Icon = Icons.Info, accent, className =
|
||||
|
||||
/* ===== Editar ===== */
|
||||
function EditPlanButton({ plan }: { plan: PlanFull }) {
|
||||
const auth = useSupabaseAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form, setForm] = useState<Partial<PlanFull>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const qc = useQueryClient()
|
||||
|
||||
// Función para comparar valores y generar diffs tipo JSON Patch
|
||||
function generateDiff(oldData: PlanFull, newData: Partial<PlanFull>) {
|
||||
const changes: any[] = []
|
||||
for (const key of Object.keys(newData)) {
|
||||
const oldValue = (oldData as any)[key]
|
||||
const newValue = (newData as any)[key]
|
||||
if (newValue !== undefined && newValue !== oldValue) {
|
||||
changes.push({
|
||||
op: "replace",
|
||||
path: `/${key}`,
|
||||
from: oldValue,
|
||||
value: newValue
|
||||
})
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (payload: Partial<PlanFull>) => {
|
||||
const { error } = await supabase.from('plan_estudios').update({
|
||||
nombre: payload.nombre ?? plan.nombre,
|
||||
nivel: payload.nivel ?? plan.nivel,
|
||||
duracion: payload.duracion ?? plan.duracion,
|
||||
total_creditos: payload.total_creditos ?? plan.total_creditos,
|
||||
}).eq('id', plan.id)
|
||||
// 1️⃣ Generar las diferencias antes del update
|
||||
const diff = generateDiff(plan, payload)
|
||||
|
||||
// 2️⃣ Guardar respaldo (solo si hay cambios)
|
||||
if (diff.length > 0) {
|
||||
const { error: backupError } = await supabase.from("historico_cambios").insert({
|
||||
id_plan_estudios: plan.id,
|
||||
json_cambios: diff, // jsonb
|
||||
user_id:auth.user?.id,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
if (backupError) throw backupError
|
||||
}
|
||||
|
||||
// 3️⃣ Actualizar el plan principal
|
||||
const { error } = await supabase
|
||||
.from("plan_estudios")
|
||||
.update({
|
||||
nombre: payload.nombre ?? plan.nombre,
|
||||
nivel: payload.nivel ?? plan.nivel,
|
||||
duracion: payload.duracion ?? plan.duracion,
|
||||
total_creditos: payload.total_creditos ?? plan.total_creditos,
|
||||
})
|
||||
.eq("id", plan.id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
onMutate: async (payload) => {
|
||||
await qc.cancelQueries({ queryKey: planKeys.byId(plan.id) })
|
||||
const prev = qc.getQueryData<PlanFull>(planKeys.byId(plan.id))
|
||||
qc.setQueryData<PlanFull>(planKeys.byId(plan.id), (old) => old ? { ...old, ...payload } as PlanFull : old as any)
|
||||
qc.setQueryData<PlanFull>(
|
||||
planKeys.byId(plan.id),
|
||||
(old) => (old ? { ...old, ...payload } as PlanFull : old as any)
|
||||
)
|
||||
return { prev }
|
||||
},
|
||||
|
||||
onError: (_e, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(planKeys.byId(plan.id), ctx.prev)
|
||||
},
|
||||
|
||||
onSettled: async () => {
|
||||
await qc.invalidateQueries({ queryKey: planKeys.byId(plan.id) })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async function save() {
|
||||
|
||||
@@ -10,26 +10,44 @@ import { Plus, RefreshCcw, Building2 } from "lucide-react"
|
||||
import { InfoChip } from "@/components/planes/InfoChip"
|
||||
import { CreatePlanDialog } from "@/components/planes/CreatePlanDialog"
|
||||
import { chipTint } from "@/components/planes/chipTint"
|
||||
import { z } from 'zod'
|
||||
|
||||
|
||||
import { z } from "zod"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
export type PlanDeEstudios = {
|
||||
id: string; nombre: string; nivel: string | null; duracion: string | null;
|
||||
total_creditos: number | null; estado: string | null; fecha_creacion: string | null; carrera_id: string | null
|
||||
id: string
|
||||
nombre: string
|
||||
nivel: string | null
|
||||
duracion: string | null
|
||||
total_creditos: number | null
|
||||
estado: string | null
|
||||
fecha_creacion: string | null
|
||||
carrera_id: string | null
|
||||
}
|
||||
type PlanRow = PlanDeEstudios & {
|
||||
carreras: {
|
||||
id: string; nombre: string;
|
||||
facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null
|
||||
id: string
|
||||
nombre: string
|
||||
facultades?: {
|
||||
id: string
|
||||
nombre: string
|
||||
color?: string | null
|
||||
icon?: string | null
|
||||
} | null
|
||||
} | null
|
||||
}
|
||||
|
||||
const planSearchSchema = z.object({
|
||||
plan: z.string().nullable()
|
||||
plan: z.string().nullable(),
|
||||
facultad: z.string().nullable().optional(),
|
||||
carrera: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/planes")({
|
||||
component: RouteComponent,
|
||||
loader: async () => {
|
||||
@@ -51,86 +69,185 @@ export const Route = createFileRoute("/_authenticated/planes")({
|
||||
validateSearch: planSearchSchema,
|
||||
})
|
||||
|
||||
|
||||
function RouteComponent() {
|
||||
const auth = useSupabaseAuth()
|
||||
const { plan } = Route.useSearch()
|
||||
const { plan, facultad, carrera } = Route.useSearch()
|
||||
const [openCreate, setOpenCreate] = useState(false)
|
||||
const data = Route.useLoaderData() as PlanRow[]
|
||||
const router = useRouter()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
|
||||
const showFacultad =
|
||||
auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
|
||||
const showCarrera =
|
||||
showFacultad || auth.claims?.role === "secretario_academico"
|
||||
|
||||
const showFacultad = auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
|
||||
const showCarrera = showFacultad || auth.claims?.role === "secretario_academico"
|
||||
// 🟣 Lista única de facultades
|
||||
const facultadesList = useMemo(() => {
|
||||
const unique = new Map<string, string>()
|
||||
data?.forEach((p) => {
|
||||
const fac = p.carreras?.facultades
|
||||
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
|
||||
})
|
||||
return Array.from(unique.entries())
|
||||
}, [data])
|
||||
|
||||
// 🎓 Lista de carreras según facultad seleccionada
|
||||
const carrerasList = useMemo(() => {
|
||||
const unique = new Map<string, string>()
|
||||
data?.forEach((p) => {
|
||||
if (
|
||||
p.carreras?.id &&
|
||||
p.carreras?.nombre &&
|
||||
(!facultad || p.carreras?.facultades?.id === facultad)
|
||||
) {
|
||||
unique.set(p.carreras.id, p.carreras.nombre)
|
||||
}
|
||||
})
|
||||
return Array.from(unique.entries())
|
||||
}, [data, facultad])
|
||||
|
||||
// 🧩 Filtrado general
|
||||
const filtered = useMemo(() => {
|
||||
const term = plan?.trim().toLowerCase()
|
||||
if (!term || !data) return data
|
||||
return data.filter((p) =>
|
||||
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(term))
|
||||
)
|
||||
}, [plan, data])
|
||||
let results = data ?? []
|
||||
|
||||
if (term) {
|
||||
results = results.filter((p) =>
|
||||
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(term))
|
||||
)
|
||||
}
|
||||
|
||||
if (facultad && facultad !== "todas") {
|
||||
results = results.filter((p) => p.carreras?.facultades?.id === facultad)
|
||||
}
|
||||
|
||||
if (carrera && carrera !== "todas") {
|
||||
results = results.filter((p) => p.carreras?.id === carrera)
|
||||
}
|
||||
|
||||
return results
|
||||
}, [plan, facultad, carrera, data])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle className="text-xl font-mono">Planes de estudio</CardTitle>
|
||||
<div className="flex w-full items-center gap-2 md:w-auto">
|
||||
|
||||
<div className="flex w-full flex-col md:flex-row items-center gap-2 md:w-auto">
|
||||
{/* 🔍 Buscador */}
|
||||
<div className="relative w-full md:w-80">
|
||||
<Input
|
||||
value={plan ?? ''}
|
||||
onChange={e => navigate({ search: { plan: e.target.value } })}
|
||||
value={plan ?? ""}
|
||||
onChange={(e) =>
|
||||
navigate({ search: { plan: e.target.value, facultad, carrera } })
|
||||
}
|
||||
placeholder="Buscar por nombre, nivel, estado…"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
|
||||
|
||||
{/* 🏛️ Filtro por facultad */}
|
||||
<Select
|
||||
value={facultad ?? "todas"}
|
||||
onValueChange={(val) =>
|
||||
navigate({ search: { plan, facultad: val, carrera: "todas" } })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Filtrar por facultad" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todas">Todas las facultades</SelectItem>
|
||||
{facultadesList.map(([id, nombre]) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 🎓 Filtro por carrera (según facultad) */}
|
||||
{facultad && facultad !== "todas" && (
|
||||
<Select
|
||||
value={carrera ?? "todas"}
|
||||
onValueChange={(val) =>
|
||||
navigate({ search: { plan, facultad, carrera: val } })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Filtrar por carrera" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todas">Todas las carreras</SelectItem>
|
||||
{carrerasList.map(([id, nombre]) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
|
||||
{/* 🔁 Recargar */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => router.invalidate()}
|
||||
title="Recargar"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* ➕ Nuevo plan */}
|
||||
<Button onClick={() => setOpenCreate(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Nuevo plan
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* GRID de tarjetas con estilo suave por facultad */}
|
||||
{/* GRID de tarjetas */}
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered?.map((p) => {
|
||||
const fac = p.carreras?.facultades
|
||||
const styles = chipTint(fac?.color)
|
||||
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Building2
|
||||
const IconComp =
|
||||
(fac?.icon && (Icons as any)[fac.icon]) || Building2
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={p.id}
|
||||
to="/plan/$planId"
|
||||
mask={{ to: '/plan/$planId', params: { planId: p.id } }}
|
||||
mask={{ to: "/plan/$planId", params: { planId: p.id } }}
|
||||
className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
params={{ planId: p.id }}
|
||||
style={styles}
|
||||
>
|
||||
<div className="relative p-5 h-40 flex flex-col justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2"
|
||||
style={{ borderColor: styles.borderColor as string, background: 'rgba(255,255,255,.6)' }}>
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2"
|
||||
style={{
|
||||
borderColor: styles.borderColor as string,
|
||||
background: "rgba(255,255,255,.6)",
|
||||
}}
|
||||
>
|
||||
<IconComp className="w-6 h-6" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold truncate">{p.nombre}</div>
|
||||
<div className="text-xs text-neutral-600 truncate">
|
||||
{p.nivel ?? "—"} {p.duracion ? `· ${p.duracion}` : ""}
|
||||
{p.nivel ?? "—"}{" "}
|
||||
{p.duracion ? `· ${p.duracion}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dentro del map de tarjetas, sustituye SOLO el footer inferior */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{/* grupo izquierdo: chips (wrap si no caben) */}
|
||||
<div className="min-w-0 flex-1 flex flex-wrap items-center gap-2">
|
||||
{showCarrera && p.carreras?.nombre && (
|
||||
<InfoChip
|
||||
@@ -147,18 +264,21 @@ function RouteComponent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* derecha: estado */}
|
||||
{p.estado && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-white/60"
|
||||
style={{ borderColor: (chipTint(fac?.color).borderColor as string) }}
|
||||
style={{
|
||||
borderColor:
|
||||
chipTint(fac?.color).borderColor as string,
|
||||
}}
|
||||
>
|
||||
{p.estado}
|
||||
{p.estado.length > 10
|
||||
? `${p.estado.slice(0, 10)}…`
|
||||
: p.estado}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
@@ -166,16 +286,14 @@ function RouteComponent() {
|
||||
</div>
|
||||
|
||||
{!filtered?.length && (
|
||||
<div className="text-center text-sm text-muted-foreground py-10">Sin resultados</div>
|
||||
<div className="text-center text-sm text-muted-foreground py-10">
|
||||
Sin resultados
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreatePlanDialog
|
||||
open={openCreate}
|
||||
onOpenChange={setOpenCreate}
|
||||
/>
|
||||
|
||||
<CreatePlanDialog open={openCreate} onOpenChange={setOpenCreate} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useSuspenseQuery, queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||
import type { Role, UserClaims } from "@/auth/supabase"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -10,58 +11,19 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
||||
import {
|
||||
RefreshCcw, ShieldCheck, ShieldAlert, Pencil, Mail,
|
||||
Cpu, Building2, ScrollText, GraduationCap, GanttChart, Plus, Eye, EyeOff,
|
||||
Ban as BanIcon, Check
|
||||
} from "lucide-react"
|
||||
import { SupabaseClient } from "@supabase/supabase-js"
|
||||
import * as Icons from "lucide-react"
|
||||
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
||||
import { toast } from "sonner"
|
||||
|
||||
|
||||
|
||||
/* -------------------- Tipos -------------------- */
|
||||
type AdminUser = {
|
||||
id: string
|
||||
email: string | null
|
||||
created_at: string
|
||||
last_sign_in_at: string | null
|
||||
user_metadata: any
|
||||
app_metadata: any
|
||||
banned_until?: string | null
|
||||
}
|
||||
|
||||
|
||||
const SCOPED_ROLES = ["director_facultad", "secretario_academico", "jefe_carrera"] as const
|
||||
|
||||
const ROLES = [
|
||||
"lci",
|
||||
"vicerrectoria",
|
||||
"director_facultad",
|
||||
"secretario_academico",
|
||||
"jefe_carrera",
|
||||
"planeacion",
|
||||
] as const
|
||||
export type Role = typeof ROLES[number]
|
||||
|
||||
const ROLE_META: Record<Role, { label: string; Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; className: string }> = {
|
||||
lci: { label: "Laboratorio de Cómputo de Ingeniería", Icon: Cpu, className: "bg-neutral-900 text-white" },
|
||||
vicerrectoria: { label: "Vicerrectoría Académica", Icon: Building2, className: "bg-indigo-600 text-white" },
|
||||
director_facultad: { label: "Director(a) de Facultad", Icon: Building2, className: "bg-purple-600 text-white" },
|
||||
secretario_academico: { label: "Secretario Académico", Icon: ScrollText, className: "bg-emerald-600 text-white" },
|
||||
jefe_carrera: { label: "Jefe de Carrera", Icon: GraduationCap, className: "bg-orange-600 text-white" },
|
||||
planeacion: { label: "Planeación Curricular", Icon: GanttChart, className: "bg-sky-600 text-white" },
|
||||
}
|
||||
|
||||
function RolePill({ role }: { role: Role }) {
|
||||
const meta = ROLE_META[role]
|
||||
if (!meta) return null
|
||||
const { Icon, className, label } = meta
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:text-[11px] ${className} max-w-[160px] sm:max-w-none truncate`} title={label}>
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/* -------------------- Query Keys & Fetcher -------------------- */
|
||||
const usersKeys = {
|
||||
@@ -69,13 +31,58 @@ const usersKeys = {
|
||||
list: () => [...usersKeys.root, "list"] as const,
|
||||
}
|
||||
|
||||
async function fetchUsers(): Promise<AdminUser[]> {
|
||||
// ⚠️ Dev only: service role en cliente
|
||||
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
|
||||
const { data } = await admin.auth.admin.listUsers()
|
||||
return (data?.users ?? []) as AdminUser[]
|
||||
async function fetchUsers(): Promise<UserClaims[]> {
|
||||
try {
|
||||
const { data: perfiles, error } = await supabase.from("perfiles").select("id");
|
||||
|
||||
if (error) {
|
||||
console.error("Error al obtener usuarios:", error.message);
|
||||
return []; // Devuelve un arreglo vacío en caso de error
|
||||
}
|
||||
|
||||
if (!perfiles || perfiles.length === 0) {
|
||||
console.log("No se encontraron perfiles.");
|
||||
return []; // Devuelve un arreglo vacío si no hay datos
|
||||
}
|
||||
|
||||
// Llama a `obtener_claims_usuario` para cada perfil
|
||||
const usuarios = await Promise.all(
|
||||
perfiles.map(async (perfil) => {
|
||||
const { data: claims, error: rpcError } = await supabase.rpc("obtener_claims_usuario", {
|
||||
p_user_id: perfil.id, // Pasa el ID del perfil como parámetro
|
||||
});
|
||||
|
||||
console.log("Claims para perfil", perfil.id, claims[0]);
|
||||
if (rpcError) {
|
||||
console.error(`Error al obtener claims para el perfil ${perfil.id}:`, rpcError.message);
|
||||
return null; // Devuelve null si hay un error
|
||||
}
|
||||
|
||||
return {
|
||||
id: perfil.id,
|
||||
role: claims[0]?.role,
|
||||
title: claims[0]?.title,
|
||||
facultad_id: claims[0]?.facultad_id,
|
||||
carrera_id: claims[0]?.carrera_id,
|
||||
facultad_color: claims[0]?.facultad_color,
|
||||
clave: claims[0]?.clave,
|
||||
nombre: claims[0]?.nombre,
|
||||
apellidos: claims[0]?.apellidos,
|
||||
avatar: claims[0]?.avatar,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Filtra los resultados nulos (errores en las llamadas RPC)
|
||||
return usuarios.filter((u) => u !== null) as UserClaims[];
|
||||
} catch (err) {
|
||||
console.error("Error inesperado:", err);
|
||||
return []; // Devuelve un arreglo vacío en caso de error inesperado
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const usersOptions = () =>
|
||||
queryOptions({ queryKey: usersKeys.list(), queryFn: fetchUsers, staleTime: 60_000 })
|
||||
|
||||
@@ -91,12 +98,37 @@ export const Route = createFileRoute("/_authenticated/usuarios")({
|
||||
/* -------------------- Página -------------------- */
|
||||
function RouteComponent() {
|
||||
const auth = useSupabaseAuth()
|
||||
|
||||
if (auth.claims?.role !== "lci" && auth.claims?.role !== "vicerrectoria") {
|
||||
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
|
||||
}
|
||||
|
||||
const { ROLES, ROLE_META } = useMemo(() => {
|
||||
if (!auth.roles) return { ROLES: [], ROLE_META: {} };
|
||||
|
||||
// Construir ROLES como un arreglo de strings
|
||||
const rolesArray = auth.roles.map((role) => role.nombre);
|
||||
|
||||
// Construir ROLE_META como un objeto basado en ROLES
|
||||
const rolesMeta = auth.roles.reduce((acc, role) => {
|
||||
acc[role.nombre] = {
|
||||
id: role.id,
|
||||
label: role.label,
|
||||
Icon: (Icons as any)[role.icono] || Icons.Cpu, // Icono por defecto si no está definido
|
||||
className: /* role.nombre_clase || */ "bg-gray-500 text-white", // Clase por defecto si no está definida
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, { id: string; label: string; Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; className: string }>);
|
||||
|
||||
return { ROLES: rolesArray, ROLE_META: rolesMeta };
|
||||
}, [auth.roles]);
|
||||
|
||||
const router = useRouter()
|
||||
const qc = useQueryClient()
|
||||
const { data } = useSuspenseQuery(usersOptions())
|
||||
|
||||
const [q, setQ] = useState("")
|
||||
const [editing, setEditing] = useState<AdminUser | null>(null)
|
||||
const [editing, setEditing] = useState<UserClaims | null>(null)
|
||||
const [form, setForm] = useState<{
|
||||
role?: Role
|
||||
claims_admin?: boolean
|
||||
@@ -118,10 +150,47 @@ function RouteComponent() {
|
||||
}>({ email: "", password: "" })
|
||||
|
||||
function genPassword() {
|
||||
/*
|
||||
Supabase requiere que las contraseñas tengan las siguientes características:
|
||||
- Mínimo de 6 caracteres
|
||||
- Debe contener al menos una letra minúscula
|
||||
- Debe contener al menos una letra mayúscula
|
||||
- Debe contener al menos un número
|
||||
- Debe contener al menos un carácter especial
|
||||
Para garantizar la seguridad, generaremos contraseñas de 12 caracteres en vez del mínimo de 6
|
||||
*/
|
||||
|
||||
// 1. Generar una permutación de los números de 1 al 12 con el método Fisher-Yates
|
||||
|
||||
const positions = Array.from({ length: 12 }, (_, i) => i);
|
||||
for (let i = positions.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[positions[i], positions[j]] = [positions[j], positions[i]];
|
||||
}
|
||||
|
||||
// 2. Las correspondencias son las siguientes:
|
||||
// - El primer número indica la posición de la letra minúscula
|
||||
// - El segundo número indica la posición de la letra mayúscula
|
||||
// - El tercer número indica la posición del número
|
||||
// - El cuarto número indica la posición del carácter especial
|
||||
// - En las demás posiciones puede haber cualquier caracter alfanumérico
|
||||
|
||||
const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("")
|
||||
return s.slice(0, 14)
|
||||
}
|
||||
|
||||
function RolePill({ role }: { role: Role }) {
|
||||
const meta = ROLE_META[role]
|
||||
if (!meta) return null
|
||||
const { Icon, className, label } = meta
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:text-[11px] ${className} max-w-[160px] sm:max-w-none truncate`} title={label}>
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- Mutations ---------- */
|
||||
const invalidateAll = async () => {
|
||||
await qc.invalidateQueries({ queryKey: usersKeys.root })
|
||||
@@ -167,11 +236,13 @@ function RouteComponent() {
|
||||
})
|
||||
|
||||
const toggleBan = useMutation({
|
||||
mutationFn: async (u: AdminUser) => {
|
||||
const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
|
||||
mutationFn: async (u: UserClaims) => {
|
||||
throw new Error("Funcionalidad de baneo no implementada aún.")
|
||||
const banned = false // cuando se tenga acceso a ese campo
|
||||
// const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
|
||||
const payload = banned ? { banned_until: null } : { banned_until: new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString() }
|
||||
const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
|
||||
if (error) throw new Error(error.message)
|
||||
// const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
|
||||
// if (error) throw new Error(error.message)
|
||||
return !banned
|
||||
},
|
||||
onSuccess: async (isBanned) => {
|
||||
@@ -183,39 +254,42 @@ function RouteComponent() {
|
||||
|
||||
const createUser = useMutation({
|
||||
mutationFn: async (payload: typeof createForm) => {
|
||||
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
|
||||
const password = payload.password?.trim() || genPassword()
|
||||
const { error, data } = await admin.auth.admin.createUser({
|
||||
// Validaciones previas
|
||||
if (!payload.role) {
|
||||
throw new Error("Selecciona un rol para el usuario.");
|
||||
}
|
||||
if ((payload.role === "secretario_academico" || payload.role === "director_facultad") && !payload.facultad_id) {
|
||||
throw new Error("Selecciona una facultad para este rol.");
|
||||
}
|
||||
if (payload.role === "jefe_carrera" && (!payload.facultad_id || !payload.carrera_id)) {
|
||||
throw new Error("Selecciona una facultad y una carrera para este rol.");
|
||||
}
|
||||
|
||||
const password = payload.password?.trim()
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: payload.email.trim(),
|
||||
password,
|
||||
email_confirm: true,
|
||||
user_metadata: {
|
||||
nombre: payload.nombre ?? "",
|
||||
apellidos: payload.apellidos ?? "",
|
||||
title: payload.title ?? "",
|
||||
clave: payload.clave ?? "",
|
||||
avatar: payload.avatar ?? "",
|
||||
},
|
||||
app_metadata: {
|
||||
role: payload.role,
|
||||
claims_admin: !!payload.claims_admin,
|
||||
facultad_id: payload.facultad_id ?? null,
|
||||
carrera_id: payload.carrera_id ?? null,
|
||||
},
|
||||
})
|
||||
if (error) throw new Error(error.message)
|
||||
const uid = data.user?.id
|
||||
if (uid && payload.role && (SCOPED_ROLES as readonly string[]).includes(payload.role)) {
|
||||
if (payload.role === "director_facultad") {
|
||||
if (!payload.facultad_id) throw new Error("Selecciona facultad")
|
||||
await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "director_facultad", facultad_id: payload.facultad_id })
|
||||
} else if (payload.role === "secretario_academico") {
|
||||
if (!payload.facultad_id) throw new Error("Selecciona facultad")
|
||||
await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "secretario_academico", facultad_id: payload.facultad_id })
|
||||
} else if (payload.role === "jefe_carrera") {
|
||||
if (!payload.facultad_id || !payload.carrera_id) throw new Error("Selecciona facultad y carrera")
|
||||
await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "jefe_carrera", facultad_id: payload.facultad_id, carrera_id: payload.carrera_id })
|
||||
options: {
|
||||
data: {
|
||||
nombre: payload.nombre ?? "",
|
||||
apellidos: payload.apellidos ?? "",
|
||||
title: payload.title ?? "",
|
||||
clave: payload.clave ?? "",
|
||||
avatar: payload.avatar ?? "",
|
||||
role: payload.role,
|
||||
role_id: payload.role ? ROLE_META[payload.role]?.id : null,
|
||||
facultad_id: payload.facultad_id ?? null,
|
||||
carrera_id: payload.carrera_id ?? null,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (error) throw new Error(error.message)
|
||||
|
||||
const uid = data.user?.id
|
||||
|
||||
if(!uid) {
|
||||
throw new Error("No se pudo obtener el ID del usuario creado.");
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
@@ -228,19 +302,23 @@ function RouteComponent() {
|
||||
})
|
||||
|
||||
const saveUser = useMutation({
|
||||
mutationFn: async ({ u, f }: { u: AdminUser; f: typeof form }) => {
|
||||
// 1) Actualiza metadatos (tu Edge Function; placeholder aquí)
|
||||
// await fetch('/functions/update-user', { method: 'POST', body: JSON.stringify({ id: u.id, ...f }) })
|
||||
// Simula éxito:
|
||||
// 2) Nombramiento si aplica
|
||||
if (f.role && (SCOPED_ROLES as readonly string[]).includes(f.role)) {
|
||||
if (f.role === "director_facultad") {
|
||||
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "director_facultad", facultad_id: f.facultad_id! })
|
||||
} else if (f.role === "secretario_academico") {
|
||||
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "secretario_academico", facultad_id: f.facultad_id! })
|
||||
} else if (f.role === "jefe_carrera") {
|
||||
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "jefe_carrera", facultad_id: f.facultad_id!, carrera_id: f.carrera_id! })
|
||||
mutationFn: async ({ u, f }: { u: UserClaims; f: typeof form }) => {
|
||||
|
||||
const { error } = await supabase.rpc('actualizar_perfil_y_rol', {
|
||||
datos: {
|
||||
user_id: u.id,
|
||||
rol_nombre: f.role,
|
||||
titulo: f.title,
|
||||
facultad_id: f.facultad_id,
|
||||
carrera_id: f.carrera_id,
|
||||
nombre: f.nombre,
|
||||
apellidos: f.apellidos,
|
||||
avatar: f.avatar,
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
@@ -251,34 +329,29 @@ function RouteComponent() {
|
||||
onError: (e: any) => toast.error(e?.message || "No se pudo guardar"),
|
||||
})
|
||||
|
||||
if (!auth.claims?.claims_admin) {
|
||||
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const t = q.trim().toLowerCase()
|
||||
if (!t) return data
|
||||
return data.filter((u) => {
|
||||
const role: Role | undefined = u.app_metadata?.role
|
||||
const role: Role | undefined = u.role
|
||||
const label = role ? ROLE_META[role]?.label : ""
|
||||
return [u.email, u.user_metadata?.nombre, u.user_metadata?.apellidos, label]
|
||||
return [u.nombre, u.apellidos, label]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(t))
|
||||
})
|
||||
}, [q, data])
|
||||
|
||||
function openEdit(u: AdminUser) {
|
||||
function openEdit(u: UserClaims) {
|
||||
setEditing(u)
|
||||
setForm({
|
||||
role: u.app_metadata?.role,
|
||||
claims_admin: !!u.app_metadata?.claims_admin,
|
||||
nombre: u.user_metadata?.nombre ?? "",
|
||||
apellidos: u.user_metadata?.apellidos ?? "",
|
||||
title: u.user_metadata?.title ?? "",
|
||||
clave: u.user_metadata?.clave ?? "",
|
||||
avatar: u.user_metadata?.avatar ?? "",
|
||||
facultad_id: u.app_metadata?.facultad_id ?? null,
|
||||
carrera_id: u.app_metadata?.carrera_id ?? null,
|
||||
role: u.role,
|
||||
nombre: u.nombre ?? "",
|
||||
apellidos: u.apellidos ?? "",
|
||||
title: u.title ?? "",
|
||||
clave: u.clave ?? "",
|
||||
avatar: u.avatar ?? "",
|
||||
facultad_id: u.facultad_id ?? null,
|
||||
carrera_id: u.carrera_id ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -301,10 +374,10 @@ function RouteComponent() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Input placeholder="Buscar por nombre, email o rol…" value={q} onChange={(e) => setQ(e.target.value)} className="w-full" />
|
||||
<Button variant="outline" size="icon" title="Recargar" onClick={invalidateAll}>
|
||||
<RefreshCcw className="w-4 h-4" />
|
||||
<Icons.RefreshCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap">
|
||||
<Plus className="w-4 h-4 mr-1" /> Nuevo usuario
|
||||
<Icons.Plus className="w-4 h-4 mr-1" /> Nuevo usuario
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -312,48 +385,48 @@ function RouteComponent() {
|
||||
<CardContent>
|
||||
<div className="grid gap-3">
|
||||
{filtered.map((u) => {
|
||||
const m = u.user_metadata || {}
|
||||
const a = u.app_metadata || {}
|
||||
const roleCode: Role | undefined = a.role
|
||||
const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now()
|
||||
const roleCode: Role | undefined = u.role
|
||||
const banned = false // cuando se tenga acceso a ese campo
|
||||
// const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now()
|
||||
return (
|
||||
<div key={u.id} className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 sm:p-4 hover:shadow-sm transition">
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<img src={m.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(m.nombre || u.email || "U")}`} alt="" className="h-10 w-10 rounded-full object-cover" />
|
||||
<img src={u.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(u.nombre || /* u.email || */ "U")}`} alt="" className="h-10 w-10 rounded-full object-cover" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{m.title ? `${m.title} ` : ""}{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}</div>
|
||||
<div className="font-medium truncate">{u.title ? `${u.title} ` : ""}{u.nombre ? `${u.nombre} ${u.apellidos ?? ""}` : /* (u.email ?? "—") */ "—"}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 sm:gap-2">
|
||||
{roleCode && <RolePill role={roleCode} />}
|
||||
{a.claims_admin ? (
|
||||
<Badge className="gap-1" variant="secondary"><ShieldCheck className="w-3 h-3" /> Admin</Badge>
|
||||
{u.role === "lci" || u.role === "vicerrectoria" ? (
|
||||
<Badge className="gap-1" variant="secondary"><Icons.ShieldCheck className="w-3 h-3" /> Admin</Badge>
|
||||
) : (
|
||||
<Badge className="gap-1" variant="outline"><ShieldAlert className="w-3 h-3" /> Usuario</Badge>
|
||||
<Badge className="gap-1" variant="outline"><Icons.ShieldAlert className="w-3 h-3" /> Usuario</Badge>
|
||||
)}
|
||||
<Badge variant={banned ? ("destructive" as any) : "secondary"} className="gap-1">
|
||||
{banned ? <BanIcon className="w-3 h-3" /> : <Check className="w-3 h-3" />} {banned ? "Baneado" : "Activo"}
|
||||
{banned ? <Icons.BanIcon className="w-3 h-3" /> : <Icons.Check className="w-3 h-3" />} {banned ? "Baneado" : "Activo"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="sm" onClick={() => toggleBan.mutate(u)} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
|
||||
<BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
|
||||
<Icons.BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
|
||||
<Pencil className="w-4 h-4 mr-1" /> Editar
|
||||
<Icons.Pencil className="w-4 h-4 mr-1" /> Editar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] sm:text-xs text-neutral-600">
|
||||
<span className="inline-flex items-center gap-1"><Mail className="w-3 h-3" /> {u.email ?? "—"}</span>
|
||||
{/* Cuando se tenga acceso a esta info, se mostrará
|
||||
<span className="inline-flex items-center gap-1"><Icons.Mail className="w-3 h-3" /> {u.email ?? "—"}</span>
|
||||
<span className="hidden xs:inline">Creado: {new Date(u.created_at).toLocaleDateString()}</span>
|
||||
<span className="hidden md:inline">Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}</span>
|
||||
<span className="hidden md:inline">Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}</span> */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:hidden self-start shrink-0 flex gap-1">
|
||||
<Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><BanIcon className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Pencil className="w-4 h-4" /></Button>
|
||||
<Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><Icons.BanIcon className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Icons.Pencil className="w-4 h-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -371,7 +444,7 @@ function RouteComponent() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1"><Label>Nombre</Label><Input value={form.nombre ?? ""} onChange={(e) => setForm((s) => ({ ...s, nombre: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Apellidos</Label><Input value={form.apellidos ?? ""} onChange={(e) => setForm((s) => ({ ...s, apellidos: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Título</Label><Input value={form.title ?? ""} onChange={(e) => setForm((s) => ({ ...s, title: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Título <small>(opcional)</small></Label><Input value={form.title ?? ""} onChange={(e) => setForm((s) => ({ ...s, title: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Clave</Label><Input value={form.clave ?? ""} onChange={(e) => setForm((s) => ({ ...s, clave: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Avatar (URL)</Label><Input value={form.avatar ?? ""} onChange={(e) => setForm((s) => ({ ...s, avatar: e.target.value }))} /></div>
|
||||
<div className="space-y-1">
|
||||
@@ -422,6 +495,7 @@ function RouteComponent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Probablemente ya no sea necesario
|
||||
<div className="space-y-1">
|
||||
<Label>Permisos</Label>
|
||||
<Select value={String(!!form.claims_admin)} onValueChange={(v) => setForm((s) => ({ ...s, claims_admin: v === 'true' }))}>
|
||||
@@ -431,7 +505,7 @@ function RouteComponent() {
|
||||
<SelectItem value="false">Usuario</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
||||
@@ -463,16 +537,16 @@ function RouteComponent() {
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Contraseña temporal</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input type={showPwd ? "text" : "password"} value={createForm.password} onChange={(e) => setCreateForm((s) => ({ ...s, password: e.target.value }))} placeholder="Se generará si la dejas vacía" />
|
||||
<Button type="button" variant="outline" onClick={() => setCreateForm((s) => ({ ...s, password: genPassword() }))}>Generar</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setShowPwd((v) => !v)} aria-label="Mostrar u ocultar">{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}</Button>
|
||||
<Input type={showPwd ? "text" : "password"} value={createForm.password} onChange={(e) => setCreateForm((s) => ({ ...s, password: e.target.value }))} placeholder="abCD12&;" />
|
||||
{/* <Button type="button" variant="outline" onClick={() => setCreateForm((s) => ({ ...s, password: genPassword() }))}>Generar</Button> */}
|
||||
<Button type="button" variant="outline" onClick={() => setShowPwd((v) => !v)} aria-label="Mostrar u ocultar">{showPwd ? <Icons.EyeOff className="w-4 h-4" /> : <Icons.Eye className="w-4 h-4" />}</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-neutral-500">Pídeles cambiarla al iniciar sesión.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1"><Label>Nombre</Label><Input value={createForm.nombre ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, nombre: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Apellidos</Label><Input value={createForm.apellidos ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, apellidos: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Título</Label><Input value={createForm.title ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, title: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Título <small>(opcional)</small></Label><Input value={createForm.title ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, title: e.target.value }))} /></div>
|
||||
<div className="space-y-1"><Label>Clave</Label><Input value={createForm.clave ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, clave: e.target.value }))} /></div>
|
||||
<div className="space-y-1 md:col-span-2"><Label>Avatar (URL)</Label><Input value={createForm.avatar ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, avatar: e.target.value }))} /></div>
|
||||
|
||||
@@ -483,6 +557,7 @@ function RouteComponent() {
|
||||
onValueChange={(v) => {
|
||||
setCreateForm((s) => {
|
||||
const role = v as Role
|
||||
console.log("Rol seleccionado: ", role, ROLE_META[role]);
|
||||
if (role === "jefe_carrera") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
|
||||
if (role === "secretario_academico" || role === "director_facultad") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
|
||||
return { ...s, role, facultad_id: null, carrera_id: null }
|
||||
@@ -523,6 +598,7 @@ function RouteComponent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Probablemente ya no sea necesario
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Permisos</Label>
|
||||
<Select value={String(!!createForm.claims_admin)} onValueChange={(v) => setCreateForm((s) => ({ ...s, claims_admin: v === "true" }))}>
|
||||
@@ -532,12 +608,12 @@ function RouteComponent() {
|
||||
<SelectItem value="false">Usuario</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={() => createUser.mutate(createForm)} disabled={!createForm.email || createUser.isPending}>
|
||||
<Button onClick={() => createUser.mutate(createForm)} disabled={!createForm.email || !createForm.password || createUser.isPending}>
|
||||
{createUser.isPending ? "Creando…" : "Crear usuario"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
import { createFileRoute, redirect, useRouter } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
@@ -27,6 +27,7 @@ function LoginComponent() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -35,7 +36,7 @@ function LoginComponent() {
|
||||
|
||||
try {
|
||||
await auth.login(email, password)
|
||||
window.location.href = redirect
|
||||
router.navigate({ to: redirect})
|
||||
} catch (err: any) {
|
||||
setError(err.message || "No fue posible iniciar sesión")
|
||||
} finally {
|
||||
@@ -95,12 +96,6 @@ function LoginComponent() {
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Contraseña</Label>
|
||||
<a
|
||||
href="/reset-password"
|
||||
className="text-xs text-muted-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
¿Olvidaste tu contraseña?
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex w-10 items-center justify-center">
|
||||
@@ -124,6 +119,14 @@ function LoginComponent() {
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="/reset-password"
|
||||
className="text-xs text-muted-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
¿Olvidaste tu contraseña?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isLoading} className="w-full font-mono" size="lg">
|
||||
|
||||
@@ -138,7 +138,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.animate-aurora {
|
||||
background: radial-gradient(at 20% 30%, oklch(27.5% 0.13488 262.73), transparent 50%),
|
||||
radial-gradient(at 80% 70%, oklch(0.704 0.191 22.216), transparent 50%),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type RefRow = {
|
||||
fine_tuning_referencias_id: string
|
||||
documentos_id: string
|
||||
titulo_archivo: string | null
|
||||
descripcion: string | null
|
||||
s3_file_path: string | null // Added this property to match the API requirements.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"include": ["**/*.ts", "**/*.tsx", "src/components/ai/AIChatModal.jsx", "src/components/ai/AIChatModal.js"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
Reference in New Issue
Block a user