Compare commits
26 Commits
f92a3dae70
...
feature/Hi
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e884f20c5 | |||
| 8bb8399ec5 | |||
| 9462e25a20 | |||
| daac6f3f6d | |||
| 4cf93ff1f4 | |||
| d25b8b0441 | |||
| bec6405c54 | |||
| 53502d927b | |||
| 6e2b3d72f1 | |||
| 0c5c3f935b | |||
| 8da08b6bf1 | |||
| 1fe8f2b6a8 | |||
| 78580df13b | |||
| ff82d0c364 | |||
| 14b188d3ca | |||
| 3fccdc0478 | |||
| d491100c73 | |||
| ce2cd6b397 | |||
| f2b3010ac9 | |||
| c49c0bbc0a | |||
| 101758da24 | |||
| e03d5f5e36 | |||
| b3ca317e5e | |||
| e12d0ad8b1 | |||
| 4be34e8d6a | |||
| da4cf5a5e0 |
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}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
157
bun.lock
157
bun.lock
@@ -36,6 +36,7 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.1.2",
|
"recharts": "^3.1.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -465,10 +466,20 @@
|
|||||||
|
|
||||||
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
"@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/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
"@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/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
|
||||||
|
|
||||||
"@types/phoenix": ["@types/phoenix@1.6.6", "", {}, "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A=="],
|
"@types/phoenix": ["@types/phoenix@1.6.6", "", {}, "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A=="],
|
||||||
@@ -477,10 +488,14 @@
|
|||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="],
|
"@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="],
|
||||||
|
|
||||||
|
"@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/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=="],
|
"@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=="],
|
"@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=="],
|
"@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 +534,8 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
"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=="],
|
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
@@ -531,8 +548,18 @@
|
|||||||
|
|
||||||
"canvas-confetti": ["canvas-confetti@1.9.3", "", {}, "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g=="],
|
"canvas-confetti": ["canvas-confetti@1.9.3", "", {}, "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g=="],
|
||||||
|
|
||||||
|
"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=="],
|
"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=="],
|
"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=="],
|
"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,6 +572,8 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
"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=="],
|
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
|
||||||
@@ -583,6 +612,8 @@
|
|||||||
|
|
||||||
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
"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=="],
|
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||||
|
|
||||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||||
@@ -591,6 +622,8 @@
|
|||||||
|
|
||||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
"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=="],
|
"diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="],
|
||||||
|
|
||||||
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||||
@@ -611,12 +644,16 @@
|
|||||||
|
|
||||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
"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=="],
|
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||||
|
|
||||||
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||||
|
|
||||||
"expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="],
|
"expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="],
|
||||||
|
|
||||||
|
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
@@ -637,8 +674,14 @@
|
|||||||
|
|
||||||
"gsap": ["gsap@3.13.0", "", {}, "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw=="],
|
"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-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=="],
|
||||||
|
|
||||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
"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=="],
|
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
@@ -647,16 +690,28 @@
|
|||||||
|
|
||||||
"immer": ["immer@10.1.1", "", {}, "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw=="],
|
"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=="],
|
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||||
|
|
||||||
|
"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-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-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-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-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=="],
|
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||||
|
|
||||||
"isbot": ["isbot@5.1.30", "", {}, "sha512-3wVJEonAns1OETX83uWsk5IAne2S5zfDcntD2hbtU23LelSqNXzXs9zKjMPOLMzroCgIjCfjYAEHrd2D6FOkiA=="],
|
"isbot": ["isbot@5.1.30", "", {}, "sha512-3wVJEonAns1OETX83uWsk5IAne2S5zfDcntD2hbtU23LelSqNXzXs9zKjMPOLMzroCgIjCfjYAEHrd2D6FOkiA=="],
|
||||||
@@ -693,6 +748,8 @@
|
|||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
|
"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=="],
|
"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=="],
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
@@ -703,6 +760,64 @@
|
|||||||
|
|
||||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
"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=="],
|
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||||
|
|
||||||
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
|
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
|
||||||
@@ -721,6 +836,8 @@
|
|||||||
|
|
||||||
"nwsapi": ["nwsapi@2.2.21", "", {}, "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA=="],
|
"nwsapi": ["nwsapi@2.2.21", "", {}, "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA=="],
|
||||||
|
|
||||||
|
"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=="],
|
"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=="],
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
@@ -737,6 +854,8 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
|
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
|
||||||
@@ -745,6 +864,8 @@
|
|||||||
|
|
||||||
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
"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-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=="],
|
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||||
@@ -765,6 +886,10 @@
|
|||||||
|
|
||||||
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||||
|
|
||||||
|
"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=="],
|
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
@@ -795,12 +920,20 @@
|
|||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"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=="],
|
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||||
|
|
||||||
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
"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=="],
|
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
|
||||||
@@ -837,6 +970,10 @@
|
|||||||
|
|
||||||
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
|
"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=="],
|
"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=="],
|
"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 +984,18 @@
|
|||||||
|
|
||||||
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
@@ -859,6 +1008,10 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
"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=="],
|
"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 +1046,8 @@
|
|||||||
|
|
||||||
"zod": ["zod@4.0.17", "", {}, "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ=="],
|
"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=="],
|
"@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=="],
|
"@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 +1076,8 @@
|
|||||||
|
|
||||||
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"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=="],
|
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|||||||
5810
package-lock.json
generated
Normal file
5810
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -38,10 +38,13 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"gsap": "^3.13.0",
|
"gsap": "^3.13.0",
|
||||||
|
"jspdf": "^3.0.3",
|
||||||
|
"jspdf-autotable": "^5.0.2",
|
||||||
"lucide-react": "^0.540.0",
|
"lucide-react": "^0.540.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "^3.1.2",
|
"recharts": "^3.1.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -62,4 +65,4 @@
|
|||||||
"vitest": "^3.0.5",
|
"vitest": "^3.0.5",
|
||||||
"web-vitals": "^4.2.4"
|
"web-vitals": "^4.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,26 +10,29 @@ export interface SupabaseAuthState {
|
|||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
user: User | null
|
user: User | null
|
||||||
claims: UserClaims | null
|
claims: UserClaims | null
|
||||||
|
roles: RolCatalogo[] | null
|
||||||
login: (email: string, password: string) => Promise<void>
|
login: (email: string, password: string) => Promise<void>
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Role =
|
export interface RolCatalogo {
|
||||||
| 'lci'
|
id: string
|
||||||
| 'vicerrectoria'
|
nombre: string
|
||||||
| 'director_facultad' // 👈 NEW
|
icono: string
|
||||||
| 'secretario_academico'
|
nombre_clase: string
|
||||||
| 'jefe_carrera'
|
label: string
|
||||||
| 'planeacion'
|
}
|
||||||
|
|
||||||
type UserClaims = {
|
export type Role = string;
|
||||||
claims_admin: boolean
|
|
||||||
clave: string
|
export type UserClaims = {
|
||||||
|
id: string | null
|
||||||
|
clave?: string
|
||||||
nombre: string
|
nombre: string
|
||||||
apellidos: string
|
apellidos: string
|
||||||
title: string
|
title?: string
|
||||||
avatar: string | null
|
avatar?: string | null
|
||||||
carrera_id?: string | null
|
carrera_id?: string | null
|
||||||
facultad_id?: string | null
|
facultad_id?: string | null
|
||||||
facultad_color?: string | null // 🎨 NEW
|
facultad_color?: string | null // 🎨 NEW
|
||||||
@@ -41,28 +44,35 @@ const SupabaseAuthContext = createContext<SupabaseAuthState | undefined>(undefin
|
|||||||
export function SupabaseAuthProvider({ children }: { children: React.ReactNode }) {
|
export function SupabaseAuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
const [claims, setClaims] = useState<UserClaims | null>(null)
|
const [claims, setClaims] = useState<UserClaims | null>(null)
|
||||||
|
const [roles, setRoles] = useState<RolCatalogo[] | null>(null)
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
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
|
// Carga inicial
|
||||||
supabase.auth.getSession().then(async ({ data: { session } }) => {
|
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||||
const u = session?.user ?? null
|
handleSession(session)
|
||||||
setUser(u)
|
|
||||||
setIsAuthenticated(!!u)
|
|
||||||
setClaims(await buildClaims(session))
|
|
||||||
setIsLoading(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Carga roles catálogo
|
||||||
|
fetchRoles().then(fetchedRoles => {
|
||||||
|
setRoles(fetchedRoles);
|
||||||
|
});
|
||||||
|
|
||||||
// Suscripción a cambios de sesión
|
// Suscripción a cambios de sesión
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => {
|
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
const u = session?.user ?? null
|
handleSession(session)
|
||||||
setUser(u)
|
|
||||||
setIsAuthenticated(!!u)
|
|
||||||
setClaims(await buildClaims(session))
|
|
||||||
setIsLoading(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => subscription.unsubscribe()
|
return () => subscription.unsubscribe()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -80,7 +90,7 @@ export function SupabaseAuthProvider({ children }: { children: React.ReactNode }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SupabaseAuthContext.Provider
|
<SupabaseAuthContext.Provider
|
||||||
value={{ isAuthenticated, user, claims, login, logout, isLoading }}
|
value={{ isAuthenticated, user, claims, roles, login, logout, isLoading }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</SupabaseAuthContext.Provider>
|
</SupabaseAuthContext.Provider>
|
||||||
@@ -99,49 +109,54 @@ export function useSupabaseAuth() {
|
|||||||
* Helpers
|
* 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> {
|
async function buildClaims(session: Session | null): Promise<UserClaims | null> {
|
||||||
const u = session?.user
|
// Validar sesión
|
||||||
if (!u) return null
|
if (!session || !session.user) {
|
||||||
|
console.warn('No session or user found');
|
||||||
const app = (u.app_metadata ?? {}) as Partial<UserClaims> & { role?: Role }
|
return null;
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
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 {
|
if (error) {
|
||||||
claims_admin: !!base.claims_admin,
|
console.error('Error al obtener la información:', error);
|
||||||
role: (base.role ?? 'lci') as Role,
|
throw new Error('Error al obtener la información del usuario');
|
||||||
clave: base.clave ?? '',
|
}
|
||||||
nombre: base.nombre ?? '',
|
|
||||||
apellidos: base.apellidos ?? '',
|
console.log(data);
|
||||||
title: base.title ?? '',
|
if (!data || data.length === 0) {
|
||||||
avatar: base.avatar ?? null,
|
console.warn('No se encontró información para el usuario');
|
||||||
facultad_id: (base.facultad_id as string | null) ?? null,
|
return null;
|
||||||
carrera_id: (base.carrera_id as string | null) ?? null,
|
}
|
||||||
facultad_color, // 🎨
|
|
||||||
|
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 || [];
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import * as Icons from "lucide-react"
|
|||||||
import type { RefRow } from "@/types/RefRow"
|
import type { RefRow } from "@/types/RefRow"
|
||||||
|
|
||||||
// POST -> recibe blob PDF y (opcional) Content-Disposition
|
// 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, {
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -35,6 +35,7 @@ export function DetailDialog({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
pdfUrl?: string
|
pdfUrl?: string
|
||||||
}) {
|
}) {
|
||||||
|
console.log("DetailDialog render", { row })
|
||||||
const [viewerUrl, setViewerUrl] = useState<string | null>(null)
|
const [viewerUrl, setViewerUrl] = useState<string | null>(null)
|
||||||
const [currentBlob, setCurrentBlob] = useState<Blob | null>(null)
|
const [currentBlob, setCurrentBlob] = useState<Blob | null>(null)
|
||||||
const [filename, setFilename] = useState<string>("archivo.pdf")
|
const [filename, setFilename] = useState<string>("archivo.pdf")
|
||||||
@@ -48,13 +49,15 @@ export function DetailDialog({
|
|||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!row?.s3_file_path) {
|
console.log(row)
|
||||||
|
if (!row?.documentos_id) {
|
||||||
setViewerUrl(null)
|
setViewerUrl(null)
|
||||||
setCurrentBlob(null)
|
setCurrentBlob(null)
|
||||||
|
console.warn("No hay documentos_id en el row")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
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
|
if (ctrl.signal.aborted) return
|
||||||
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
|
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
|
||||||
setFilename(name)
|
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)
|
// 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.")
|
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`, { 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 })
|
||||||
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
|
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
|
||||||
const link = document.createElement("a")
|
const link = document.createElement("a")
|
||||||
const href = URL.createObjectURL(blob)
|
const href = URL.createObjectURL(blob)
|
||||||
@@ -111,7 +114,7 @@ export function DetailDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="max-w-3xl">
|
<DialogContent className="max-w-fit">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="font-mono">{row?.titulo_archivo ?? "(Sin título)"}</DialogTitle>
|
<DialogTitle className="font-mono">{row?.titulo_archivo ?? "(Sin título)"}</DialogTitle>
|
||||||
<DialogDescription>{row?.descripcion || "Sin descripción"}</DialogDescription>
|
<DialogDescription>{row?.descripcion || "Sin descripción"}</DialogDescription>
|
||||||
@@ -131,13 +134,13 @@ export function DetailDialog({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Tags
|
||||||
{row.tags?.length ? (
|
{row.tags?.length ? (
|
||||||
<div className="text-xs text-neutral-600">
|
<div className="text-xs text-neutral-600">
|
||||||
<span className="font-medium">Tags: </span>
|
<span className="font-medium">Tags: </span>
|
||||||
{row.tags.join(", ")}
|
{row.tags.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null} */}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-neutral-600">Instrucciones</Label>
|
<Label className="text-xs text-neutral-600">Instrucciones</Label>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { supabase } from "@/auth/supabase"
|
import { supabase,useSupabaseAuth } from "@/auth/supabase"
|
||||||
import { Button } from "../ui/button"
|
import { Button } from "../ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -35,11 +35,13 @@ export function EditBibliografiaButton({
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [text, setText] = useState("")
|
const [text, setText] = useState("")
|
||||||
|
const auth = useSupabaseAuth()
|
||||||
|
|
||||||
const initialTextRef = useRef("")
|
const initialTextRef = useRef("")
|
||||||
const lines = useMemo(() => parseLines(text), [text])
|
const lines = useMemo(() => parseLines(text), [text])
|
||||||
const dirty = useMemo(() => initialTextRef.current !== text, [text])
|
const dirty = useMemo(() => initialTextRef.current !== text, [text])
|
||||||
|
|
||||||
|
// 🔹 Abre el editor y carga los valores actuales
|
||||||
function openEditor() {
|
function openEditor() {
|
||||||
const start = (value ?? []).join("\n")
|
const start = (value ?? []).join("\n")
|
||||||
setText(start)
|
setText(start)
|
||||||
@@ -47,52 +49,110 @@ export function EditBibliografiaButton({
|
|||||||
setOpen(true)
|
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() {
|
async function save() {
|
||||||
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
setSaving(true)
|
// 1️⃣ Obtener bibliografía anterior
|
||||||
const refs = parseLines(text)
|
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
|
const { data, error } = await supabase
|
||||||
.from("asignaturas")
|
.from("asignaturas")
|
||||||
.update({ bibliografia: refs })
|
.update({ bibliografia: newRefs })
|
||||||
.eq("id", asignaturaId)
|
.eq("id", asignaturaId)
|
||||||
.select()
|
.select()
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
|
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
|
|
||||||
onSaved((data as any)?.bibliografia ?? refs)
|
// 5️⃣ Refrescar estado local
|
||||||
initialTextRef.current = refs.join("\n")
|
onSaved((data as any)?.bibliografia ?? newRefs)
|
||||||
toast.success(`${refs.length} referencia(s) guardada(s).`)
|
initialTextRef.current = newRefs.join("\n")
|
||||||
|
toast.success(`${newRefs.length} referencia(s) guardada(s).`)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
} catch (e: any) {
|
} catch (err: any) {
|
||||||
toast.error(e?.message ?? "No se pudo guardar")
|
toast.error(err.message ?? "No se pudo guardar la bibliografía")
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Acciones
|
// 🔧 Acciones extra
|
||||||
function actionTrim() {
|
function actionTrim() {
|
||||||
const next = parseLines(text).map((s) => s.replace(/\s+/g, " ").trim())
|
const next = parseLines(text).map((s) => s.replace(/\s+/g, " ").trim())
|
||||||
setText(next.join("\n"))
|
setText(next.join("\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionDedupe() {
|
function actionDedupe() {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const next: string[] = []
|
const next: string[] = []
|
||||||
for (const l of parseLines(text)) {
|
for (const l of parseLines(text)) {
|
||||||
const k = l.toLowerCase()
|
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"))
|
setText(next.join("\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionSort() {
|
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"))
|
setText(next.join("\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function actionImportClipboard() {
|
async function actionImportClipboard() {
|
||||||
try {
|
try {
|
||||||
const clip = await navigator.clipboard.readText()
|
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)]
|
const next = [...parseLines(text), ...parseLines(clip)]
|
||||||
setText(next.join("\n"))
|
setText(next.join("\n"))
|
||||||
toast.success("Texto importado")
|
toast.success("Texto importado")
|
||||||
@@ -100,6 +160,7 @@ export function EditBibliografiaButton({
|
|||||||
toast.error(e?.message ?? "No se pudo leer el portapapeles")
|
toast.error(e?.message ?? "No se pudo leer el portapapeles")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function actionExportClipboard() {
|
async function actionExportClipboard() {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(parseLines(text).join("\n"))
|
await navigator.clipboard.writeText(parseLines(text).join("\n"))
|
||||||
@@ -109,7 +170,7 @@ export function EditBibliografiaButton({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atajo guardar
|
// ⌨️ Atajo Ctrl+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -120,7 +181,6 @@ export function EditBibliografiaButton({
|
|||||||
}
|
}
|
||||||
window.addEventListener("keydown", onKey)
|
window.addEventListener("keydown", onKey)
|
||||||
return () => window.removeEventListener("keydown", onKey)
|
return () => window.removeEventListener("keydown", onKey)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open, saving, dirty, text])
|
}, [open, saving, dirty, text])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function useDeleteCarreraDialog(carreraId: string, onDeleted?: () => void
|
|||||||
|
|
||||||
const dialog = (
|
const dialog = (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent>
|
<DialogContent className="bg-white">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="font-mono" >¿Eliminar carrera?</DialogTitle>
|
<DialogTitle className="font-mono" >¿Eliminar carrera?</DialogTitle>
|
||||||
<DialogDescription>
|
<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 { AuroraButton } from "@/components/effect/aurora-button"
|
||||||
import confetti from "canvas-confetti"
|
import confetti from "canvas-confetti"
|
||||||
import { useQueryClient } from "@tanstack/react-query"
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
import { supabase } from "@/auth/supabase"
|
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||||
import { Field } from "./Field"
|
import { Field } from "./Field"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
||||||
import { asignaturaKeys } from "./planQueries"
|
import { asignaturaKeys } from "./planQueries"
|
||||||
|
import { useRouter } from "@tanstack/react-router"
|
||||||
|
|
||||||
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
|
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const router = useRouter()
|
||||||
|
const supabaseAuth = useSupabaseAuth()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [mode, setMode] = useState<"manual" | "ia">("manual")
|
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_teoricas: toNum(f.horas_teoricas),
|
||||||
horas_practicas: toNum(f.horas_practicas),
|
horas_practicas: toNum(f.horas_practicas),
|
||||||
objetivos: toNull(f.objetivos),
|
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)
|
setSaving(false)
|
||||||
if (error) { alert(error.message); return }
|
if (error) { alert(error.message); return }
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
onAdded?.()
|
onAdded?.()
|
||||||
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
|
||||||
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWithAI() {
|
async function createWithAI() {
|
||||||
if (!canIA) return
|
if (!canIA) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
// inserte la asignatura generada directamente
|
||||||
|
// obtengas el uuid que se insertó
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/generar/asignatura`, {
|
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/generar/asignatura`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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())
|
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 } })
|
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
router.invalidate()
|
||||||
|
router.navigate({
|
||||||
|
to: "/asignatura/$asignaturaId",
|
||||||
|
params: { asignaturaId },
|
||||||
|
})
|
||||||
onAdded?.()
|
onAdded?.()
|
||||||
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
// qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
||||||
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
// qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e?.message ?? "Error al generar la asignatura")
|
alert(e?.message ?? "Error al generar la asignatura")
|
||||||
} finally { setSaving(false) }
|
} finally { setSaving(false) }
|
||||||
|
|||||||
@@ -85,12 +85,14 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
|
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
|
||||||
const lockCarrera = role === "jefe_carrera"
|
const lockCarrera = role === "jefe_carrera"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchDbFiles() {
|
async function fetchDbFiles() {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("fine_tuning_referencias")
|
.from("documentos")
|
||||||
.select("fine_tuning_referencias_id, titulo_archivo, s3_file_path, fecha_subida, tags")
|
.select("documentos_id, titulo_archivo, fecha_subida, tags")
|
||||||
.ilike("titulo_archivo", `%${debouncedSearchTerm}%`)
|
.ilike("titulo_archivo", `%${debouncedSearchTerm}%`)
|
||||||
.range((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage - 1);
|
.range((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage - 1);
|
||||||
|
|
||||||
@@ -100,9 +102,9 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
}
|
}
|
||||||
|
|
||||||
setDbFiles((data || []).map((file: any) => ({
|
setDbFiles((data || []).map((file: any) => ({
|
||||||
id: file.fine_tuning_referencias_id,
|
id: file.documentos_id,
|
||||||
titulo: file.titulo_archivo,
|
titulo: file.titulo_archivo,
|
||||||
s3_file_path: file.s3_file_path,
|
s3_file_path: `prueba-referencias/documento_${file.documentos_id}.pdf`,
|
||||||
fecha_subida: file.fecha_subida,
|
fecha_subida: file.fecha_subida,
|
||||||
tags: file.tags || [],
|
tags: file.tags || [],
|
||||||
})));
|
})));
|
||||||
@@ -116,35 +118,35 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
|
|
||||||
const isSelected = useCallback((path: string) => selectedFiles.includes(path), [selectedFiles]);
|
const isSelected = useCallback((path: string) => selectedFiles.includes(path), [selectedFiles]);
|
||||||
|
|
||||||
const toggleSelected = useCallback((path: string) => {
|
const toggleSelected = useCallback((id: string) => {
|
||||||
setSelectedFiles(prev => prev.includes(path) ? prev.filter(p => p !== path) : [...prev, path]);
|
setSelectedFiles(prev => prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const replaceSelection = useCallback((path: string) => {
|
const replaceSelection = useCallback((id: string) => {
|
||||||
setSelectedFiles([path]);
|
setSelectedFiles([id]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const rangeSelect = useCallback((start: number, end: number) => {
|
const rangeSelect = useCallback((start: number, end: number) => {
|
||||||
const [s, e] = start < end ? [start, end] : [end, start];
|
const [s, e] = start < end ? [start, end] : [end, start];
|
||||||
const paths = dbFiles.slice(s, e + 1).map(f => f.s3_file_path);
|
const ids = dbFiles.slice(s, e + 1).map(f => f.id);
|
||||||
setSelectedFiles(prev => Array.from(new Set([...prev, ...paths])));
|
setSelectedFiles(prev => Array.from(new Set([...prev, ...ids])));
|
||||||
}, [dbFiles]);
|
}, [dbFiles]);
|
||||||
|
|
||||||
const handleCardClick = useCallback((e: React.MouseEvent, index: number, file: { s3_file_path: string }) => {
|
const handleCardClick = useCallback((e: React.MouseEvent, index: number, file: { id: string }) => {
|
||||||
const path = file.s3_file_path;
|
const id = file.id;
|
||||||
|
|
||||||
if (e.shiftKey && lastSelectedIndex !== null) {
|
if (e.shiftKey && lastSelectedIndex !== null) {
|
||||||
rangeSelect(lastSelectedIndex, index);
|
rangeSelect(lastSelectedIndex, index);
|
||||||
} else if (e.metaKey || e.ctrlKey) {
|
} else if (e.metaKey || e.ctrlKey) {
|
||||||
toggleSelected(path);
|
toggleSelected(id);
|
||||||
setLastSelectedIndex(index);
|
setLastSelectedIndex(index);
|
||||||
} else {
|
} else {
|
||||||
if (isSelected(path) && selectedFiles.length === 1) {
|
if (isSelected(id) && selectedFiles.length === 1) {
|
||||||
// si ya es el único seleccionado, des-selecciona
|
// si ya es el único seleccionado, des-selecciona
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
setLastSelectedIndex(null);
|
setLastSelectedIndex(null);
|
||||||
} else {
|
} else {
|
||||||
replaceSelection(path);
|
replaceSelection(id);
|
||||||
setLastSelectedIndex(index);
|
setLastSelectedIndex(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,9 +164,10 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
try {
|
try {
|
||||||
const res = await postAPI("/api/generar/plan", {
|
const res = await postAPI("/api/generar/plan", {
|
||||||
carreraId,
|
carreraId,
|
||||||
prompt,
|
prompt: prompt,
|
||||||
insert: true,
|
insert: true,
|
||||||
files: selectedFiles,
|
files: selectedFiles,
|
||||||
|
created_by: auth.user?.id,
|
||||||
})
|
})
|
||||||
const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id
|
const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id
|
||||||
if (newId) {
|
if (newId) {
|
||||||
@@ -258,10 +261,11 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
<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) => {
|
{dbFiles.map((file, index) => {
|
||||||
const ext = fileExt(file.titulo);
|
const ext = fileExt(file.titulo);
|
||||||
const selected = isSelected(file.s3_file_path);
|
const selected = isSelected(file.id);
|
||||||
|
console.log(file);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
type="button"
|
|
||||||
key={file.id}
|
key={file.id}
|
||||||
role="gridcell"
|
role="gridcell"
|
||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
@@ -269,7 +273,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPreviewRow({
|
setPreviewRow({
|
||||||
fine_tuning_referencias_id: file.id,
|
documentos_id: file.id,
|
||||||
created_by: "unknown",
|
created_by: "unknown",
|
||||||
s3_file_path: file.s3_file_path,
|
s3_file_path: file.s3_file_path,
|
||||||
titulo_archivo: file.titulo,
|
titulo_archivo: file.titulo,
|
||||||
@@ -312,6 +316,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
) : (
|
) : (
|
||||||
<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 && (
|
{file.tags && file.tags.length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{file.tags.map((tag, i) => (
|
{file.tags.map((tag, i) => (
|
||||||
@@ -330,7 +335,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPreviewRow({
|
setPreviewRow({
|
||||||
fine_tuning_referencias_id: file.id,
|
documentos_id: file.id,
|
||||||
created_by: "unknown",
|
created_by: "unknown",
|
||||||
s3_file_path: file.s3_file_path,
|
s3_file_path: file.s3_file_path,
|
||||||
titulo_archivo: file.titulo,
|
titulo_archivo: file.titulo,
|
||||||
@@ -352,7 +357,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
<span className="truncate">{ext.toUpperCase()}</span>
|
<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>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ export function DeletePlanButton({ planId, onDeleted }: { planId: string; onDele
|
|||||||
|
|
||||||
return confirm ? (
|
return confirm ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setConfirm(false)} disabled={loading}>Cancelar</Button>
|
||||||
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
|
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
|
||||||
{loading ? "Eliminando…" : "Confirmar eliminación"}
|
{loading ? "Eliminando…" : "Confirmar eliminación"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setConfirm(false)} disabled={loading}>Cancelar</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" onClick={() => setConfirm(true)}>
|
<Button variant="outline" onClick={() => setConfirm(true)}>
|
||||||
|
|||||||
427
src/components/planes/DownloadPlanPDF.tsx
Normal file
427
src/components/planes/DownloadPlanPDF.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import { jsPDF } from "jspdf"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import { Download } from "lucide-react"
|
||||||
|
// Importamos 'react' para poder usar el hook de estado si fuera necesario.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipo mínimo para el plan. Hemos añadido 'number' a la unión
|
||||||
|
* para permitir propiedades como 'total_creditos' que son numéricas,
|
||||||
|
* lo cual resuelve el error de asignación con PlanFull.
|
||||||
|
*/
|
||||||
|
export type PlanLike = Record<string, string | number | object | null | undefined> // CORREGIDO: Se agregó 'object'
|
||||||
|
|
||||||
|
// Usamos el tipo corregido PlanLike en la prop 'plan'
|
||||||
|
export function DownloadPlanPDF({ plan }: { plan: PlanLike }) {
|
||||||
|
// console.log(plan) // Mantener el log para debug
|
||||||
|
|
||||||
|
function generatePDF() {
|
||||||
|
// Inicialización del documento
|
||||||
|
const doc = new jsPDF({
|
||||||
|
orientation: "portrait",
|
||||||
|
unit: "mm",
|
||||||
|
format: "letter",
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageWidth = doc.internal.pageSize.getWidth()
|
||||||
|
const pageHeight = doc.internal.pageSize.getHeight()
|
||||||
|
const margin = 20
|
||||||
|
const maxWidth = pageWidth - margin * 2
|
||||||
|
|
||||||
|
// Parámetros de estilo institucional (basados en las capturas)
|
||||||
|
const lineHeight = 5.0 // mm por línea (ajustado para más texto por página)
|
||||||
|
const sectionGap = 10 // Espacio entre recuadros de sección
|
||||||
|
const bodyFontSize = 10.5
|
||||||
|
const headingFontSize = 12
|
||||||
|
const subHeadingFontSize = 10
|
||||||
|
const bulletGlifo = "\u21D2" // Flecha doble (⇒) para el glifo
|
||||||
|
const bulletIndent = 6 // Sangría para el texto de la lista
|
||||||
|
|
||||||
|
let cursorY = margin
|
||||||
|
|
||||||
|
// Variable para controlar si ya se dibujaron todas las secciones (para la última caja)
|
||||||
|
let totalSections = 0;
|
||||||
|
let drawnSections = 0;
|
||||||
|
|
||||||
|
// --- Utilidades de Dibujo ---
|
||||||
|
|
||||||
|
// Dibuja el encabezado ("Anexo 1") y pie de página (Numeración) en todas las páginas
|
||||||
|
const drawHeaderAndFooter = () => {
|
||||||
|
// FIX: Usamos (doc as any) para acceder a getNumberOfPages() y evitar el error de TS
|
||||||
|
const pageCount = (doc as any).internal.getNumberOfPages()
|
||||||
|
for (let i = 1; i <= pageCount; i++) {
|
||||||
|
doc.setPage(i)
|
||||||
|
|
||||||
|
// Encabezado (Anexo 1)
|
||||||
|
doc.setFont("helvetica", "normal")
|
||||||
|
doc.setFontSize(10)
|
||||||
|
doc.text("Anexo 1", pageWidth - margin, margin - 5, { align: "right" })
|
||||||
|
|
||||||
|
// Pie de página (Numeración)
|
||||||
|
// Usamos el mismo tamaño y posición que en el ejemplo
|
||||||
|
doc.setFontSize(8)
|
||||||
|
doc.text(
|
||||||
|
`Página ${i} de ${pageCount}`,
|
||||||
|
pageWidth - margin, // Posicionado a la derecha
|
||||||
|
pageHeight - 10,
|
||||||
|
{ align: "right" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Regresar al último estado de la página para continuar dibujando
|
||||||
|
doc.setPage(pageCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica si se necesita una nueva página antes de dibujar una línea o un elemento.
|
||||||
|
const addPageIfNeeded = (neededHeight: number = lineHeight) => {
|
||||||
|
// Aseguramos que haya espacio para la altura necesaria + un poco de margen de seguridad
|
||||||
|
// El margen de seguridad ayuda a que la línea de pie de página no se solape
|
||||||
|
if (cursorY + neededHeight > pageHeight - 15) {
|
||||||
|
doc.addPage()
|
||||||
|
cursorY = margin
|
||||||
|
// El encabezado "Anexo 1" se dibuja al final en drawHeaderAndFooter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dibuja un título de sección con el estilo de recuadro gris (como en las capturas).
|
||||||
|
* Retorna la altura ocupada por el recuadro para el cálculo de la altura total de la sección.
|
||||||
|
*/
|
||||||
|
const drawHeadingBox = (text: string, marginTop: number = 0): number => {
|
||||||
|
doc.setFont("helvetica", "bold")
|
||||||
|
doc.setFontSize(headingFontSize)
|
||||||
|
|
||||||
|
// Espacio antes del título
|
||||||
|
cursorY += marginTop
|
||||||
|
|
||||||
|
const titleLines = doc.splitTextToSize(text.toUpperCase(), maxWidth - 4) // Pequeño padding
|
||||||
|
const titleHeight = titleLines.length * lineHeight + 4 // Texto + padding vertical
|
||||||
|
|
||||||
|
// 1. Verificar si el recuadro cabe en la página
|
||||||
|
addPageIfNeeded(titleHeight + 5) // 5mm de margen de seguridad
|
||||||
|
|
||||||
|
// 2. Dibujar Recuadro Gris (Relleno)
|
||||||
|
doc.setFillColor(230, 230, 230) // Gris claro
|
||||||
|
doc.rect(margin, cursorY, maxWidth, titleHeight, "F")
|
||||||
|
|
||||||
|
// 3. Dibujar texto centrado
|
||||||
|
const textX = pageWidth / 2
|
||||||
|
const textY = cursorY + titleHeight / 2 + 0.8 // 0.8mm para centrado óptico
|
||||||
|
doc.text(titleLines, textX, textY, { align: "center" })
|
||||||
|
|
||||||
|
cursorY += titleHeight // Avanzar el cursor justo después del recuadro
|
||||||
|
return titleHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dibuja un bloque de texto (párrafo o lista) manejando el salto de página línea por línea,
|
||||||
|
* y envuelto en un recuadro.
|
||||||
|
*/
|
||||||
|
const drawContentBox = (text?: string | null, isList: boolean = false, isLastSection: boolean = false) => {
|
||||||
|
// Manejamos 'text' que ahora puede ser string o number
|
||||||
|
const content = (text !== null && text !== undefined) ? String(text).trim() : "Sin información."
|
||||||
|
|
||||||
|
doc.setFont("helvetica", "normal")
|
||||||
|
doc.setFontSize(bodyFontSize)
|
||||||
|
|
||||||
|
let initialY = cursorY // Guardar Y inicial para dibujar el recuadro final
|
||||||
|
|
||||||
|
// El contenido se dibuja en un recuadro. Dejamos un padding interno.
|
||||||
|
const innerMargin = margin + 2
|
||||||
|
const innerMaxWidth = maxWidth - 4
|
||||||
|
let currentContentY = cursorY + 2 // Iniciar con 2mm de padding superior
|
||||||
|
|
||||||
|
// Dividir el contenido en bloques (párrafos o ítems de lista)
|
||||||
|
const blocks = isList ?
|
||||||
|
content.split('\n').filter(line => line.trim().length > 0) :
|
||||||
|
content.split('\n').filter(line => line.trim().length > 0)
|
||||||
|
|
||||||
|
let contentDrawn = false
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
let cleanBlock = block.trim()
|
||||||
|
|
||||||
|
// Si es lista, limpiamos los posibles marcadores (1., *, -)
|
||||||
|
if (isList) {
|
||||||
|
cleanBlock = cleanBlock.replace(/^(\d+\.|\*|[\-\•]|\u27A2|\u21D2)\s*/, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cleanBlock) continue
|
||||||
|
|
||||||
|
// Líneas que componen el bloque actual
|
||||||
|
const textWidth = isList ? innerMaxWidth - bulletIndent : innerMaxWidth
|
||||||
|
const lines = doc.splitTextToSize(cleanBlock, textWidth)
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
// 1. Verificar si se necesita un salto de página ANTES de dibujar la línea
|
||||||
|
if (currentContentY + lineHeight > pageHeight - 15) {
|
||||||
|
// Cierra el recuadro en la página actual
|
||||||
|
doc.rect(margin, initialY, maxWidth, pageHeight - 15 - initialY)
|
||||||
|
|
||||||
|
doc.addPage()
|
||||||
|
|
||||||
|
// En la nueva página, el punto de inicio del recuadro es el margen superior
|
||||||
|
initialY = margin
|
||||||
|
currentContentY = margin + 2 // Iniciar con padding
|
||||||
|
cursorY = margin // El cursorY global se actualiza para la siguiente sección/línea
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLine = lines[i]
|
||||||
|
|
||||||
|
if (isList && i === 0) {
|
||||||
|
// Dibujar el glifo solo en la primera línea del ítem
|
||||||
|
doc.text(bulletGlifo, innerMargin, currentContentY)
|
||||||
|
doc.text(currentLine, innerMargin + bulletIndent, currentContentY)
|
||||||
|
} else if (isList && i > 0) {
|
||||||
|
// Dibujar líneas subsiguientes con sangría (sin glifo)
|
||||||
|
doc.text(currentLine, innerMargin + bulletIndent, currentContentY)
|
||||||
|
} else {
|
||||||
|
// Dibujar párrafo normal
|
||||||
|
doc.text(currentLine, innerMargin, currentContentY)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentContentY += lineHeight // Avanzar el cursor de contenido
|
||||||
|
}
|
||||||
|
|
||||||
|
// Espacio entre ítems de lista o entre párrafos
|
||||||
|
currentContentY += isList ? 1.5 : 4
|
||||||
|
contentDrawn = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Después de dibujar todo el contenido, dibujar el recuadro exterior
|
||||||
|
if (contentDrawn) {
|
||||||
|
let finalY = currentContentY - 2 // Ajuste final de padding y espacio
|
||||||
|
|
||||||
|
// FIX: Usamos (doc as any) para acceder a los métodos internos y evitar el error de TS
|
||||||
|
if (isLastSection &&
|
||||||
|
(doc as any).internal.getCurrentPageInfo().pageNumber === (doc as any).internal.getNumberOfPages()) {
|
||||||
|
|
||||||
|
// Si es la ÚLTIMA sección Y estamos en la ÚLTIMA página,
|
||||||
|
// forzamos el recuadro a ir hasta el final (pageHeight - 15)
|
||||||
|
finalY = pageHeight - 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dibujar el recuadro completo (desde el Y inicial guardado hasta el Y final)
|
||||||
|
doc.rect(margin, initialY, maxWidth, finalY - initialY)
|
||||||
|
|
||||||
|
cursorY = finalY + sectionGap // Actualizar el cursor global para la siguiente sección
|
||||||
|
} else {
|
||||||
|
// Si no se dibuja contenido, solo saltar la altura del recuadro vacío.
|
||||||
|
doc.rect(margin, initialY, maxWidth, 10) // Dibuja una caja vacía de 10mm
|
||||||
|
cursorY += 10 + sectionGap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Portada (Estilo Institucional) ---
|
||||||
|
|
||||||
|
const drawTitlePage = () => {
|
||||||
|
cursorY = 40 // Empezar más abajo
|
||||||
|
|
||||||
|
// UNIVERSIDAD LA SALLE
|
||||||
|
doc.setFont("helvetica", "bold")
|
||||||
|
doc.setFontSize(14)
|
||||||
|
doc.text("UNIVERSIDAD LA SALLE", pageWidth / 2, cursorY, { align: "center" })
|
||||||
|
cursorY += 5
|
||||||
|
|
||||||
|
// Separador horizontal
|
||||||
|
doc.line(margin, cursorY, pageWidth - margin, cursorY)
|
||||||
|
cursorY += 15
|
||||||
|
|
||||||
|
// LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES
|
||||||
|
doc.setFontSize(18)
|
||||||
|
// Manejamos la conversión a string si es necesario
|
||||||
|
const mainTitle = (plan["titulo"] !== null && plan["titulo"] !== undefined ? String(plan["titulo"]) : "LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES").toUpperCase()
|
||||||
|
const mainTitleLines = doc.splitTextToSize(mainTitle, maxWidth - 20)
|
||||||
|
doc.text(mainTitleLines, pageWidth / 2, cursorY, { align: "center" })
|
||||||
|
cursorY += mainTitleLines.length * 8
|
||||||
|
|
||||||
|
// Nivel y Nombre del Plan de Estudios
|
||||||
|
doc.setFont("helvetica", "normal")
|
||||||
|
doc.setFontSize(10)
|
||||||
|
doc.text("Nivel y Nombre del Plan de Estudios", pageWidth / 2, cursorY, { align: "center" })
|
||||||
|
cursorY += 5
|
||||||
|
|
||||||
|
// Separador horizontal
|
||||||
|
doc.line(margin, cursorY, pageWidth - margin, cursorY)
|
||||||
|
cursorY += 10
|
||||||
|
|
||||||
|
// Escolar / Presencial (Modalidad Educativa)
|
||||||
|
doc.setFont("helvetica", "bold")
|
||||||
|
doc.setFontSize(14)
|
||||||
|
doc.text("Escolar / Presencial", pageWidth / 2, cursorY, { align: "center" })
|
||||||
|
doc.setFont("helvetica", "normal")
|
||||||
|
doc.setFontSize(10)
|
||||||
|
cursorY += 5
|
||||||
|
doc.text("Modalidad Educativa", pageWidth / 2, cursorY, { align: "center" })
|
||||||
|
cursorY += 15
|
||||||
|
|
||||||
|
// Recuadros de Vigencia, Antecedente y Área (Simulación del Layout)
|
||||||
|
|
||||||
|
// Recuadro Vigencia (Parte superior central)
|
||||||
|
const boxWidth = maxWidth * 0.5
|
||||||
|
const boxX = (pageWidth - boxWidth) / 2
|
||||||
|
const boxY = cursorY
|
||||||
|
doc.rect(boxX, boxY, boxWidth, 20)
|
||||||
|
doc.rect(boxX, boxY + 15, boxWidth, 5)
|
||||||
|
doc.setFontSize(10)
|
||||||
|
doc.text("Vigencia", boxX + boxWidth / 2, boxY + 18, { align: "center" })
|
||||||
|
cursorY += 30 // Espacio para el primer recuadro
|
||||||
|
|
||||||
|
// Recuadro Antecedente Académico (Izquierda)
|
||||||
|
const smallBoxWidth = maxWidth * 0.4
|
||||||
|
const smallBoxHeight = 35
|
||||||
|
const smallBoxX1 = margin
|
||||||
|
doc.rect(smallBoxX1, cursorY, smallBoxWidth, smallBoxHeight)
|
||||||
|
doc.rect(smallBoxX1, cursorY + smallBoxHeight - 5, smallBoxWidth, 5)
|
||||||
|
doc.setFontSize(10)
|
||||||
|
doc.text("Bachillerato", smallBoxX1 + smallBoxWidth / 2, cursorY + smallBoxHeight / 2, { align: "center" })
|
||||||
|
doc.text("Antecedente Académico", smallBoxX1 + smallBoxWidth / 2, cursorY + smallBoxHeight - 2, { align: "center" })
|
||||||
|
|
||||||
|
// Recuadro Área de Estudio (Derecha)
|
||||||
|
const smallBoxX2 = pageWidth - margin - smallBoxWidth
|
||||||
|
doc.rect(smallBoxX2, cursorY, smallBoxWidth, smallBoxHeight)
|
||||||
|
doc.rect(smallBoxX2, cursorY + smallBoxHeight - 5, smallBoxWidth, 5)
|
||||||
|
doc.setFontSize(10)
|
||||||
|
doc.text("Ingeniería, manufactura y construcción", smallBoxX2 + smallBoxWidth / 2, cursorY + smallBoxHeight / 2, { align: "center" })
|
||||||
|
doc.text("Área de Estudio", smallBoxX2 + smallBoxWidth / 2, cursorY + smallBoxHeight - 2, { align: "center" })
|
||||||
|
cursorY += smallBoxHeight + 10
|
||||||
|
|
||||||
|
// Datos Fijos (Abajo)
|
||||||
|
doc.setFont("helvetica", "normal")
|
||||||
|
doc.setFontSize(10)
|
||||||
|
|
||||||
|
const drawDataPair = (label: string, value: string) => {
|
||||||
|
const labelX = margin
|
||||||
|
const valueX = margin + maxWidth * 0.4
|
||||||
|
doc.text(label + ":", labelX, cursorY)
|
||||||
|
doc.setFont("helvetica", "bold")
|
||||||
|
doc.text(value, valueX, cursorY)
|
||||||
|
doc.setFont("helvetica", "normal")
|
||||||
|
cursorY += 5
|
||||||
|
}
|
||||||
|
|
||||||
|
drawDataPair("Clave del Plan de Estudios", "2020")
|
||||||
|
drawDataPair("Diseño Curricular", "Rígido")
|
||||||
|
// Usamos plan.total_ciclos si existe
|
||||||
|
drawDataPair("Total de Ciclos del Plan de Estudios", plan["total_ciclos"] ? String(plan["total_ciclos"]) : "9")
|
||||||
|
drawDataPair("Duración del Ciclo Escolar", "16 semanas")
|
||||||
|
drawDataPair("Carga Horaria a la Semana", "27")
|
||||||
|
|
||||||
|
// Pie de página institucional (simulado)
|
||||||
|
doc.text(
|
||||||
|
"Dirección de Asuntos Académicos - Anexo 1",
|
||||||
|
pageWidth / 2,
|
||||||
|
pageHeight - margin,
|
||||||
|
{ align: "center" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Ejecución Principal ---
|
||||||
|
|
||||||
|
// 1. Dibuja la portada
|
||||||
|
drawTitlePage()
|
||||||
|
|
||||||
|
// 2. Comienza el contenido del plan en la nueva página
|
||||||
|
doc.addPage()
|
||||||
|
cursorY = margin
|
||||||
|
|
||||||
|
// Las secciones se ajustan a las que generas, pero también a las adicionales del documento de referencia
|
||||||
|
const SECTIONS: Array<{ key: string; title: string; isList: boolean }> = [
|
||||||
|
{ key: "objetivo_general", title: "Objetivo General", isList: false },
|
||||||
|
// La sección FIN DE APRENDIZAJE O FORMACIÓN es el Objetivo General del documento institucional, la mapearemos aquí.
|
||||||
|
{ key: "fin_aprendizaje", title: "FIN DE APRENDIZAJE O FORMACIÓN", isList: false }, // Mapea al objetivo general
|
||||||
|
|
||||||
|
{ key: "perfil_ingreso", title: "PERFIL DE INGRESO", isList: true },
|
||||||
|
{ key: "perfil_egreso", title: "PERFIL DE EGRESO", isList: true },
|
||||||
|
{ key: "competencias_genericas", title: "COMPETENCIAS GENÉRICAS", isList: true },
|
||||||
|
{ key: "competencias_especificas", title: "COMPETENCIAS ESPECÍFICAS", isList: true },
|
||||||
|
{ key: "indicadores_desempeno", title: "INDICADORES DE DESEMPEÑO", isList: true },
|
||||||
|
{ key: "sistema_evaluacion", title: "SISTEMA DE EVALUACIÓN", isList: false },
|
||||||
|
{ key: "pertinencia", title: "PERTINENCIA", isList: false },
|
||||||
|
|
||||||
|
// Nuevas secciones basadas en las imágenes que suelen ir con "No aplica"
|
||||||
|
{ key: "administracion", title: "ADMINISTRACIÓN Y OPERATIVIDAD DEL PLAN DE ESTUDIOS", isList: false },
|
||||||
|
{ key: "sustento_teorico", title: "SUSTENTO TEÓRICO DEL MODELO CURRICULAR", isList: false },
|
||||||
|
{ key: "justificacion_curricular", title: "JUSTIFICACIÓN DE LA PROPUESTA CURRICULAR EN LA MODALIDAD NO ESCOLARIZADA O MIXTA", isList: false },
|
||||||
|
{ key: "programa_investigacion", title: "PROGRAMA DE INVESTIGACIÓN", isList: false },
|
||||||
|
{ key: "curso_propedeutico", title: "CURSO PROPEDÉUTICO", isList: false },
|
||||||
|
{ key: "propuesta_evaluacion", title: "PROPUESTA DE EVALUACIÓN PERIÓDICA DEL PLAN DE ESTUDIOS", isList: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Contar el número total de secciones con contenido
|
||||||
|
totalSections = SECTIONS.length;
|
||||||
|
|
||||||
|
for (const s of SECTIONS) {
|
||||||
|
drawnSections++; // Incrementar contador de secciones dibujadas
|
||||||
|
|
||||||
|
// Obtenemos el valor (que puede ser string, number, object, null, o undefined)
|
||||||
|
let value = plan[s.key]
|
||||||
|
|
||||||
|
// Mapeo especial para el objetivo general institucional (si existe)
|
||||||
|
if (s.key === "fin_aprendizaje" && (value === null || value === undefined)) {
|
||||||
|
value = plan["objetivo_general"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inicializar content como string para pasarlo a la función de dibujo
|
||||||
|
let content: string | null = null;
|
||||||
|
|
||||||
|
// Si el valor no es nulo/undefined, convertir a string
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
// Si es un objeto, lo ignoramos y usamos un string vacío.
|
||||||
|
// Esto es clave para 'carreras', que no tiene un formato textual.
|
||||||
|
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
content = "";
|
||||||
|
} else {
|
||||||
|
content = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si el contenido es nulo o vacío, usamos un placeholder común en el documento institucional
|
||||||
|
if (!content || content.trim() === "") {
|
||||||
|
// Para las secciones del plan generado, si no hay contenido, usar "Sin información."
|
||||||
|
if (["objetivo_general", "perfil_ingreso", "perfil_egreso", "competencias_genericas", "competencias_especificas", "indicadores_desempeno", "sistema_evaluacion", "pertinencia"].includes(s.key)) {
|
||||||
|
content = "Sin información."
|
||||||
|
} else {
|
||||||
|
// Para las secciones auxiliares del formato institucional
|
||||||
|
if (s.key === "administracion" || s.key === "sustento_teorico" || s.key === "justificacion_curricular" || s.key === "programa_investigacion") {
|
||||||
|
content = "No aplica"
|
||||||
|
} else if (s.key === "curso_propedeutico") {
|
||||||
|
content = "No tiene"
|
||||||
|
} else if (s.key === "propuesta_evaluacion") {
|
||||||
|
// Texto de la Propuesta de Evaluación (última página)
|
||||||
|
content = "La Universidad La Salle aplica una metodología para la evaluación y modificación de los programas académicos de licenciatura o posgrado que imparte. Los principales niveles, estudios, acciones y plazos que comprende dicha metodología son los siguientes:\n\nNIVEL DE EVALUACIÓN CURRICULAR INTERNA: DIAGNÓSTICO DE ESTRUCTURA Y OPERACIÓN.\n1. Análisis técnico-pedagógico del planteamiento curricular vigente.\n2. Estudio con directivos del área académica correspondiente, para analizar y valorar las problemáticas en la estructura y gestión del programa académico durante el periodo en que se ha desarrollado.\n3. Consulta a profesores sobre: a) problemáticas percibidas en la formación académica, profesional y actitudinal de los estudiantes, b) problemáticas en la operación, c) necesidades sociales, avances disciplinarios y/o tecnológicos detectados en su propio ejercicio profesional, que consideran importante incluir en el planteamiento curricular.\n4. Estudio de opinión de estudiantes sobre las problemáticas que aprecian en la formación que reciben respecto a la operación y estructura del programa académico.\n\nNIVEL DE EVALUACIÓN CURRICULAR EXTERNA: DIAGNÓSTICO DE IMPACTO Y PRÁCTICAS PROFESIONALES.\n5. Estudio sobre el estado del conocimiento en que se encuentran el o los campos disciplinarios vinculados con el programa académico, en México y, de ser posible, en otros países.\n6. Análisis de la oferta y la evolución que, en términos estadísticos, han tenido programas académicos similares en el ámbito de influencia y/o en el país.\n7. Estudio sobre requerimientos y tendencias en la formación, a partir del análisis de criterios, perfiles, estándares y parámetros de organismos evaluadores o acreditadores de programas académicos (si existen para el campo profesional), así como de la comparación general del programa en evaluación con otros similares y prestigiosos, de IES nacionales y, de ser posible, extranjeras.\n8. Estudio con egresados del programa académico para conocer su opinión sobre: a) el mismo programa; b) formación recibida; c) sitios de inserción laboral y características de sus prácticas profesionales, y d) aspectos disciplinarios, tecnológicos y/o actitudinales que, a la luz de su experiencia, consideren necesario incluir como parte de la formación.\n9. Estudio con empleadores para conocer su valoración sobre las prácticas profesionales de los egresados del programa académico, y su apreciación sobre nuevos requerimientos en el campo."
|
||||||
|
} else {
|
||||||
|
continue; // Si sigue siendo nulo, saltar la sección
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinar si es la última sección que se dibujará
|
||||||
|
const isLastSection = drawnSections === totalSections;
|
||||||
|
|
||||||
|
// Dibuja el recuadro del título de la sección
|
||||||
|
drawHeadingBox(s.title, sectionGap)
|
||||||
|
|
||||||
|
// Dibuja el contenido de la sección dentro de su recuadro.
|
||||||
|
// Pasamos isLastSection para que drawContentBox sepa si debe forzar el cierre.
|
||||||
|
drawContentBox(content, s.isList, isLastSection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalizar y dibujar encabezados/pies en todas las páginas (se dibuja en el paso final)
|
||||||
|
drawHeaderAndFooter()
|
||||||
|
|
||||||
|
// Guardar el documento
|
||||||
|
const name = (plan["prompt"] ? `Plan_${plan["prompt"]}` : `Plan_de_estudios`).replace(/\s+/g, "_")
|
||||||
|
doc.save(`${name}_Institucional.pdf`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="outline" className="flex items-center gap-2 " onClick={generatePDF}>
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,11 @@ import { useSuspenseQuery, useMutation, useQueryClient, queryOptions } from "@ta
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { supabase } from "@/auth/supabase"
|
import { supabase,useSupabaseAuth } from "@/auth/supabase"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import { HistorialCambiosModal } from "../historico/HistorialCambiosModal"
|
||||||
|
|
||||||
|
|
||||||
/* =====================================================
|
/* =====================================================
|
||||||
Query keys & fetcher
|
Query keys & fetcher
|
||||||
@@ -25,6 +28,7 @@ export type PlanTextFields = {
|
|||||||
indicadores_desempeno?: string | string[] | null
|
indicadores_desempeno?: string | string[] | null
|
||||||
pertinencia?: string | string[] | null
|
pertinencia?: string | string[] | null
|
||||||
prompt?: string | null
|
prompt?: string | null
|
||||||
|
historico?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPlanText(planId: string): Promise<PlanTextFields> {
|
async function fetchPlanText(planId: string): Promise<PlanTextFields> {
|
||||||
@@ -72,7 +76,7 @@ function ExpandableText({ text, mono = false }: { text?: string | string[] | nul
|
|||||||
const rendered = Array.isArray(text) ? `• ${content}` : content
|
const rendered = Array.isArray(text) ? `• ${content}` : content
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${mono ? "font-mono whitespace-pre-wrap" : ""} text-sm ${open ? "" : "line-clamp-10"}`}>{rendered}</div>
|
<ReactMarkdown>{rendered}</ReactMarkdown>
|
||||||
{String(rendered).length > 220 && (
|
{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"}
|
{open ? "Ver menos" : "Ver más"}
|
||||||
@@ -109,6 +113,9 @@ function SectionPanel({ title, icon: Icon, color, children, id }: { title: strin
|
|||||||
===================================================== */
|
===================================================== */
|
||||||
export function AcademicSections({ planId, color }: { planId: string; color?: string | null }) {
|
export function AcademicSections({ planId, color }: { planId: string; color?: string | null }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const auth = useSupabaseAuth()
|
||||||
|
const [openHistorial, setOpenHistorial] = useState(false)
|
||||||
|
if(!planId) return <div>Cargando…</div>
|
||||||
const { data: plan } = useSuspenseQuery(planTextOptions(planId))
|
const { data: plan } = useSuspenseQuery(planTextOptions(planId))
|
||||||
|
|
||||||
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
|
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
|
||||||
@@ -151,6 +158,7 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
|||||||
{ id: "sec-ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" 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-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-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 }
|
||||||
],
|
],
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
@@ -159,61 +167,118 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
|
|||||||
<>
|
<>
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
{sections.map((s) => {
|
{sections.map((s) => {
|
||||||
const text = plan[s.key] ?? null
|
const text = plan[s.key] ?? null
|
||||||
return (
|
return (
|
||||||
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
|
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
|
||||||
<ExpandableText text={text} mono={s.mono} />
|
{s.key === "historico" ? (
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<Button variant="outline" size="sm" onClick={() => setOpenHistorial(true)}>
|
||||||
<Button
|
Ver historial
|
||||||
variant="outline"
|
</Button>
|
||||||
size="sm"
|
) : (
|
||||||
disabled={!text || (Array.isArray(text) && text.length === 0)}
|
<>
|
||||||
onClick={() => {
|
<ExpandableText text={text} mono={s.mono} />
|
||||||
const toCopy = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
if (toCopy) navigator.clipboard.writeText(toCopy)
|
<Button
|
||||||
}}
|
variant="outline"
|
||||||
>
|
size="sm"
|
||||||
Copiar
|
disabled={!text || (Array.isArray(text) && text.length === 0)}
|
||||||
</Button>
|
onClick={() => {
|
||||||
<Button
|
const toCopy = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||||
variant="ghost"
|
if (toCopy) navigator.clipboard.writeText(toCopy)
|
||||||
size="sm"
|
}}
|
||||||
onClick={() => {
|
>
|
||||||
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
Copiar
|
||||||
setEditing({ key: s.key, title: s.title })
|
</Button>
|
||||||
setDraft(current)
|
{s.key !== "prompt" && (
|
||||||
}}
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
Editar
|
size="sm"
|
||||||
</Button>
|
onClick={() => {
|
||||||
</div>
|
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||||
</SectionPanel>
|
setEditing({ key: s.key, title: s.title })
|
||||||
)
|
setDraft(current)
|
||||||
})}
|
}}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionPanel>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Diálogo de edición */}
|
{/* 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">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="font-mono" >{editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}</DialogTitle>
|
<DialogTitle className="font-mono">
|
||||||
</DialogHeader>
|
{editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}
|
||||||
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`} placeholder="Escribe aquí…" />
|
</DialogTitle>
|
||||||
<DialogFooter>
|
</DialogHeader>
|
||||||
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
|
||||||
<Button
|
<Textarea
|
||||||
onClick={() => {
|
value={draft}
|
||||||
if (!editing) return
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
updateField.mutate({ key: editing.key, value: draft })
|
className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`}
|
||||||
setEditing(null)
|
placeholder="Escribe aquí…"
|
||||||
}}
|
/>
|
||||||
disabled={updateField.isPending}
|
|
||||||
>
|
<DialogFooter>
|
||||||
{updateField.isPending ? "Guardando…" : "Guardar"}
|
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
||||||
</Button>
|
<Button
|
||||||
</DialogFooter>
|
onClick={async () => {
|
||||||
</DialogContent>
|
if (!editing) return
|
||||||
</Dialog>
|
|
||||||
|
// 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>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<HistorialCambiosModal
|
||||||
|
open={openHistorial}
|
||||||
|
onClose={() => setOpenHistorial(false)}
|
||||||
|
planId={planId}
|
||||||
|
onRestore={async (key, value) => {
|
||||||
|
updateField.mutate({ key, value })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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 };
|
||||||
|
}
|
||||||
@@ -73,7 +73,6 @@ function useUserDisplay() {
|
|||||||
avatar: claims?.avatar ?? null,
|
avatar: claims?.avatar ?? null,
|
||||||
initials: getInitials([nombre, apellidos].filter(Boolean).join(" ")),
|
initials: getInitials([nombre, apellidos].filter(Boolean).join(" ")),
|
||||||
role,
|
role,
|
||||||
isAdmin: Boolean(claims?.claims_admin),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +149,7 @@ function Layout() {
|
|||||||
|
|
||||||
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
const { claims } = useSupabaseAuth()
|
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 ?? '')
|
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')
|
||||||
|
|
||||||
@@ -189,18 +188,18 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<Link
|
<Link
|
||||||
to="/facultades"
|
to="/facultades"
|
||||||
key='facultades'
|
key='facultades'
|
||||||
activeOptions={{ exact: true }}
|
activeOptions={{ exact: true }}
|
||||||
activeProps={{ className: "bg-primary/10 text-foreground" }}
|
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"
|
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" />
|
<KeySquare className="h-4 w-4" />
|
||||||
<span className="truncate">Facultades</span>
|
<span className="truncate">Facultades</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<Separator className="mt-auto" />
|
<Separator className="mt-auto" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// routes/_authenticated/archivos.tsx
|
// routes/_authenticated/archivos.tsx
|
||||||
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||||
import { useMemo, useState } from "react"
|
import { use, useMemo, useState } from "react"
|
||||||
import { supabase } from "@/auth/supabase"
|
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||||
import * as Icons from "lucide-react"
|
import * as Icons from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -16,12 +16,13 @@ import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@
|
|||||||
import { DetailDialog } from "@/components/archivos/DetailDialog"
|
import { DetailDialog } from "@/components/archivos/DetailDialog"
|
||||||
|
|
||||||
import type { RefRow } from "@/types/RefRow"
|
import type { RefRow } from "@/types/RefRow"
|
||||||
|
import { uuid } from "zod"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/archivos")({
|
export const Route = createFileRoute("/_authenticated/archivos")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("fine_tuning_referencias")
|
.from("documentos")
|
||||||
.select("*")
|
.select("*")
|
||||||
.order("fecha_subida", { ascending: false })
|
.order("fecha_subida", { ascending: false })
|
||||||
.limit(200)
|
.limit(200)
|
||||||
@@ -67,10 +68,25 @@ function RouteComponent() {
|
|||||||
async function remove(id: string) {
|
async function remove(id: string) {
|
||||||
if (!confirm("¿Eliminar archivo de referencia?")) return
|
if (!confirm("¿Eliminar archivo de referencia?")) return
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from("fine_tuning_referencias")
|
.from("documentos")
|
||||||
.delete()
|
.delete()
|
||||||
.eq("fine_tuning_referencias_id", id)
|
.eq("documentos_id", id)
|
||||||
if (error) return alert(error.message)
|
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()
|
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">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{filtered.map((r) => (
|
{filtered.map((r) => (
|
||||||
<article
|
<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"
|
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
|
||||||
>
|
>
|
||||||
<header className="min-w-0">
|
<header className="min-w-0">
|
||||||
@@ -151,6 +167,7 @@ function RouteComponent() {
|
|||||||
<p className="text-sm text-neutral-700 line-clamp-3">{r.descripcion}</p>
|
<p className="text-sm text-neutral-700 line-clamp-3">{r.descripcion}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tags
|
||||||
{r.tags && r.tags.length > 0 && (
|
{r.tags && r.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{r.tags.map((t, i) => (
|
{r.tags.map((t, i) => (
|
||||||
@@ -159,13 +176,13 @@ function RouteComponent() {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between gap-2">
|
<div className="mt-auto flex items-center justify-between gap-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setViewing(r)}>
|
<Button variant="ghost" size="sm" onClick={() => setViewing(r)}>
|
||||||
<Icons.Eye className="w-4 h-4 mr-1" /> Ver
|
<Icons.Eye className="w-4 h-4 mr-1" /> Ver
|
||||||
</Button>
|
</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
|
<Icons.Trash2 className="w-4 h-4 mr-1" /> Eliminar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,6 +209,7 @@ function RouteComponent() {
|
|||||||
function UploadDialog({
|
function UploadDialog({
|
||||||
open, onOpenChange, onDone,
|
open, onOpenChange, onDone,
|
||||||
}: { open: boolean; onOpenChange: (o: boolean) => void; onDone: () => void }) {
|
}: { open: boolean; onOpenChange: (o: boolean) => void; onDone: () => void }) {
|
||||||
|
const supabaseAuth = useSupabaseAuth()
|
||||||
const [file, setFile] = useState<File | null>(null)
|
const [file, setFile] = useState<File | null>(null)
|
||||||
const [instrucciones, setInstrucciones] = useState("")
|
const [instrucciones, setInstrucciones] = useState("")
|
||||||
const [tags, setTags] = useState("")
|
const [tags, setTags] = useState("")
|
||||||
@@ -222,6 +240,7 @@ function UploadDialog({
|
|||||||
prompt: instrucciones,
|
prompt: instrucciones,
|
||||||
fileBase64,
|
fileBase64,
|
||||||
insert: true,
|
insert: true,
|
||||||
|
uuid: supabaseAuth.user?.id ?? null,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -234,21 +253,21 @@ function UploadDialog({
|
|||||||
try {
|
try {
|
||||||
const payload = await res.json()
|
const payload = await res.json()
|
||||||
createdId =
|
createdId =
|
||||||
payload?.fine_tuning_referencias_id ||
|
payload?.documentos_id ||
|
||||||
payload?.id ||
|
payload?.id ||
|
||||||
payload?.data?.fine_tuning_referencias_id ||
|
payload?.data?.documentos_id ||
|
||||||
null
|
null
|
||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
|
|
||||||
if (createdId && (tags.trim() || fuente.trim() || typeof interno === "boolean")) {
|
if (createdId && (tags.trim() || fuente.trim() || typeof interno === "boolean")) {
|
||||||
await supabase
|
await supabase
|
||||||
.from("fine_tuning_referencias")
|
.from("documentos")
|
||||||
.update({
|
.update({
|
||||||
tags: tags.trim() ? tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
|
tags: tags.trim() ? tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
|
||||||
fuente_autoridad: fuente.trim() || undefined,
|
fuente_autoridad: fuente.trim() || undefined,
|
||||||
interno,
|
interno,
|
||||||
})
|
})
|
||||||
.eq("fine_tuning_referencias_id", createdId)
|
.eq("documentos_id", createdId)
|
||||||
}
|
}
|
||||||
|
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// routes/_authenticated/asignatura/$asignaturaId.tsx
|
// routes/_authenticated/asignatura/$asignaturaId.tsx
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
|
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
|
||||||
import * as Icons from "lucide-react"
|
import * as Icons from "lucide-react"
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "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 { Button } from "@/components/ui/button"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -134,7 +135,7 @@ function Page() {
|
|||||||
{/* ===== Hero ===== */}
|
{/* ===== Hero ===== */}
|
||||||
<div className="relative overflow-hidden rounded-3xl border shadow-sm">
|
<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={`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="min-w-0">
|
||||||
<div className="inline-flex items-center gap-2 text-xs text-neutral-600">
|
<div className="inline-flex items-center gap-2 text-xs text-neutral-600">
|
||||||
<Icons.BookOpen className="h-4 w-4" /> Asignatura
|
<Icons.BookOpen className="h-4 w-4" /> Asignatura
|
||||||
@@ -165,6 +166,7 @@ function Page() {
|
|||||||
</Button>
|
</Button>
|
||||||
<EditAsignaturaButton asignatura={a} onUpdate={setA} />
|
<EditAsignaturaButton asignatura={a} onUpdate={setA} />
|
||||||
<MejorarAIButton asignaturaId={a.id} onApply={(nuevo) => setA(nuevo)} />
|
<MejorarAIButton asignaturaId={a.id} onApply={(nuevo) => setA(nuevo)} />
|
||||||
|
<BorrarAsignaturaButton asignatura_id={a.id} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -191,7 +193,7 @@ function Page() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Syllabus */}
|
{/* Syllabus */}
|
||||||
{unidades.length > 0 && (
|
|
||||||
<Section id="syllabus" title="Programa / Contenidos" icon={Icons.ListTree}>
|
<Section id="syllabus" title="Programa / Contenidos" icon={Icons.ListTree}>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
@@ -285,7 +287,7 @@ function Page() {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
{/* Bibliografía */}
|
{/* Bibliografía */}
|
||||||
@@ -401,29 +403,79 @@ function EditAsignaturaButton({ asignatura, onUpdate }: {
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [form, setForm] = useState<Partial<Asignatura>>({})
|
const [form, setForm] = useState<Partial<Asignatura>>({})
|
||||||
|
const auth = useSupabaseAuth()
|
||||||
|
|
||||||
const openAndFill = () => { setForm(asignatura); setOpen(true) }
|
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() {
|
async function save() {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
const payload = {
|
try {
|
||||||
nombre: form.nombre ?? asignatura.nombre,
|
// 1️⃣ Preparar el payload final
|
||||||
clave: form.clave ?? asignatura.clave,
|
const payload = {
|
||||||
tipo: form.tipo ?? asignatura.tipo,
|
nombre: form.nombre ?? asignatura.nombre,
|
||||||
semestre: form.semestre ?? asignatura.semestre,
|
clave: form.clave ?? asignatura.clave,
|
||||||
creditos: form.creditos ?? asignatura.creditos,
|
tipo: form.tipo ?? asignatura.tipo,
|
||||||
horas_teoricas: form.horas_teoricas ?? asignatura.horas_teoricas,
|
semestre: form.semestre ?? asignatura.semestre,
|
||||||
horas_practicas: form.horas_practicas ?? asignatura.horas_practicas,
|
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 (
|
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 }) {
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -603,6 +700,7 @@ export function EditContenidosButton({
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [units, setUnits] = useState<UnitDraft[]>([])
|
const [units, setUnits] = useState<UnitDraft[]>([])
|
||||||
const [initialUnits, setInitialUnits] = useState<UnitDraft[]>([])
|
const [initialUnits, setInitialUnits] = useState<UnitDraft[]>([])
|
||||||
|
const auth = useSupabaseAuth() // 👈 para registrar el usuario que edita
|
||||||
|
|
||||||
// --- Normaliza entrada flexible a estructura estable
|
// --- Normaliza entrada flexible a estructura estable
|
||||||
const normalize = useCallback((v: any): UnitDraft[] => {
|
const normalize = useCallback((v: any): UnitDraft[] => {
|
||||||
@@ -632,13 +730,13 @@ export function EditContenidosButton({
|
|||||||
}
|
}
|
||||||
return { title, temas }
|
return { title, temas }
|
||||||
})
|
})
|
||||||
return entries.length ? entries : [{ title: "Unidad 1", temas: [] }]
|
return entries.length ? entries : [{ title: "", temas: [] }]
|
||||||
} catch {
|
} 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 buildPayload = useCallback((us: UnitDraft[]) => {
|
||||||
const out: Record<string, any> = {}
|
const out: Record<string, any> = {}
|
||||||
us.forEach((u, idx) => {
|
us.forEach((u, idx) => {
|
||||||
@@ -650,14 +748,14 @@ export function EditContenidosButton({
|
|||||||
.forEach((t, i) => {
|
.forEach((t, i) => {
|
||||||
sub[String(i + 1)] = t
|
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
|
return out
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// --- Limpia UI: recorta espacios, elimina líneas vacías/duplicadas (case-insensitive)
|
// --- Limpia UI
|
||||||
const cleanUnits = useCallback((us: UnitDraft[]) => {
|
const cleanUnits = useCallback((us: UnitDraft[]) => {
|
||||||
return us.map((u, idx) => {
|
return us.map((u) => {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const temas = u.temas
|
const temas = u.temas
|
||||||
.map((t) => t.trim())
|
.map((t) => t.trim())
|
||||||
@@ -668,10 +766,7 @@ export function EditContenidosButton({
|
|||||||
seen.add(key)
|
seen.add(key)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
return {
|
return { title: (u.title || "").trim(), temas }
|
||||||
title: (u.title || "").trim() || `Unidad ${idx + 1}`,
|
|
||||||
temas,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -687,7 +782,7 @@ export function EditContenidosButton({
|
|||||||
[units, initialUnits, cleanUnits],
|
[units, initialUnits, cleanUnits],
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Atajos: Guardar con Ctrl/Cmd + Enter
|
// --- Atajos: Ctrl/Cmd + Enter
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
@@ -699,7 +794,6 @@ export function EditContenidosButton({
|
|||||||
}
|
}
|
||||||
window.addEventListener("keydown", handler)
|
window.addEventListener("keydown", handler)
|
||||||
return () => window.removeEventListener("keydown", handler)
|
return () => window.removeEventListener("keydown", handler)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open, units, saving])
|
}, [open, units, saving])
|
||||||
|
|
||||||
// --- Acciones por unidad
|
// --- Acciones por unidad
|
||||||
@@ -707,15 +801,17 @@ export function EditContenidosButton({
|
|||||||
if (!confirm("¿Eliminar esta unidad?")) return
|
if (!confirm("¿Eliminar esta unidad?")) return
|
||||||
setUnits((prev) => prev.filter((_, i) => i !== idx))
|
setUnits((prev) => prev.filter((_, i) => i !== idx))
|
||||||
}
|
}
|
||||||
|
|
||||||
const moveUnit = (idx: number, dir: -1 | 1) => {
|
const moveUnit = (idx: number, dir: -1 | 1) => {
|
||||||
setUnits((prev) => {
|
setUnits((prev) => {
|
||||||
const next = [...prev]
|
const next = [...prev]
|
||||||
const j = idx + dir
|
const j = idx + dir
|
||||||
if (j < 0 || j >= next.length) return prev
|
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
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const duplicateUnit = (idx: number) => {
|
const duplicateUnit = (idx: number) => {
|
||||||
setUnits((prev) => {
|
setUnits((prev) => {
|
||||||
const next = [...prev]
|
const next = [...prev]
|
||||||
@@ -727,24 +823,54 @@ export function EditContenidosButton({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ Función para guardar con respaldo histórico
|
||||||
async function save() {
|
async function save() {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
const cleaned = cleanUnits(units)
|
try {
|
||||||
const contenidos = buildPayload(cleaned)
|
const cleaned = cleanUnits(units)
|
||||||
const { data, error } = await supabase
|
const contenidos = buildPayload(cleaned)
|
||||||
.from("asignaturas")
|
|
||||||
.update({ contenidos })
|
// 1️⃣ Generar diff entre valor anterior y nuevo
|
||||||
.eq("id", asignaturaId)
|
const diff = [
|
||||||
.select()
|
{
|
||||||
.maybeSingle()
|
op: "replace",
|
||||||
setSaving(false)
|
path: "/contenidos",
|
||||||
if (error) {
|
from: value,
|
||||||
alert(error.message || "No se pudo guardar")
|
value: contenidos,
|
||||||
return
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 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 = () => {
|
const cancel = () => {
|
||||||
@@ -839,7 +965,7 @@ export function EditContenidosButton({
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() =>
|
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
|
<Icons.Plus className="w-4 h-4 mr-2" /> Agregar unidad
|
||||||
@@ -850,7 +976,7 @@ export function EditContenidosButton({
|
|||||||
|
|
||||||
<DialogFooter className="px-6 pb-5">
|
<DialogFooter className="px-6 pb-5">
|
||||||
<Button variant="outline" onClick={cancel}>Cancelar</Button>
|
<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 ? (
|
{saving ? (
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<Icons.Loader2 className="h-4 w-4 animate-spin" /> Guardando…
|
<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 { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
|
||||||
import { supabase } from '@/auth/supabase'
|
import { supabase } from '@/auth/supabase'
|
||||||
import * as Icons from 'lucide-react'
|
import * as Icons from 'lucide-react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
|
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 { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { InfoChip } from '@/components/planes/InfoChip'
|
||||||
|
|
||||||
/* ================== Tipos ================== */
|
/* ================== Tipos ================== */
|
||||||
type FacMini = { id: string; nombre: string; color?: string | null; icon?: string | null }
|
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[]> {
|
async function fetchAsignaturas(search: SearchState): Promise<Asignatura[]> {
|
||||||
const planIds = await fetchPlanIdsByScope(search)
|
const planIds = await fetchPlanIdsByScope(search)
|
||||||
if (planIds && planIds.length === 0) return []
|
if (planIds && planIds.length === 0) return []
|
||||||
|
console.log(AsignaturaCard);
|
||||||
|
|
||||||
let query = supabase
|
let query = supabase
|
||||||
.from('asignaturas')
|
.from('asignaturas')
|
||||||
.select(`
|
.select(`
|
||||||
@@ -169,6 +171,60 @@ function RouteComponent() {
|
|||||||
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
|
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
|
||||||
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
|
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
|
// NEW: Clonado individual
|
||||||
const [cloneOpen, setCloneOpen] = useState(false)
|
const [cloneOpen, setCloneOpen] = useState(false)
|
||||||
const [cloneTarget, setCloneTarget] = useState<Asignatura | null>(null)
|
const [cloneTarget, setCloneTarget] = useState<Asignatura | null>(null)
|
||||||
@@ -217,28 +273,31 @@ function RouteComponent() {
|
|||||||
return { sinBibliografia, sinCriterios, sinContenidos }
|
return { sinBibliografia, sinCriterios, sinContenidos }
|
||||||
}, [asignaturas])
|
}, [asignaturas])
|
||||||
|
|
||||||
// Filtrado
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const t = q.trim().toLowerCase()
|
const t = q.trim().toLowerCase()
|
||||||
return asignaturas.filter(a => {
|
return asignaturas.filter(a => {
|
||||||
const matchesQ =
|
const matchesQ =
|
||||||
!t ||
|
!t ||
|
||||||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
|
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.some(v => String(v).toLowerCase().includes(t))
|
.some(v => String(v).toLowerCase().includes(t))
|
||||||
|
|
||||||
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
|
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
|
||||||
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
|
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 =
|
const flagOK =
|
||||||
!flag ||
|
!flag ||
|
||||||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
|
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
|
||||||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
|
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
|
||||||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
|
(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
|
// Agrupación
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
@@ -257,7 +316,19 @@ function RouteComponent() {
|
|||||||
}, [filtered, groupBy])
|
}, [filtered, groupBy])
|
||||||
|
|
||||||
// Helpers
|
// 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
|
// NEW: util para clonar 1 asignatura
|
||||||
async function cloneOne(src: Asignatura, overrides: {
|
async function cloneOne(src: Asignatura, overrides: {
|
||||||
@@ -292,6 +363,8 @@ function RouteComponent() {
|
|||||||
if (error) throw error
|
if (error) throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// NEW: abrir modal clon individual
|
// NEW: abrir modal clon individual
|
||||||
function openClone(a: Asignatura) {
|
function openClone(a: Asignatura) {
|
||||||
setCloneTarget(a)
|
setCloneTarget(a)
|
||||||
@@ -320,6 +393,8 @@ function RouteComponent() {
|
|||||||
setCart([])
|
setCart([])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// NEW: clonado en lote
|
// NEW: clonado en lote
|
||||||
async function cloneBulk() {
|
async function cloneBulk() {
|
||||||
if (!bulk.plan_destino_id) { toast.error('Selecciona un plan de destino'); return }
|
if (!bulk.plan_destino_id) { toast.error('Selecciona un plan de destino'); return }
|
||||||
@@ -394,51 +469,111 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filtros */}
|
{/* Filtros */}
|
||||||
<div className="grid gap-4 sm:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-5 items-end">
|
||||||
|
{/* 🔍 Búsqueda */}
|
||||||
<div>
|
<div>
|
||||||
<Label>Búsqueda</Label>
|
<Label>Búsqueda</Label>
|
||||||
<Input
|
<Input
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={handleChange}
|
||||||
placeholder="Nombre, clave, plan, carrera, facultad…"
|
placeholder="Nombre, clave, plan, carrera, facultad…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 📘 Semestre */}
|
||||||
<div>
|
<div>
|
||||||
<Label>Semestre</Label>
|
<Label>Semestre</Label>
|
||||||
<Select value={sem} onValueChange={setSem}>
|
<Select value={sem} onValueChange={setSem}>
|
||||||
<SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger>
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Todos" />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="todos">Todos</SelectItem>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🏛️ Facultad */}
|
||||||
<div>
|
<div>
|
||||||
<Label>Tipo</Label>
|
<Label>Facultad</Label>
|
||||||
<Select value={tipo} onValueChange={setTipo}>
|
<Select
|
||||||
<SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger>
|
value={facultad ?? "todas"}
|
||||||
<SelectContent className="max-h-64">
|
onValueChange={(val) => {
|
||||||
<SelectItem value="todos">Todos</SelectItem>
|
setFacultad(val)
|
||||||
{tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* 🎓 Carrera */}
|
||||||
<Label>Agrupación</Label>
|
<div className={!facultad || facultad === "todas" ? "invisible" : ""}>
|
||||||
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}>
|
<Label>Carrera</Label>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<Select
|
||||||
|
value={carrera ?? "todas"}
|
||||||
|
onValueChange={(val) => setCarrera(val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<SelectValue placeholder="Filtrar por carrera" />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="semestre">Por semestre</SelectItem>
|
<SelectItem value="todas">Todas las carreras</SelectItem>
|
||||||
<SelectItem value="ninguno">Sin agrupación</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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Chips de salud */}
|
{/* Chips de salud */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<HealthChip
|
<HealthChip
|
||||||
@@ -462,7 +597,7 @@ function RouteComponent() {
|
|||||||
label="Sin contenidos"
|
label="Sin contenidos"
|
||||||
value={salud.sinContenidos}
|
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}>
|
<Button variant="ghost" className="h-7 px-3" onClick={clearFilters}>
|
||||||
Limpiar filtros
|
Limpiar filtros
|
||||||
</Button>
|
</Button>
|
||||||
@@ -694,9 +829,15 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
|
|||||||
const horasP = a.horas_practicas ?? 0
|
const horasP = a.horas_practicas ?? 0
|
||||||
const meta = tipoMeta(a.tipo)
|
const meta = tipoMeta(a.tipo)
|
||||||
const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2
|
const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2
|
||||||
|
console.log(a);
|
||||||
|
|
||||||
return (
|
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="p-3">
|
||||||
<div className="flex items-start gap-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">
|
<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}
|
<Icons.ScrollText className="w-3.5 h-3.5" /> <strong>Plan:</strong>{a.plan.nombre}
|
||||||
</span>
|
</span>
|
||||||
{a.plan.carrera && (
|
{a.plan.carrera && (
|
||||||
<span className="inline-flex items-center gap-1">
|
<InfoChip
|
||||||
<Icons.GraduationCap className="w-3.5 h-3.5" /> <strong>Carrera:</strong> {a.plan.carrera.nombre}
|
icon={<Icons.GraduationCap className="h-3 w-3" />}
|
||||||
</span>
|
label={a.plan.carrera.nombre}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{a.plan.carrera?.facultad && (
|
{a.plan.carrera?.facultad && (
|
||||||
<span className="inline-flex items-center gap-1">
|
<InfoChip
|
||||||
<FacIcon className="w-3.5 h-3.5" /> {a.plan.carrera.facultad.nombre}
|
icon={<Icons.Building2 className="h-3 w-3" />}
|
||||||
</span>
|
label={a.plan.carrera.facultad.nombre}
|
||||||
|
tint={a.plan.carrera.facultad.color}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -126,6 +126,17 @@ function RouteComponent() {
|
|||||||
const [detail, setDetail] = useState<CarreraRow | null>(null)
|
const [detail, setDetail] = useState<CarreraRow | null>(null)
|
||||||
const [editCarrera, setEditCarrera] = useState<CarreraRow | null>(null)
|
const [editCarrera, setEditCarrera] = useState<CarreraRow | null>(null)
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
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 filtered = useMemo(() => {
|
||||||
const term = q.trim().toLowerCase()
|
const term = q.trim().toLowerCase()
|
||||||
@@ -198,10 +209,7 @@ function RouteComponent() {
|
|||||||
const border = tint(fac?.color, 0.28)
|
const border = tint(fac?.color, 0.28)
|
||||||
const chip = tint(fac?.color, 0.1)
|
const chip = tint(fac?.color, 0.1)
|
||||||
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
|
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 (
|
return (
|
||||||
<ContextMenu key={c.id}>
|
<ContextMenu key={c.id}>
|
||||||
<ContextMenuTrigger onClick={(e) => openContextMenu(e)}>
|
<ContextMenuTrigger onClick={(e) => openContextMenu(e)}>
|
||||||
@@ -233,11 +241,14 @@ function RouteComponent() {
|
|||||||
<ContextMenuItem onClick={() => setEditCarrera(c)}>
|
<ContextMenuItem onClick={() => setEditCarrera(c)}>
|
||||||
<Icons.Pencil className="w-4 h-4 mr-2" /> Editar
|
<Icons.Pencil className="w-4 h-4 mr-2" /> Editar
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem onClick={() => setDeleteOpen(true)}>
|
<ContextMenuItem onClick={() => {
|
||||||
|
setDeleteTarget(c)
|
||||||
|
setDeleteOpen(true)
|
||||||
|
}}>
|
||||||
<Icons.Trash className="w-4 h-4 mr-2" /> Eliminar
|
<Icons.Trash className="w-4 h-4 mr-2" /> Eliminar
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
{deleteDialog}
|
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -247,6 +258,8 @@ function RouteComponent() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{deleteDialog}
|
||||||
|
|
||||||
{/* Crear / Editar */}
|
{/* Crear / Editar */}
|
||||||
<CarreraFormDialog
|
<CarreraFormDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ function RouteComponent() {
|
|||||||
|
|
||||||
const name = auth.user?.user_metadata?.nombre || auth.user?.email?.split('@')[0] || '¡Hola!'
|
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 role = auth.claims?.role as 'lci' | 'vicerrectoria' | 'secretario_academico' | 'jefe_carrera' | 'planeacion' | undefined
|
||||||
|
|
||||||
const navigate = useNavigate({ from: Route.fullPath })
|
const navigate = useNavigate({ from: Route.fullPath })
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import { Textarea } from "@/components/ui/textarea"
|
|||||||
import { AuroraButton } from "@/components/effect/aurora-button"
|
import { AuroraButton } from "@/components/effect/aurora-button"
|
||||||
import { DeletePlanButton } from "@/components/planes/DeletePlan"
|
import { DeletePlanButton } from "@/components/planes/DeletePlan"
|
||||||
import { AddAsignaturaButton } from "@/components/planes/AddAsignaturaButton"
|
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")({
|
export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
@@ -33,24 +35,27 @@ export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
|||||||
|
|
||||||
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
|
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
|
||||||
const { planId } = params
|
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(planByIdOptions(planId)),
|
||||||
queryClient.ensureQueryData(asignaturasCountOptions(planId)),
|
// queryClient.ensureQueryData(asignaturasCountOptions(planId)),
|
||||||
queryClient.ensureQueryData(asignaturasPreviewOptions(planId)),
|
queryClient.ensureQueryData(asignaturasPreviewOptions(planId)),
|
||||||
])
|
])
|
||||||
return { planId }
|
|
||||||
|
return { plan, asignaturas }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// ...existing code...
|
// ...existing code...
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { planId } = Route.useLoaderData() as LoaderData
|
//const { plan, asignaturas: asignaturasPreview } = Route.useLoaderData() as LoaderData
|
||||||
const auth = useSupabaseAuth()
|
const { plan } = Route.useLoaderData() as LoaderData
|
||||||
|
|
||||||
const { data: plan } = useSuspenseQuery(planByIdOptions(planId))
|
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(plan.id))
|
||||||
const { data: asignaturasCount } = useSuspenseQuery(asignaturasCountOptions(planId))
|
const auth = useSupabaseAuth()
|
||||||
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(planId))
|
const asignaturasCount = asignaturasPreview.length
|
||||||
|
|
||||||
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
|
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
|
||||||
const showCarrera = auth.claims?.role === 'secretario_academico'
|
const showCarrera = auth.claims?.role === 'secretario_academico'
|
||||||
@@ -78,7 +83,7 @@ function RouteComponent() {
|
|||||||
</nav>
|
</nav>
|
||||||
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
|
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
|
||||||
<div className="absolute inset-0 -z-0" style={accent} />
|
<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">
|
<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"
|
<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 }}>
|
style={{ borderColor: accent.borderColor as string }}>
|
||||||
@@ -98,11 +103,14 @@ function RouteComponent() {
|
|||||||
{plan.estado}
|
{plan.estado}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<div className='flex gap-2'>
|
{/* <div className='flex gap-2'> */}
|
||||||
<EditPlanButton plan={plan} />
|
<EditPlanButton plan={plan} />
|
||||||
<AdjustAIButton plan={plan} />
|
<AdjustAIButton plan={plan} />
|
||||||
|
{/* <DescargarPdfButton planId={plan.id} opcion="plan" /> */}
|
||||||
|
<DownloadPlanPDF plan={plan} />
|
||||||
|
<DescargarPdfButton planId={plan.id} opcion="asignaturas" />
|
||||||
<DeletePlanButton planId={plan.id} />
|
<DeletePlanButton planId={plan.id} />
|
||||||
</div>
|
{/* </div> */}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent ref={statsRef}>
|
<CardContent ref={statsRef}>
|
||||||
@@ -197,33 +205,77 @@ function StatCard({ label, value = "—", Icon = Icons.Info, accent, className =
|
|||||||
|
|
||||||
/* ===== Editar ===== */
|
/* ===== Editar ===== */
|
||||||
function EditPlanButton({ plan }: { plan: PlanFull }) {
|
function EditPlanButton({ plan }: { plan: PlanFull }) {
|
||||||
|
const auth = useSupabaseAuth()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [form, setForm] = useState<Partial<PlanFull>>({})
|
const [form, setForm] = useState<Partial<PlanFull>>({})
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const qc = useQueryClient()
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: async (payload: Partial<PlanFull>) => {
|
mutationFn: async (payload: Partial<PlanFull>) => {
|
||||||
const { error } = await supabase.from('plan_estudios').update({
|
// 1️⃣ Generar las diferencias antes del update
|
||||||
nombre: payload.nombre ?? plan.nombre,
|
const diff = generateDiff(plan, payload)
|
||||||
nivel: payload.nivel ?? plan.nivel,
|
|
||||||
duracion: payload.duracion ?? plan.duracion,
|
// 2️⃣ Guardar respaldo (solo si hay cambios)
|
||||||
total_creditos: payload.total_creditos ?? plan.total_creditos,
|
if (diff.length > 0) {
|
||||||
}).eq('id', plan.id)
|
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
|
if (error) throw error
|
||||||
},
|
},
|
||||||
|
|
||||||
onMutate: async (payload) => {
|
onMutate: async (payload) => {
|
||||||
await qc.cancelQueries({ queryKey: planKeys.byId(plan.id) })
|
await qc.cancelQueries({ queryKey: planKeys.byId(plan.id) })
|
||||||
const prev = qc.getQueryData<PlanFull>(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 }
|
return { prev }
|
||||||
},
|
},
|
||||||
|
|
||||||
onError: (_e, _vars, ctx) => {
|
onError: (_e, _vars, ctx) => {
|
||||||
if (ctx?.prev) qc.setQueryData(planKeys.byId(plan.id), ctx.prev)
|
if (ctx?.prev) qc.setQueryData(planKeys.byId(plan.id), ctx.prev)
|
||||||
},
|
},
|
||||||
|
|
||||||
onSettled: async () => {
|
onSettled: async () => {
|
||||||
await qc.invalidateQueries({ queryKey: planKeys.byId(plan.id) })
|
await qc.invalidateQueries({ queryKey: planKeys.byId(plan.id) })
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
|||||||
@@ -10,26 +10,44 @@ import { Plus, RefreshCcw, Building2 } from "lucide-react"
|
|||||||
import { InfoChip } from "@/components/planes/InfoChip"
|
import { InfoChip } from "@/components/planes/InfoChip"
|
||||||
import { CreatePlanDialog } from "@/components/planes/CreatePlanDialog"
|
import { CreatePlanDialog } from "@/components/planes/CreatePlanDialog"
|
||||||
import { chipTint } from "@/components/planes/chipTint"
|
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 = {
|
export type PlanDeEstudios = {
|
||||||
id: string; nombre: string; nivel: string | null; duracion: string | null;
|
id: string
|
||||||
total_creditos: number | null; estado: string | null; fecha_creacion: string | null; carrera_id: string | null
|
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 & {
|
type PlanRow = PlanDeEstudios & {
|
||||||
carreras: {
|
carreras: {
|
||||||
id: string; nombre: string;
|
id: string
|
||||||
facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null
|
nombre: string
|
||||||
|
facultades?: {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
color?: string | null
|
||||||
|
icon?: string | null
|
||||||
|
} | null
|
||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const planSearchSchema = z.object({
|
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")({
|
export const Route = createFileRoute("/_authenticated/planes")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
@@ -51,86 +69,185 @@ export const Route = createFileRoute("/_authenticated/planes")({
|
|||||||
validateSearch: planSearchSchema,
|
validateSearch: planSearchSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const auth = useSupabaseAuth()
|
const auth = useSupabaseAuth()
|
||||||
const { plan } = Route.useSearch()
|
const { plan, facultad, carrera } = Route.useSearch()
|
||||||
const [openCreate, setOpenCreate] = useState(false)
|
const [openCreate, setOpenCreate] = useState(false)
|
||||||
const data = Route.useLoaderData() as PlanRow[]
|
const data = Route.useLoaderData() as PlanRow[]
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const navigate = useNavigate({ from: Route.fullPath })
|
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"
|
// 🟣 Lista única de facultades
|
||||||
const showCarrera = showFacultad || auth.claims?.role === "secretario_academico"
|
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 filtered = useMemo(() => {
|
||||||
const term = plan?.trim().toLowerCase()
|
const term = plan?.trim().toLowerCase()
|
||||||
if (!term || !data) return data
|
let results = data ?? []
|
||||||
return data.filter((p) =>
|
|
||||||
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
|
if (term) {
|
||||||
.filter(Boolean)
|
results = results.filter((p) =>
|
||||||
.some((v) => String(v).toLowerCase().includes(term))
|
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
|
||||||
)
|
.filter(Boolean)
|
||||||
}, [plan, data])
|
.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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<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>
|
<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">
|
<div className="relative w-full md:w-80">
|
||||||
<Input
|
<Input
|
||||||
value={plan ?? ''}
|
value={plan ?? ""}
|
||||||
onChange={e => navigate({ search: { plan: e.target.value } })}
|
onChange={(e) =>
|
||||||
|
navigate({ search: { plan: e.target.value, facultad, carrera } })
|
||||||
|
}
|
||||||
placeholder="Buscar por nombre, nivel, estado…"
|
placeholder="Buscar por nombre, nivel, estado…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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" />
|
<RefreshCcw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* ➕ Nuevo plan */}
|
||||||
<Button onClick={() => setOpenCreate(true)}>
|
<Button onClick={() => setOpenCreate(true)}>
|
||||||
<Plus className="mr-2 h-4 w-4" /> Nuevo plan
|
<Plus className="mr-2 h-4 w-4" /> Nuevo plan
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{/* GRID de tarjetas con estilo suave por facultad */}
|
{/* GRID de tarjetas */}
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{filtered?.map((p) => {
|
{filtered?.map((p) => {
|
||||||
const fac = p.carreras?.facultades
|
const fac = p.carreras?.facultades
|
||||||
const styles = chipTint(fac?.color)
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={p.id}
|
key={p.id}
|
||||||
to="/plan/$planId"
|
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"
|
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 }}
|
params={{ planId: p.id }}
|
||||||
style={styles}
|
style={styles}
|
||||||
>
|
>
|
||||||
<div className="relative p-5 h-40 flex flex-col justify-between">
|
<div className="relative p-5 h-40 flex flex-col justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2"
|
<span
|
||||||
style={{ borderColor: styles.borderColor as string, background: 'rgba(255,255,255,.6)' }}>
|
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" />
|
<IconComp className="w-6 h-6" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-semibold truncate">{p.nombre}</div>
|
<div className="font-semibold truncate">{p.nombre}</div>
|
||||||
<div className="text-xs text-neutral-600 truncate">
|
<div className="text-xs text-neutral-600 truncate">
|
||||||
{p.nivel ?? "—"} {p.duracion ? `· ${p.duracion}` : ""}
|
{p.nivel ?? "—"}{" "}
|
||||||
|
{p.duracion ? `· ${p.duracion}` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dentro del map de tarjetas, sustituye SOLO el footer inferior */}
|
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<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">
|
<div className="min-w-0 flex-1 flex flex-wrap items-center gap-2">
|
||||||
{showCarrera && p.carreras?.nombre && (
|
{showCarrera && p.carreras?.nombre && (
|
||||||
<InfoChip
|
<InfoChip
|
||||||
@@ -147,18 +264,21 @@ function RouteComponent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* derecha: estado */}
|
|
||||||
{p.estado && (
|
{p.estado && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-white/60"
|
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>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
@@ -166,16 +286,14 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!filtered?.length && (
|
{!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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<CreatePlanDialog
|
<CreatePlanDialog open={openCreate} onOpenChange={setOpenCreate} />
|
||||||
open={openCreate}
|
|
||||||
onOpenChange={setOpenCreate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createFileRoute, useRouter } from "@tanstack/react-router"
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useSuspenseQuery, queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useSuspenseQuery, queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||||
|
import type { Role, UserClaims } from "@/auth/supabase"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
||||||
import {
|
import * as Icons from "lucide-react"
|
||||||
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 { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* -------------------- Tipos -------------------- */
|
/* -------------------- 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 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 -------------------- */
|
/* -------------------- Query Keys & Fetcher -------------------- */
|
||||||
const usersKeys = {
|
const usersKeys = {
|
||||||
@@ -69,13 +31,58 @@ const usersKeys = {
|
|||||||
list: () => [...usersKeys.root, "list"] as const,
|
list: () => [...usersKeys.root, "list"] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUsers(): Promise<AdminUser[]> {
|
async function fetchUsers(): Promise<UserClaims[]> {
|
||||||
// ⚠️ Dev only: service role en cliente
|
try {
|
||||||
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
|
const { data: perfiles, error } = await supabase.from("perfiles").select("id");
|
||||||
const { data } = await admin.auth.admin.listUsers()
|
|
||||||
return (data?.users ?? []) as AdminUser[]
|
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 = () =>
|
const usersOptions = () =>
|
||||||
queryOptions({ queryKey: usersKeys.list(), queryFn: fetchUsers, staleTime: 60_000 })
|
queryOptions({ queryKey: usersKeys.list(), queryFn: fetchUsers, staleTime: 60_000 })
|
||||||
|
|
||||||
@@ -91,12 +98,37 @@ export const Route = createFileRoute("/_authenticated/usuarios")({
|
|||||||
/* -------------------- Página -------------------- */
|
/* -------------------- Página -------------------- */
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const auth = useSupabaseAuth()
|
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 router = useRouter()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { data } = useSuspenseQuery(usersOptions())
|
const { data } = useSuspenseQuery(usersOptions())
|
||||||
|
|
||||||
const [q, setQ] = useState("")
|
const [q, setQ] = useState("")
|
||||||
const [editing, setEditing] = useState<AdminUser | null>(null)
|
const [editing, setEditing] = useState<UserClaims | null>(null)
|
||||||
const [form, setForm] = useState<{
|
const [form, setForm] = useState<{
|
||||||
role?: Role
|
role?: Role
|
||||||
claims_admin?: boolean
|
claims_admin?: boolean
|
||||||
@@ -118,10 +150,47 @@ function RouteComponent() {
|
|||||||
}>({ email: "", password: "" })
|
}>({ email: "", password: "" })
|
||||||
|
|
||||||
function genPassword() {
|
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("")
|
const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("")
|
||||||
return s.slice(0, 14)
|
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 ---------- */
|
/* ---------- Mutations ---------- */
|
||||||
const invalidateAll = async () => {
|
const invalidateAll = async () => {
|
||||||
await qc.invalidateQueries({ queryKey: usersKeys.root })
|
await qc.invalidateQueries({ queryKey: usersKeys.root })
|
||||||
@@ -167,11 +236,13 @@ function RouteComponent() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const toggleBan = useMutation({
|
const toggleBan = useMutation({
|
||||||
mutationFn: async (u: AdminUser) => {
|
mutationFn: async (u: UserClaims) => {
|
||||||
const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
|
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 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)
|
// const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
|
||||||
if (error) throw new Error(error.message)
|
// if (error) throw new Error(error.message)
|
||||||
return !banned
|
return !banned
|
||||||
},
|
},
|
||||||
onSuccess: async (isBanned) => {
|
onSuccess: async (isBanned) => {
|
||||||
@@ -183,39 +254,42 @@ function RouteComponent() {
|
|||||||
|
|
||||||
const createUser = useMutation({
|
const createUser = useMutation({
|
||||||
mutationFn: async (payload: typeof createForm) => {
|
mutationFn: async (payload: typeof createForm) => {
|
||||||
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
|
// Validaciones previas
|
||||||
const password = payload.password?.trim() || genPassword()
|
if (!payload.role) {
|
||||||
const { error, data } = await admin.auth.admin.createUser({
|
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(),
|
email: payload.email.trim(),
|
||||||
password,
|
password,
|
||||||
email_confirm: true,
|
options: {
|
||||||
user_metadata: {
|
data: {
|
||||||
nombre: payload.nombre ?? "",
|
nombre: payload.nombre ?? "",
|
||||||
apellidos: payload.apellidos ?? "",
|
apellidos: payload.apellidos ?? "",
|
||||||
title: payload.title ?? "",
|
title: payload.title ?? "",
|
||||||
clave: payload.clave ?? "",
|
clave: payload.clave ?? "",
|
||||||
avatar: payload.avatar ?? "",
|
avatar: payload.avatar ?? "",
|
||||||
},
|
role: payload.role,
|
||||||
app_metadata: {
|
role_id: payload.role ? ROLE_META[payload.role]?.id : null,
|
||||||
role: payload.role,
|
facultad_id: payload.facultad_id ?? null,
|
||||||
claims_admin: !!payload.claims_admin,
|
carrera_id: payload.carrera_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 && 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 })
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
onSuccess: async () => {
|
||||||
@@ -228,19 +302,23 @@ function RouteComponent() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const saveUser = useMutation({
|
const saveUser = useMutation({
|
||||||
mutationFn: async ({ u, f }: { u: AdminUser; f: typeof form }) => {
|
mutationFn: async ({ u, f }: { u: UserClaims; 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 }) })
|
const { error } = await supabase.rpc('actualizar_perfil_y_rol', {
|
||||||
// Simula éxito:
|
datos: {
|
||||||
// 2) Nombramiento si aplica
|
user_id: u.id,
|
||||||
if (f.role && (SCOPED_ROLES as readonly string[]).includes(f.role)) {
|
rol_nombre: f.role,
|
||||||
if (f.role === "director_facultad") {
|
titulo: f.title,
|
||||||
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "director_facultad", facultad_id: f.facultad_id! })
|
facultad_id: f.facultad_id,
|
||||||
} else if (f.role === "secretario_academico") {
|
carrera_id: f.carrera_id,
|
||||||
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "secretario_academico", facultad_id: f.facultad_id! })
|
nombre: f.nombre,
|
||||||
} else if (f.role === "jefe_carrera") {
|
apellidos: f.apellidos,
|
||||||
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "jefe_carrera", facultad_id: f.facultad_id!, carrera_id: f.carrera_id! })
|
avatar: f.avatar,
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -251,34 +329,29 @@ function RouteComponent() {
|
|||||||
onError: (e: any) => toast.error(e?.message || "No se pudo guardar"),
|
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 filtered = useMemo(() => {
|
||||||
const t = q.trim().toLowerCase()
|
const t = q.trim().toLowerCase()
|
||||||
if (!t) return data
|
if (!t) return data
|
||||||
return data.filter((u) => {
|
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 : ""
|
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)
|
.filter(Boolean)
|
||||||
.some((v) => String(v).toLowerCase().includes(t))
|
.some((v) => String(v).toLowerCase().includes(t))
|
||||||
})
|
})
|
||||||
}, [q, data])
|
}, [q, data])
|
||||||
|
|
||||||
function openEdit(u: AdminUser) {
|
function openEdit(u: UserClaims) {
|
||||||
setEditing(u)
|
setEditing(u)
|
||||||
setForm({
|
setForm({
|
||||||
role: u.app_metadata?.role,
|
role: u.role,
|
||||||
claims_admin: !!u.app_metadata?.claims_admin,
|
nombre: u.nombre ?? "",
|
||||||
nombre: u.user_metadata?.nombre ?? "",
|
apellidos: u.apellidos ?? "",
|
||||||
apellidos: u.user_metadata?.apellidos ?? "",
|
title: u.title ?? "",
|
||||||
title: u.user_metadata?.title ?? "",
|
clave: u.clave ?? "",
|
||||||
clave: u.user_metadata?.clave ?? "",
|
avatar: u.avatar ?? "",
|
||||||
avatar: u.user_metadata?.avatar ?? "",
|
facultad_id: u.facultad_id ?? null,
|
||||||
facultad_id: u.app_metadata?.facultad_id ?? null,
|
carrera_id: u.carrera_id ?? null,
|
||||||
carrera_id: u.app_metadata?.carrera_id ?? null,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,10 +374,10 @@ function RouteComponent() {
|
|||||||
<div className="flex items-center gap-2">
|
<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" />
|
<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}>
|
<Button variant="outline" size="icon" title="Recargar" onClick={invalidateAll}>
|
||||||
<RefreshCcw className="w-4 h-4" />
|
<Icons.RefreshCcw className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap">
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -312,48 +385,48 @@ function RouteComponent() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{filtered.map((u) => {
|
{filtered.map((u) => {
|
||||||
const m = u.user_metadata || {}
|
const roleCode: Role | undefined = u.role
|
||||||
const a = u.app_metadata || {}
|
const banned = false // cuando se tenga acceso a ese campo
|
||||||
const roleCode: Role | undefined = a.role
|
// const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now()
|
||||||
const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now()
|
|
||||||
return (
|
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 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">
|
<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="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<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">
|
<div className="mt-1 flex flex-wrap items-center gap-1.5 sm:gap-2">
|
||||||
{roleCode && <RolePill role={roleCode} />}
|
{roleCode && <RolePill role={roleCode} />}
|
||||||
{a.claims_admin ? (
|
{u.role === "lci" || u.role === "vicerrectoria" ? (
|
||||||
<Badge className="gap-1" variant="secondary"><ShieldCheck className="w-3 h-3" /> Admin</Badge>
|
<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">
|
<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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<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">
|
<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>
|
||||||
<Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] sm:text-xs text-neutral-600">
|
<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 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>
|
</div>
|
||||||
<div className="sm:hidden self-start shrink-0 flex gap-1">
|
<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="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"><Pencil 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,7 +444,7 @@ function RouteComponent() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<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>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>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>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"><Label>Avatar (URL)</Label><Input value={form.avatar ?? ""} onChange={(e) => setForm((s) => ({ ...s, avatar: e.target.value }))} /></div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -422,6 +495,7 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Probablemente ya no sea necesario
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Permisos</Label>
|
<Label>Permisos</Label>
|
||||||
<Select value={String(!!form.claims_admin)} onValueChange={(v) => setForm((s) => ({ ...s, claims_admin: v === 'true' }))}>
|
<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>
|
<SelectItem value="false">Usuario</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
||||||
@@ -463,16 +537,16 @@ function RouteComponent() {
|
|||||||
<div className="space-y-1 md:col-span-2">
|
<div className="space-y-1 md:col-span-2">
|
||||||
<Label>Contraseña temporal</Label>
|
<Label>Contraseña temporal</Label>
|
||||||
<div className="flex gap-2">
|
<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" />
|
<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={() => 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>
|
<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>
|
</div>
|
||||||
<p className="text-[11px] text-neutral-500">Pídeles cambiarla al iniciar sesión.</p>
|
<p className="text-[11px] text-neutral-500">Pídeles cambiarla al iniciar sesión.</p>
|
||||||
</div>
|
</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>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>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"><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>
|
<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) => {
|
onValueChange={(v) => {
|
||||||
setCreateForm((s) => {
|
setCreateForm((s) => {
|
||||||
const role = v as Role
|
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 === "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 }
|
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 }
|
return { ...s, role, facultad_id: null, carrera_id: null }
|
||||||
@@ -523,6 +598,7 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Probablemente ya no sea necesario
|
||||||
<div className="space-y-1 md:col-span-2">
|
<div className="space-y-1 md:col-span-2">
|
||||||
<Label>Permisos</Label>
|
<Label>Permisos</Label>
|
||||||
<Select value={String(!!createForm.claims_admin)} onValueChange={(v) => setCreateForm((s) => ({ ...s, claims_admin: v === "true" }))}>
|
<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>
|
<SelectItem value="false">Usuario</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
<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"}
|
{createUser.isPending ? "Creando…" : "Crear usuario"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</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 { useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
@@ -27,6 +27,7 @@ function LoginComponent() {
|
|||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -35,7 +36,7 @@ function LoginComponent() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await auth.login(email, password)
|
await auth.login(email, password)
|
||||||
window.location.href = redirect
|
router.navigate({ to: redirect})
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "No fue posible iniciar sesión")
|
setError(err.message || "No fue posible iniciar sesión")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -95,12 +96,6 @@ function LoginComponent() {
|
|||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="password">Contraseña</Label>
|
<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>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex w-10 items-center justify-center">
|
<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" />}
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="/reset-password"
|
||||||
|
className="text-xs text-muted-foreground underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
¿Olvidaste tu contraseña?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={isLoading} className="w-full font-mono" size="lg">
|
<Button type="submit" disabled={isLoading} className="w-full font-mono" size="lg">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type RefRow = {
|
export type RefRow = {
|
||||||
fine_tuning_referencias_id: string
|
documentos_id: string
|
||||||
titulo_archivo: string | null
|
titulo_archivo: string | null
|
||||||
descripcion: string | null
|
descripcion: string | null
|
||||||
s3_file_path: string | null // Added this property to match the API requirements.
|
s3_file_path: string | null // Added this property to match the API requirements.
|
||||||
|
|||||||
Reference in New Issue
Block a user