Compare commits
28 Commits
78580df13b
...
formatChat
| Author | SHA1 | Date | |
|---|---|---|---|
| 169599874e | |||
| 9b3880a02f | |||
| 9d9fb3d8a8 | |||
| a6f0010a53 | |||
| 29231206c0 | |||
| 93c79eee77 | |||
| 6f97a83eb0 | |||
| 4ec2c2d533 | |||
| efe7faa65f | |||
| c9d66ce2e5 | |||
| f7a29ad510 | |||
| e7a47f56f8 | |||
| 214d17cf98 | |||
| 8c890d76e0 | |||
| 7105b286bf | |||
| 0e884f20c5 | |||
| 8bb8399ec5 | |||
| 9462e25a20 | |||
| daac6f3f6d | |||
| 6d264a8214 | |||
| 4cf93ff1f4 | |||
| d25b8b0441 | |||
| bec6405c54 | |||
| 53502d927b | |||
| 6e2b3d72f1 | |||
| 0c5c3f935b | |||
| 8da08b6bf1 | |||
| 1fe8f2b6a8 |
4
.env.local2
Normal file
4
.env.local2
Normal file
@@ -0,0 +1,4 @@
|
||||
VITE_SUPABASE_URL=http://127.0.0.1:54321
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4ZGtzc3Vyem1qbm5oZ3RpYW1hIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEzNzg2MzIsImV4cCI6MjA1Njk1NDYzMn0.g1mBmsw-i6F6e-tPv5gWkHZacyPM2Y9X0fiKVYmVYKE
|
||||
#VITE_BACK_ORIGIN=http://localhost:3001
|
||||
VITE_BACK_ORIGIN=http://localhost:3001
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,6 +4,6 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
count.txt
|
||||
.env
|
||||
.env*
|
||||
.nitro
|
||||
.tanstack
|
||||
|
||||
48
bun.lock
48
bun.lock
@@ -32,6 +32,8 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"gsap": "^3.13.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lucide-react": "^0.540.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
@@ -482,12 +484,18 @@
|
||||
|
||||
"@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
|
||||
|
||||
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
|
||||
|
||||
"@types/phoenix": ["@types/phoenix@1.6.6", "", {}, "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A=="],
|
||||
|
||||
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
@@ -536,6 +544,8 @@
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
@@ -548,6 +558,8 @@
|
||||
|
||||
"canvas-confetti": ["canvas-confetti@1.9.3", "", {}, "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g=="],
|
||||
|
||||
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chai": ["chai@5.3.1", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-48af6xm9gQK8rhIcOxWwdGzIervm8BVTin+yRp9HEvU20BtVZ2lBywlIJBzwaDtvo0FvjeL7QdCADoUoqIbV3A=="],
|
||||
@@ -578,6 +590,10 @@
|
||||
|
||||
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
|
||||
|
||||
"core-js": ["core-js@3.46.0", "", {}, "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA=="],
|
||||
|
||||
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
|
||||
|
||||
"cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
@@ -628,6 +644,8 @@
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"dompurify": ["dompurify@3.3.0", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.204", "", {}, "sha512-s9VbBXWxfDrl67PlO4avwh0/GU2vcwx8Fph3wlR8LJl7ySGYId59EFE17VWVcuC3sLWNPENm6Z/uGqKbkPCcXA=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||
@@ -654,8 +672,12 @@
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
@@ -682,6 +704,8 @@
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
@@ -694,6 +718,8 @@
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
|
||||
|
||||
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||
|
||||
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||
@@ -726,6 +752,10 @@
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jspdf": ["jspdf@3.0.3", "", { "dependencies": { "@babel/runtime": "^7.26.9", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.2.4", "html2canvas": "^1.0.0-rc.5" } }, "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ=="],
|
||||
|
||||
"jspdf-autotable": ["jspdf-autotable@5.0.2", "", { "peerDependencies": { "jspdf": "^2 || ^3" } }, "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
|
||||
@@ -836,6 +866,8 @@
|
||||
|
||||
"nwsapi": ["nwsapi@2.2.21", "", {}, "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA=="],
|
||||
|
||||
"pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
@@ -844,6 +876,8 @@
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
|
||||
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
@@ -858,6 +892,8 @@
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
|
||||
|
||||
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="],
|
||||
@@ -886,6 +922,8 @@
|
||||
|
||||
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||
|
||||
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
@@ -894,6 +932,8 @@
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
|
||||
|
||||
"rollup": ["rollup@4.46.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.46.3", "@rollup/rollup-android-arm64": "4.46.3", "@rollup/rollup-darwin-arm64": "4.46.3", "@rollup/rollup-darwin-x64": "4.46.3", "@rollup/rollup-freebsd-arm64": "4.46.3", "@rollup/rollup-freebsd-x64": "4.46.3", "@rollup/rollup-linux-arm-gnueabihf": "4.46.3", "@rollup/rollup-linux-arm-musleabihf": "4.46.3", "@rollup/rollup-linux-arm64-gnu": "4.46.3", "@rollup/rollup-linux-arm64-musl": "4.46.3", "@rollup/rollup-linux-loongarch64-gnu": "4.46.3", "@rollup/rollup-linux-ppc64-gnu": "4.46.3", "@rollup/rollup-linux-riscv64-gnu": "4.46.3", "@rollup/rollup-linux-riscv64-musl": "4.46.3", "@rollup/rollup-linux-s390x-gnu": "4.46.3", "@rollup/rollup-linux-x64-gnu": "4.46.3", "@rollup/rollup-linux-x64-musl": "4.46.3", "@rollup/rollup-win32-arm64-msvc": "4.46.3", "@rollup/rollup-win32-ia32-msvc": "4.46.3", "@rollup/rollup-win32-x64-msvc": "4.46.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RZn2XTjXb8t5g13f5YclGoilU/kwT696DIkY3sywjdZidNSi3+vseaQov7D7BZXVJCPv3pDWUN69C78GGbXsKw=="],
|
||||
|
||||
"rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
|
||||
@@ -924,6 +964,8 @@
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
|
||||
|
||||
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
@@ -934,6 +976,8 @@
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.11", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow=="],
|
||||
|
||||
"svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
|
||||
@@ -944,6 +988,8 @@
|
||||
|
||||
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
|
||||
|
||||
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
|
||||
@@ -1006,6 +1052,8 @@
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
|
||||
|
||||
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
|
||||
|
||||
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
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,6 +38,8 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"gsap": "^3.13.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lucide-react": "^0.540.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
|
||||
627
src/components/ai/AIChatModal.jsx
Normal file
627
src/components/ai/AIChatModal.jsx
Normal file
@@ -0,0 +1,627 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { supabase } from "@/auth/supabase";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
/* ---------- UI Mocks (sin cambios) ---------- */
|
||||
const Paperclip = (props) => (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Dialog = ({ open, onOpenChange, children }) =>
|
||||
open ? <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={onOpenChange}>{children}</div> : null;
|
||||
const DialogContent = ({ className, children }) =>
|
||||
<div className={`bg-white rounded-xl shadow-2xl transform transition-all max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}>{children}</div>;
|
||||
const DialogHeader = ({ children }) => <div className="pb-4 border-b border-gray-200">{children}</div>;
|
||||
const DialogTitle = ({ className, children }) => <h2 className={`text-xl font-bold ${className}`}>{children}</h2>;
|
||||
const Button = ({ onClick, disabled, className, variant, children }) => (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
${variant === "outline"
|
||||
? "bg-white border border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700"}
|
||||
${disabled ? "opacity-50 cursor-not-allowed" : ""} ${className}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
const Card = ({ className, children }) => <div className={`bg-white rounded-2xl shadow-md ${className}`}>{children}</div>;
|
||||
const CardContent = ({ className, children }) => <div className={`p-4 ${className}`}>{children}</div>;
|
||||
const ScrollArea = ({ className, children }) => <div className={`overflow-y-auto ${className}`}>{children}</div>;
|
||||
|
||||
/* ------------- COMPONENT ------------- */
|
||||
export default function AIChatModal({ open, onClose, context, onAccept, plan_format }) {
|
||||
|
||||
const [vectorStores, setVectorStores] = useState([]);
|
||||
const [vectorFiles, setVectorFiles] = useState([]);
|
||||
const [selectedVector, setSelectedVector] = useState(null);
|
||||
const [selectedFiles, setSelectedFiles] = useState([]);
|
||||
|
||||
const [attachedFiles, setAttachedFiles] = useState([]);
|
||||
const [attachedPreviews, setAttachedPreviews] = useState([]);
|
||||
|
||||
// chat
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
// loading states
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||
const [loadingVectors, setLoadingVectors] = useState(false);
|
||||
|
||||
// conversation control
|
||||
const [conversationId, setConversationId] = useState(null);
|
||||
const [creatingConversation, setCreatingConversation] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
useEffect(scrollToBottom, [messages]);
|
||||
|
||||
const normalizeInvokeResponse = (resp) => {
|
||||
if (!resp) return null;
|
||||
const raw = resp.data;
|
||||
if (typeof raw === "string") {
|
||||
try { return JSON.parse(raw); } catch (e) { console.warn("❗ No se pudo parsear resp.data:", raw); return null; }
|
||||
}
|
||||
if (typeof raw === "object" && raw !== null) return raw;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Al abrir: reset o crear conversación
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
if (conversationId) {
|
||||
deleteConversation(conversationId).catch((e) => console.error(e));
|
||||
}
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
setSelectedFiles([]);
|
||||
setAttachedFiles([]);
|
||||
setAttachedPreviews([]);
|
||||
setConversationId(null);
|
||||
setSelectedVector(null);
|
||||
setVectorFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context) {
|
||||
setMessages([
|
||||
{
|
||||
role: "system",
|
||||
//content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}`
|
||||
content: `Contexto académico:\n${context.section || "—"}\n\nTexto original:\n${context.originalText || "—"}`
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await createConversation();
|
||||
fetchVectorStores();
|
||||
})();
|
||||
|
||||
}, [open]);
|
||||
|
||||
// ---------- CREATE CONVERSATION ----------
|
||||
const createConversation = async () => {
|
||||
try {
|
||||
setCreatingConversation(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
const resp = await supabase.functions.invoke("modal-conversation", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: { action: "start", role: "system", content: context?.cont_conversation ?? "" }
|
||||
});
|
||||
|
||||
let parsed = null;
|
||||
if (typeof resp?.data === "string") {
|
||||
try { parsed = JSON.parse(resp.data); } catch (e) { parsed = null; }
|
||||
} else if (typeof resp?.data === "object" && resp.data !== null) parsed = resp.data;
|
||||
else parsed = resp;
|
||||
|
||||
const convId =
|
||||
parsed?.conversationId ||
|
||||
parsed?.data?.conversationId ||
|
||||
parsed?.data?.id ||
|
||||
parsed?.id ||
|
||||
parsed?.conversation_id ||
|
||||
parsed?.data?.conversation_id;
|
||||
|
||||
if (!convId) { setCreatingConversation(false); return; }
|
||||
setConversationId(convId);
|
||||
} catch (err) {
|
||||
console.error("Error creando conversación:", err);
|
||||
} finally {
|
||||
setCreatingConversation(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- DELETE CONVERSATION ----------
|
||||
const deleteConversation = async (convIdParam) => {
|
||||
try {
|
||||
const convIdToUse = convIdParam ?? conversationId;
|
||||
if (!convIdToUse) return;
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
await supabase.functions.invoke("modal-conversation", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: { action: "end", conversationId: convIdToUse }
|
||||
});
|
||||
|
||||
setConversationId(null);
|
||||
} catch (err) {
|
||||
console.error("Error eliminando conversación:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- CONVERT FILE TO BASE64 ----------
|
||||
const fileToBase64 = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = (e) => reject(e);
|
||||
reader.onload = () => resolve(reader.result.split(",")[1]);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// ---------- HANDLE CONVERSATION (envío) ----------
|
||||
const handleConversation = async ({ text }) => {
|
||||
let contextText = "";
|
||||
if (context?.originalText) contextText += `CONTEXTO DEL CAMPO:\n${context.originalText}\n`;
|
||||
|
||||
if (!conversationId) {
|
||||
console.warn("No hay conversación activa todavía. conversationId:", conversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
// archivos adjuntos (locales) -> base64
|
||||
let filesInput = [];
|
||||
if (attachedFiles.length > 0) {
|
||||
for (const file of attachedFiles) {
|
||||
const base64 = await fileToBase64(file);
|
||||
filesInput.push({
|
||||
type: "input_file",
|
||||
filename: file.name,
|
||||
file_data: `data:${file.type};base64,${base64}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// archivos seleccionados del vector (por id)
|
||||
if (selectedFiles.length > 0) {
|
||||
const filesFromVectors = selectedFiles.map(f => ({
|
||||
type: "input_file",
|
||||
file_id: f.id
|
||||
}));
|
||||
filesInput = [...filesInput, ...filesFromVectors];
|
||||
}
|
||||
|
||||
const promptFinal = `${contextText}\nPREGUNTA DEL USUARIO:\n${text}`;
|
||||
const payload = {
|
||||
action: "message",
|
||||
format: plan_format,
|
||||
conversationId,
|
||||
vectorStoreId: selectedVector ?? null,
|
||||
fileIds: selectedFiles.length ? selectedFiles.map(f => f.id) : [],
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: promptFinal },
|
||||
...filesInput
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const { data: invokeData, error } = await supabase.functions.invoke(
|
||||
"modal-conversation",
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: payload
|
||||
}
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
const parsed = normalizeInvokeResponse({ data: invokeData });
|
||||
|
||||
// Extraer texto del assistant (robusto)
|
||||
let assistantText = null;
|
||||
if (parsed?.data?.output_text) assistantText = parsed.data.output_text;
|
||||
if (!assistantText && Array.isArray(parsed?.data?.output)) {
|
||||
const msgBlock = parsed.data.output.find(o => o.type === "message");
|
||||
if (msgBlock?.content?.[0]?.text) assistantText = msgBlock.content[0].text;
|
||||
}
|
||||
assistantText = assistantText || "Sin respuesta del modelo.";
|
||||
|
||||
setMessages(prev => [...prev, { role: "assistant", content: cleanAssistantResponse(assistantText) }]);
|
||||
|
||||
// limpiar attachments locales (pero mantener seleccionados del vector si quieres — aquí los limpiamos)
|
||||
setAttachedFiles([]);
|
||||
setAttachedPreviews([]);
|
||||
// si quieres mantener los selectedFiles tras el envío, comenta la siguiente línea:
|
||||
setSelectedFiles([]);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error en handleConversation:", err);
|
||||
setMessages(prev => [...prev, { role: "assistant", content: "Ocurrió un error al procesar tu mensaje." }]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- VECTORES ----------
|
||||
const fetchVectorStores = async () => {
|
||||
try {
|
||||
setLoadingVectors(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: { module: "vectorStores", action: "list" }
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
setVectorStores(Array.isArray(data) ? data : (data?.data ?? []));
|
||||
} catch (err) {
|
||||
console.error("Error loading vector stores:", err);
|
||||
setVectorStores([]);
|
||||
} finally {
|
||||
setLoadingVectors(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) fetchVectorStores();
|
||||
}, [open]);
|
||||
|
||||
const loadFilesForVector = async (vectorStoreId) => {
|
||||
try {
|
||||
setLoadingFiles(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const token = session?.access_token;
|
||||
|
||||
const { data, error } = await supabase.functions.invoke("files-and-vector-stores-api", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: { module: "vectorStoreFiles", action: "list", params: { vector_store_id: vectorStoreId } }
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
setVectorFiles(Array.isArray(data) ? data : (data?.data ?? []));
|
||||
} catch (err) {
|
||||
console.error("Error loading vector files:", err);
|
||||
setVectorFiles([]);
|
||||
} finally {
|
||||
setLoadingFiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- UI helpers ----------
|
||||
const handleAttach = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (!files.length) return;
|
||||
setAttachedFiles(prev => [...prev, ...files]);
|
||||
setAttachedPreviews(prev => [...prev, ...files.map(f => f.name)]);
|
||||
};
|
||||
|
||||
// Al hacer click en un vector: expandir (solo uno a la vez) y cargar sus archivos
|
||||
const handleVectorClick = async (vector) => {
|
||||
if (selectedVector === vector.id) {
|
||||
// colapsar
|
||||
setSelectedVector(null);
|
||||
setVectorFiles([]);
|
||||
setSelectedFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedVector(vector.id);
|
||||
setSelectedFiles([]);
|
||||
await loadFilesForVector(vector.id);
|
||||
};
|
||||
|
||||
// Toggle selección de archivo (checkbox)
|
||||
const toggleFileSelection = (file) => {
|
||||
if (selectedFiles.some(f => f.id === file.id)) {
|
||||
setSelectedFiles(prev => prev.filter(f => f.id !== file.id));
|
||||
} else {
|
||||
setSelectedFiles(prev => [...prev, file]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSelectedFile = (fileId) => {
|
||||
setSelectedFiles(prev => prev.filter(f => f.id !== fileId));
|
||||
};
|
||||
|
||||
// ---------- Send flow ----------
|
||||
const handleSend = async () => {
|
||||
// no permitir enviar si no hay nada
|
||||
if (!input.trim() && attachedFiles.length === 0 && selectedFiles.length === 0) return;
|
||||
|
||||
if (creatingConversation) {
|
||||
// no bloqueo visible aquí por diseño; simplemente ignoramos el envío si aún creando
|
||||
return;
|
||||
}
|
||||
|
||||
if (!conversationId) {
|
||||
await createConversation();
|
||||
if (!conversationId) {
|
||||
setMessages(prev => [...prev, { role: "assistant", content: "No se pudo crear la conversación. Intenta de nuevo." }]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const userText = input.trim() || (selectedFiles.length ? `Consultar ${selectedFiles.length} archivo(s) del repositorio` : "");
|
||||
setMessages(prev => [...prev, { role: "user", content: userText }]);
|
||||
setInput("");
|
||||
|
||||
await handleConversation({ text: userText });
|
||||
};
|
||||
|
||||
function cleanAIResponse(text) {
|
||||
if (!text) return text;
|
||||
|
||||
let cleaned = text;
|
||||
|
||||
// -------------------------
|
||||
// 1. Eliminar emojis
|
||||
// -------------------------
|
||||
cleaned = cleaned.replace(/[\p{Emoji}\uFE0F]/gu, "");
|
||||
|
||||
// -------------------------
|
||||
// 2. Eliminar separadores tipo ---
|
||||
// -------------------------
|
||||
cleaned = cleaned.replace(/^---+$/gm, "");
|
||||
|
||||
// -------------------------
|
||||
// 3. Eliminar saludos y frases meta
|
||||
// -------------------------
|
||||
const metaPatterns = [
|
||||
/^hola[!¡., ]*/i,
|
||||
/^buen(os|as) (días|tardes|noches)[!¡., ]*/i,
|
||||
/estoy aquí para ayudarte[.! ]*/gi,
|
||||
/aquí tienes[,:]*/gi,
|
||||
/claro[,:]*/gi,
|
||||
/como pediste[,:]*/gi,
|
||||
/como solicitaste[,:]*/gi,
|
||||
/el texto íntegro que compartiste.*$/gi,
|
||||
/te lo dejo a continuación.*$/gi,
|
||||
/¿te gustaría.*$/gi,
|
||||
/¿en qué más puedo.*$/gi,
|
||||
/si necesitas algo más.*$/gi,
|
||||
/con gusto.*$/gi,
|
||||
];
|
||||
|
||||
metaPatterns.forEach(p => {
|
||||
cleaned = cleaned.replace(p, "").trim();
|
||||
});
|
||||
|
||||
// -------------------------
|
||||
// 4. Extraer solo contenido útil
|
||||
// -------------------------
|
||||
const startMarker = "CONTEXTO DEL CAMPO";
|
||||
const startIndex = cleaned.indexOf(startMarker);
|
||||
|
||||
if (startIndex !== -1) {
|
||||
cleaned = cleaned.substring(startIndex).trim();
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// 5. Eliminar líneas vacías múltiples
|
||||
// -------------------------
|
||||
cleaned = cleaned.replace(/\n{2,}/g, "\n\n");
|
||||
|
||||
// -------------------------
|
||||
// 6. Quitar numeraciones de cortesía (opcional)
|
||||
// Ejemplo: “1. ” al inicio de líneas
|
||||
// -------------------------
|
||||
cleaned = cleaned.replace(/^\s*\d+\.\s+/gm, "");
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
|
||||
const handleApply = () => {
|
||||
const last = [...messages].reverse().find(m => m.role === "assistant");
|
||||
|
||||
if (last && onAccept) {
|
||||
const cleaned = cleanAIResponse(last.content);
|
||||
onAccept(cleaned);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const cleanAssistantResponse = (text) => {
|
||||
if (!text) return text;
|
||||
const patterns = [/^claro[, ]*/i, /^por supuesto[, ]*/i, /^aquí tienes[, ]*/i, /^con gusto[, ]*/i, /^hola[, ]*/i, /^perfecto[, ]*/i, /^entendido[, ]*/i, /^muy bien[, ]*/i, /^ok[, ]*/i];
|
||||
let cleaned = text.trim();
|
||||
for (const p of patterns) cleaned = cleaned.replace(p, "").trim();
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose} >
|
||||
<DialogContent className="max-w-6xl w-[95vw] h-[85vh] p-6 flex flex-col relative"
|
||||
|
||||
>
|
||||
<button onClick={onClose} className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 transition z-50">✕</button>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Asistente Inteligente</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 pt-4 min-h-0">
|
||||
<div className="flex gap-6 h-full min-h-0">
|
||||
{/* Left: vectors */}
|
||||
<Card className="w-1/3 min-w-[250px] max-w-sm flex flex-col bg-muted/20 border border-gray-200 rounded-2xl">
|
||||
<CardContent className="flex flex-col flex-1 p-4">
|
||||
<h3 className="font-semibold text-sm mb-3">Repositorio de archivos</h3>
|
||||
<ScrollArea className="flex-1">
|
||||
{loadingVectors ? (
|
||||
<p className="text-gray-500 text-sm text-center mt-10">Cargando Repositorio de archivos...</p>
|
||||
) : vectorStores.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm text-center mt-10">No hay Repositorio de archivos.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{vectorStores.map((vector) => (
|
||||
<div key={vector.id}>
|
||||
{/* VECTOR */}
|
||||
<div
|
||||
onClick={() => handleVectorClick(vector)}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition flex items-center justify-between
|
||||
${selectedVector === vector.id ? "bg-blue-50 border-blue-400 shadow" : "bg-white border-gray-300"}`}
|
||||
>
|
||||
<div className="truncate">
|
||||
<strong className="block truncate">{vector.name || vector.id}</strong>
|
||||
<p className="text-xs text-gray-400 truncate">{vector.description || ""}</p>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{selectedVector === vector.id ? "▼" : "▶"}</div>
|
||||
</div>
|
||||
|
||||
{/* ARCHIVOS cuando está expandido */}
|
||||
{selectedVector === vector.id && (
|
||||
<div className="ml-4 mt-2 mb-2 space-y-2">
|
||||
{loadingFiles ? (
|
||||
<p className="text-gray-400 text-sm">Cargando archivos...</p>
|
||||
) : vectorFiles.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">No hay archivos en este repositorio</p>
|
||||
) : (
|
||||
vectorFiles.map((file) => (
|
||||
<label key={file.id} className="flex items-center gap-2 p-2 rounded-md hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFiles.some(f => f.id === file.id)}
|
||||
onChange={() => toggleFileSelection(file)}
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{file.filename ?? file.name ?? file.id}</div>
|
||||
<div className="text-xs text-gray-400">{file.id}</div>
|
||||
</div>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</ScrollArea>
|
||||
|
||||
{/* Resumen de archivos seleccionados (de vectores) */}
|
||||
<div className="mt-4">
|
||||
<h4 className="font-semibold text-sm mb-2">Archivos seleccionados</h4>
|
||||
{selectedFiles.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No has seleccionado archivos del repositorio</p>
|
||||
) : (
|
||||
<ul className="space-y-2 max-h-40 overflow-auto">
|
||||
{selectedFiles.map((f) => (
|
||||
<li key={f.id} className="flex items-center justify-between p-2 rounded-md border bg-white">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{f.filename ?? f.name ?? f.id}</div>
|
||||
<div className="text-xs text-gray-400 truncate">{f.id}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">{/* optionally show vector id */}</span>
|
||||
<button onClick={() => removeSelectedFile(f.id)} className="text-sm text-red-500 hover:underline">Quitar</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* <div className="mt-4 flex-shrink-0">
|
||||
<Button variant="outline" className="w-full" onClick={() => alert("Funcionalidad Subir a vector store no implementada aquí")}>Subir archivo (vector)</Button>
|
||||
</div> */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Right: Chat */}
|
||||
<Card className="flex-1 flex flex-col min-w-[350px] bg-background border border-gray-200 rounded-2xl">
|
||||
<CardContent className="flex flex-col flex-1 p-4 min-h-0">
|
||||
<h3 className="font-semibold text-sm mb-3 flex-shrink-0">Chat con IA</h3>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* CONTENEDOR SCROLL DE LOS MENSAJES */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 border border-gray-200 rounded-lg p-3 space-y-3 bg-gray-50 break-words whitespace-pre-wrap">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm text-center mt-10">Inicia una conversación...</p>
|
||||
) : (
|
||||
messages.map((m, i) => (
|
||||
<div key={i} className={`break-words whitespace-pre-wrap p-3 rounded-xl shadow-sm max-w-[85%] ${m.role === "user" ? "bg-blue-50 text-blue-800 ml-auto" : m.role === "assistant" ? "bg-white text-gray-800 mr-auto border border-gray-200" : "bg-gray-100 text-gray-700 mr-auto"}`}>
|
||||
<strong className="font-bold">{m.role === "user" ? "Tú:" : m.role === "assistant" ? "IA:" : "Sistema:"}</strong>{" "}
|
||||
<ReactMarkdown>{m.content}</ReactMarkdown>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2 p-3 bg-white border border-gray-200 rounded-xl mr-auto max-w-fit shadow-sm flex-shrink-0">
|
||||
<svg className="animate-spin h-4 w-4 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">La IA está respondiendo...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{attachedPreviews.length > 0 && (
|
||||
<ul className="text-xs text-gray-600 mt-2">
|
||||
{attachedPreviews.map((name, i) => (
|
||||
<li key={i}>📄 {name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-4 items-end flex-shrink-0">
|
||||
<label className="cursor-pointer text-gray-600 hover:text-blue-600 self-center">
|
||||
<Paperclip className="w-5 h-5" />
|
||||
<input type="file" accept=".pdf,.txt,.doc,.docx" multiple className="hidden" onChange={handleAttach} />
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Escribe tu pregunta..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border border-gray-300 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-32 overflow-y-auto bg-white shadow-inner"
|
||||
style={{ minHeight: "38px" }}
|
||||
/>
|
||||
|
||||
<Button onClick={handleSend} disabled={loading || creatingConversation || (!input.trim() && attachedFiles.length === 0 && selectedFiles.length === 0)} className="shadow-md">
|
||||
{creatingConversation ? "Preparando..." : loading ? "Enviando..." : "Enviar"}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleApply} disabled={!messages.some((m) => m.role === "assistant")} className="shadow-md">
|
||||
Aplicar mejora
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import { supabase,useSupabaseAuth } from "@/auth/supabase"
|
||||
import { Button } from "../ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -35,11 +35,13 @@ export function EditBibliografiaButton({
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [text, setText] = useState("")
|
||||
const auth = useSupabaseAuth()
|
||||
|
||||
const initialTextRef = useRef("")
|
||||
const lines = useMemo(() => parseLines(text), [text])
|
||||
const dirty = useMemo(() => initialTextRef.current !== text, [text])
|
||||
|
||||
// 🔹 Abre el editor y carga los valores actuales
|
||||
function openEditor() {
|
||||
const start = (value ?? []).join("\n")
|
||||
setText(start)
|
||||
@@ -47,52 +49,110 @@ export function EditBibliografiaButton({
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
// ✅ Función para generar diferencias tipo JSON Patch
|
||||
function generateDiff(oldRefs: string[], newRefs: string[]) {
|
||||
const changes: any[] = []
|
||||
|
||||
// Si son distintos en contenido o longitud
|
||||
if (JSON.stringify(oldRefs) !== JSON.stringify(newRefs)) {
|
||||
changes.push({
|
||||
op: "replace",
|
||||
path: "/bibliografia",
|
||||
from: oldRefs,
|
||||
value: newRefs,
|
||||
})
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
try {
|
||||
setSaving(true)
|
||||
const refs = parseLines(text)
|
||||
// 1️⃣ Obtener bibliografía anterior
|
||||
const { data: oldData, error: oldError } = await supabase
|
||||
.from("asignaturas")
|
||||
.select("bibliografia")
|
||||
.eq("id", asignaturaId)
|
||||
.maybeSingle()
|
||||
|
||||
if (oldError) throw oldError
|
||||
|
||||
const oldRefs = oldData?.bibliografia ?? []
|
||||
const newRefs = parseLines(text)
|
||||
|
||||
// 2️⃣ Generar diferencias
|
||||
const diff = generateDiff(oldRefs, newRefs)
|
||||
|
||||
// 3️⃣ Guardar respaldo si hay cambios
|
||||
if (diff.length > 0) {
|
||||
const { error: backupError } = await supabase
|
||||
.from("historico_cambios_asignaturas") // misma tabla de respaldo
|
||||
.insert({
|
||||
id_asignatura: asignaturaId,
|
||||
json_cambios: diff, // jsonb
|
||||
user_id: auth.user?.id,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (backupError) throw backupError
|
||||
}
|
||||
|
||||
// 4️⃣ Actualizar bibliografía en asignaturas
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.update({ bibliografia: refs })
|
||||
.update({ bibliografia: newRefs })
|
||||
.eq("id", asignaturaId)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
|
||||
onSaved((data as any)?.bibliografia ?? refs)
|
||||
initialTextRef.current = refs.join("\n")
|
||||
toast.success(`${refs.length} referencia(s) guardada(s).`)
|
||||
// 5️⃣ Refrescar estado local
|
||||
onSaved((data as any)?.bibliografia ?? newRefs)
|
||||
initialTextRef.current = newRefs.join("\n")
|
||||
toast.success(`${newRefs.length} referencia(s) guardada(s).`)
|
||||
setOpen(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "No se pudo guardar")
|
||||
} catch (err: any) {
|
||||
toast.error(err.message ?? "No se pudo guardar la bibliografía")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Acciones
|
||||
// 🔧 Acciones extra
|
||||
function actionTrim() {
|
||||
const next = parseLines(text).map((s) => s.replace(/\s+/g, " ").trim())
|
||||
setText(next.join("\n"))
|
||||
}
|
||||
|
||||
function actionDedupe() {
|
||||
const seen = new Set<string>()
|
||||
const next: string[] = []
|
||||
for (const l of parseLines(text)) {
|
||||
const k = l.toLowerCase()
|
||||
if (!seen.has(k)) { seen.add(k); next.push(l) }
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k)
|
||||
next.push(l)
|
||||
}
|
||||
}
|
||||
setText(next.join("\n"))
|
||||
}
|
||||
|
||||
function actionSort() {
|
||||
const next = [...parseLines(text)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||
const next = [...parseLines(text)].sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { sensitivity: "base" }),
|
||||
)
|
||||
setText(next.join("\n"))
|
||||
}
|
||||
|
||||
async function actionImportClipboard() {
|
||||
try {
|
||||
const clip = await navigator.clipboard.readText()
|
||||
if (!clip) { toast("Portapapeles vacío"); return }
|
||||
if (!clip) {
|
||||
toast("Portapapeles vacío")
|
||||
return
|
||||
}
|
||||
const next = [...parseLines(text), ...parseLines(clip)]
|
||||
setText(next.join("\n"))
|
||||
toast.success("Texto importado")
|
||||
@@ -100,6 +160,7 @@ export function EditBibliografiaButton({
|
||||
toast.error(e?.message ?? "No se pudo leer el portapapeles")
|
||||
}
|
||||
}
|
||||
|
||||
async function actionExportClipboard() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(parseLines(text).join("\n"))
|
||||
@@ -109,7 +170,7 @@ export function EditBibliografiaButton({
|
||||
}
|
||||
}
|
||||
|
||||
// Atajo guardar
|
||||
// ⌨️ Atajo Ctrl+S
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (!open) return
|
||||
@@ -120,7 +181,6 @@ export function EditBibliografiaButton({
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, saving, dirty, text])
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,7 +30,7 @@ export function useDeleteCarreraDialog(carreraId: string, onDeleted?: () => void
|
||||
|
||||
const dialog = (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent className="bg-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono" >¿Eliminar carrera?</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
92
src/components/historico/HistorialCambiosModal.tsx
Normal file
92
src/components/historico/HistorialCambiosModal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import { useSupabaseAuth } from "@/auth/supabase"
|
||||
|
||||
export function HistorialCambiosModal({
|
||||
open,
|
||||
onClose,
|
||||
planId,
|
||||
onRestore, // 🔥 recibiremos una función del padre para restaurar
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
planId: string
|
||||
onRestore: (key: string, value: string) => void
|
||||
}) {
|
||||
const auth = useSupabaseAuth()
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["historico_cambios", planId, auth.user?.id],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("historico_cambios")
|
||||
.select("id, json_cambios, user_id, created_at")
|
||||
.eq("id_plan_estudios", planId)
|
||||
.eq("user_id", auth.user?.id) // ✅ filtro por usuario actual
|
||||
.order("created_at", { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
enabled: !!auth.user?.id, // ✅ solo corre si hay usuario autenticado
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Histórico de cambios</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading && <p className="text-sm text-gray-500">Cargando historial...</p>}
|
||||
{error && <p className="text-red-500 text-sm">Error al cargar: {String(error)}</p>}
|
||||
{!isLoading && !error && (!data || data.length === 0) && (
|
||||
<p className="text-gray-500 text-sm">No hay cambios registrados.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
{data?.map((item) => {
|
||||
const diff = item.json_cambios?.[0]
|
||||
const key = diff?.path?.replace("/", "")
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg border p-3 bg-white/70 dark:bg-neutral-900/50"
|
||||
>
|
||||
<div className="flex justify-between text-xs text-neutral-500 mb-2">
|
||||
<span>Usuario: {item.user_id || "Desconocido"}</span>
|
||||
<span>{new Date(item.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-700 font-mono whitespace-pre-wrap">
|
||||
<p><strong>Campo:</strong> {key}</p>
|
||||
<p><strong>Antes:</strong> {diff?.from || "—"}</p>
|
||||
<p><strong>Después:</strong> {diff?.value || "—"}</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
onClick={() => onRestore(key, diff.from)}
|
||||
>
|
||||
Restaurar
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-right">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cerrar
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -11,9 +11,11 @@ import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||
import { Field } from "./Field"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
||||
import { asignaturaKeys } from "./planQueries"
|
||||
import { useRouter } from "@tanstack/react-router"
|
||||
|
||||
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const router = useRouter()
|
||||
const supabaseAuth = useSupabaseAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -45,7 +47,13 @@ export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdd
|
||||
objetivos: toNull(f.objetivos),
|
||||
contenidos: [], bibliografia: [], criterios_evaluacion: null,
|
||||
}
|
||||
const { error } = await supabase.from("asignaturas").insert([payload])
|
||||
const { error,data } = await supabase.from("asignaturas").insert([payload]).select().single()
|
||||
console.log(data);
|
||||
router.invalidate()
|
||||
router.navigate({
|
||||
to: "/asignatura/$asignaturaId",
|
||||
params: { asignaturaId: data.id },
|
||||
})
|
||||
setSaving(false)
|
||||
if (error) { alert(error.message); return }
|
||||
setOpen(false)
|
||||
@@ -64,8 +72,17 @@ export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdd
|
||||
body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true, uuid: supabaseAuth.user?.id }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const data = await res.json()
|
||||
console.log("Asignatura generada:", data)
|
||||
const asignaturaId = data.asignaturaId || data.insertResult?.id
|
||||
if (!asignaturaId) throw new Error("No se recibió el ID de la asignatura generada")
|
||||
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
|
||||
setOpen(false)
|
||||
router.invalidate()
|
||||
router.navigate({
|
||||
to: "/asignatura/$asignaturaId",
|
||||
params: { asignaturaId },
|
||||
})
|
||||
onAdded?.()
|
||||
// qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
||||
// qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
||||
|
||||
@@ -104,7 +104,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
setDbFiles((data || []).map((file: any) => ({
|
||||
id: file.documentos_id,
|
||||
titulo: file.titulo_archivo,
|
||||
s3_file_path: file.titulo_archivo,
|
||||
s3_file_path: `prueba-referencias/documento_${file.documentos_id}.pdf`,
|
||||
fecha_subida: file.fecha_subida,
|
||||
tags: file.tags || [],
|
||||
})));
|
||||
@@ -118,35 +118,35 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
|
||||
const isSelected = useCallback((path: string) => selectedFiles.includes(path), [selectedFiles]);
|
||||
|
||||
const toggleSelected = useCallback((path: string) => {
|
||||
setSelectedFiles(prev => prev.includes(path) ? prev.filter(p => p !== path) : [...prev, path]);
|
||||
const toggleSelected = useCallback((id: string) => {
|
||||
setSelectedFiles(prev => prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]);
|
||||
}, []);
|
||||
|
||||
const replaceSelection = useCallback((path: string) => {
|
||||
setSelectedFiles([path]);
|
||||
const replaceSelection = useCallback((id: string) => {
|
||||
setSelectedFiles([id]);
|
||||
}, []);
|
||||
|
||||
const rangeSelect = useCallback((start: number, end: number) => {
|
||||
const [s, e] = start < end ? [start, end] : [end, start];
|
||||
const paths = dbFiles.slice(s, e + 1).map(f => f.s3_file_path);
|
||||
setSelectedFiles(prev => Array.from(new Set([...prev, ...paths])));
|
||||
const ids = dbFiles.slice(s, e + 1).map(f => f.id);
|
||||
setSelectedFiles(prev => Array.from(new Set([...prev, ...ids])));
|
||||
}, [dbFiles]);
|
||||
|
||||
const handleCardClick = useCallback((e: React.MouseEvent, index: number, file: { s3_file_path: string }) => {
|
||||
const path = file.s3_file_path;
|
||||
const handleCardClick = useCallback((e: React.MouseEvent, index: number, file: { id: string }) => {
|
||||
const id = file.id;
|
||||
|
||||
if (e.shiftKey && lastSelectedIndex !== null) {
|
||||
rangeSelect(lastSelectedIndex, index);
|
||||
} else if (e.metaKey || e.ctrlKey) {
|
||||
toggleSelected(path);
|
||||
toggleSelected(id);
|
||||
setLastSelectedIndex(index);
|
||||
} else {
|
||||
if (isSelected(path) && selectedFiles.length === 1) {
|
||||
if (isSelected(id) && selectedFiles.length === 1) {
|
||||
// si ya es el único seleccionado, des-selecciona
|
||||
setSelectedFiles([]);
|
||||
setLastSelectedIndex(null);
|
||||
} else {
|
||||
replaceSelection(path);
|
||||
replaceSelection(id);
|
||||
setLastSelectedIndex(index);
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
||||
prompt: prompt,
|
||||
insert: true,
|
||||
files: selectedFiles,
|
||||
uuid: auth.user?.id,
|
||||
created_by: auth.user?.id,
|
||||
})
|
||||
const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id
|
||||
if (newId) {
|
||||
@@ -261,7 +261,7 @@ 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">
|
||||
{dbFiles.map((file, index) => {
|
||||
const ext = fileExt(file.titulo);
|
||||
const selected = isSelected(file.s3_file_path);
|
||||
const selected = isSelected(file.id);
|
||||
console.log(file);
|
||||
|
||||
return (
|
||||
|
||||
428
src/components/planes/DownloadPlanPDF.tsx
Normal file
428
src/components/planes/DownloadPlanPDF.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
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",
|
||||
})
|
||||
console.log(plan);
|
||||
|
||||
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["nombre"] !== null && plan["nombre"] !== undefined ? String(plan["nombre"]) : "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
|
||||
@@ -4,9 +4,13 @@ import { useSuspenseQuery, useMutation, useQueryClient, queryOptions } from "@ta
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import { supabase,useSupabaseAuth } from "@/auth/supabase"
|
||||
import { toast } from "sonner"
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { HistorialCambiosModal } from "../historico/HistorialCambiosModal"
|
||||
// @ts-ignore
|
||||
import AIChatModal from "../ai/AIChatModal"
|
||||
|
||||
|
||||
|
||||
/* =====================================================
|
||||
@@ -27,6 +31,7 @@ export type PlanTextFields = {
|
||||
indicadores_desempeno?: string | string[] | null
|
||||
pertinencia?: string | string[] | null
|
||||
prompt?: string | null
|
||||
historico?: string | null
|
||||
}
|
||||
|
||||
async function fetchPlanText(planId: string): Promise<PlanTextFields> {
|
||||
@@ -50,6 +55,8 @@ export const planTextOptions = (planId: string) =>
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
|
||||
|
||||
/* =====================================================
|
||||
Color helpers
|
||||
===================================================== */
|
||||
@@ -111,12 +118,26 @@ function SectionPanel({ title, icon: Icon, color, children, id }: { title: strin
|
||||
===================================================== */
|
||||
export function AcademicSections({ planId, color }: { planId: string; color?: string | null }) {
|
||||
const qc = useQueryClient()
|
||||
const auth = useSupabaseAuth()
|
||||
const [openHistorial, setOpenHistorial] = useState(false)
|
||||
const [openModalIa, setopenModalIa] = useState(false)
|
||||
if(!planId) return <div>Cargando…</div>
|
||||
const { data: plan } = useSuspenseQuery(planTextOptions(planId))
|
||||
|
||||
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
|
||||
const [draft, setDraft] = useState("")
|
||||
|
||||
const plan_format={
|
||||
"objetivo_general": "...",
|
||||
"sistema_evaluacion": "...",
|
||||
"perfil_ingreso": "...",
|
||||
"perfil_egreso": "...",
|
||||
"competencias_genericas": "...",
|
||||
"competencias_especificas": "...",
|
||||
"indicadores_desempeno": "...",
|
||||
"pertinencia": "..."
|
||||
}
|
||||
|
||||
// --- mutation con actualización optimista ---
|
||||
const updateField = useMutation({
|
||||
mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => {
|
||||
@@ -154,70 +175,175 @@ 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-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-hist", title: "Histórico de cambios", icon: Icons.History, key: "historico" as const, mono: false }
|
||||
],
|
||||
[]
|
||||
)
|
||||
const [iaContext, setIaContext] = useState<{ key: keyof PlanTextFields; title: string; content: string } | null>(null)
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{sections.map((s) => {
|
||||
const text = plan[s.key] ?? null
|
||||
return (
|
||||
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
|
||||
<ExpandableText text={text} mono={s.mono} />
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!text || (Array.isArray(text) && text.length === 0)}
|
||||
onClick={() => {
|
||||
const toCopy = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||
if (toCopy) navigator.clipboard.writeText(toCopy)
|
||||
}}
|
||||
>
|
||||
Copiar
|
||||
const text = plan[s.key] ?? null
|
||||
return (
|
||||
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
|
||||
{s.key === "historico" ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpenHistorial(true)}>
|
||||
Ver historial
|
||||
</Button>
|
||||
{s.key !== "prompt" &&
|
||||
(<Button
|
||||
variant="ghost"
|
||||
<Button variant="outline" size="sm" onClick={() => setopenModalIa(true)}>
|
||||
Promt
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ExpandableText text={text} mono={s.mono} />
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!text || (Array.isArray(text) && text.length === 0)}
|
||||
onClick={() => {
|
||||
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||
setEditing({ key: s.key, title: s.title })
|
||||
setDraft(current)
|
||||
const toCopy = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||
if (toCopy) navigator.clipboard.writeText(toCopy)
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Button>)}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
)
|
||||
})}
|
||||
Copiar
|
||||
</Button>
|
||||
{s.key !== "prompt" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||
setEditing({ key: s.key, title: s.title })
|
||||
setDraft(current)
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SectionPanel>
|
||||
)
|
||||
})}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Diálogo de edición */}
|
||||
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono" >{editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`} placeholder="Escribe aquí…" />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!editing) return
|
||||
updateField.mutate({ key: editing.key, value: draft })
|
||||
setEditing(null)
|
||||
}}
|
||||
disabled={updateField.isPending}
|
||||
>
|
||||
{updateField.isPending ? "Guardando…" : "Guardar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Diálogo de edición */}
|
||||
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono">
|
||||
{editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`}
|
||||
placeholder="Escribe aquí…"
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!editing) return
|
||||
|
||||
// 1️⃣ Obtener el valor anterior (por ejemplo, desde 'plan' o 'section')
|
||||
const oldValue = (plan as any)[editing.key]
|
||||
|
||||
// 2️⃣ Crear un diff tipo JSON Patch
|
||||
const diff = [{
|
||||
op: "replace",
|
||||
path: `/${editing.key}`,
|
||||
from: oldValue,
|
||||
value: draft
|
||||
}]
|
||||
|
||||
// 3️⃣ Guardar respaldo antes de actualizar
|
||||
const { error: backupError } = await supabase.from("historico_cambios").insert({
|
||||
id_plan_estudios: planId, // asegúrate de tener `plan.id` disponible en este contexto
|
||||
json_cambios: diff,
|
||||
user_id:auth.user?.id,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
|
||||
if (backupError) {
|
||||
console.error("Error al guardar respaldo:", backupError)
|
||||
alert("No se pudo guardar el respaldo de los cambios")
|
||||
return
|
||||
}
|
||||
|
||||
// 4️⃣ Ejecutar la mutación original
|
||||
updateField.mutate({ key: editing.key, value: draft })
|
||||
|
||||
// 5️⃣ Cerrar el diálogo
|
||||
setEditing(null)
|
||||
}}
|
||||
disabled={updateField.isPending}
|
||||
>
|
||||
{updateField.isPending ? "Guardando…" : "Guardar"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (!editing) return
|
||||
const current = draft
|
||||
setIaContext({
|
||||
key: editing.key,
|
||||
title: editing.title,
|
||||
content: current,
|
||||
})
|
||||
setopenModalIa(true)
|
||||
setEditing(null) // 🔹 Cierra el modal de edición
|
||||
}}
|
||||
>
|
||||
Mejorar con IA
|
||||
</Button>
|
||||
|
||||
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<HistorialCambiosModal
|
||||
open={openHistorial}
|
||||
onClose={() => setOpenHistorial(false)}
|
||||
planId={planId}
|
||||
onRestore={async (key, value) => {
|
||||
updateField.mutate({ key, value })
|
||||
}}
|
||||
/>
|
||||
|
||||
<AIChatModal
|
||||
//plan_format={plan_format}
|
||||
open={openModalIa}
|
||||
onClose={() => setopenModalIa(false)}
|
||||
context={{
|
||||
section: null,//,iaContext?.title,
|
||||
fieldKey: null,//iaContext?.key,
|
||||
originalText: iaContext?.content,
|
||||
cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId} que se encuentra en la tabla plan_estudios con el mcp para realizar los cambios que se te soliciten Responde únicamente con la información solicitada.
|
||||
No uses frases como “claro”, “por supuesto”, “aquí tienes”, “con gusto”, “hola”, “perfecto”.
|
||||
No uses introducciones, despedidas ni texto de relleno.
|
||||
Entrega solo el contenido útil.`,
|
||||
}}
|
||||
onAccept={(newText: string) => {
|
||||
if (iaContext) {
|
||||
updateField.mutate({ key: iaContext.key, value: newText })
|
||||
setIaContext(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
200
src/components/ui/prompt-input.tsx
Normal file
200
src/components/ui/prompt-input.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
type PromptInputContextType = {
|
||||
isLoading: boolean
|
||||
value: string
|
||||
setValue: (value: string) => void
|
||||
maxHeight: number | string
|
||||
onSubmit?: () => void
|
||||
disabled?: boolean
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
}
|
||||
|
||||
const PromptInputContext = createContext<PromptInputContextType>({
|
||||
isLoading: false,
|
||||
value: "",
|
||||
setValue: () => {},
|
||||
maxHeight: 240,
|
||||
onSubmit: undefined,
|
||||
disabled: false,
|
||||
textareaRef: React.createRef<HTMLTextAreaElement>(),
|
||||
})
|
||||
|
||||
function usePromptInput() {
|
||||
const context = useContext(PromptInputContext)
|
||||
if (!context) {
|
||||
throw new Error("usePromptInput must be used within a PromptInput")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
type PromptInputProps = {
|
||||
isLoading?: boolean
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
maxHeight?: number | string
|
||||
onSubmit?: () => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function PromptInput({
|
||||
className,
|
||||
isLoading = false,
|
||||
maxHeight = 240,
|
||||
value,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
children,
|
||||
}: PromptInputProps) {
|
||||
const [internalValue, setInternalValue] = useState(value || "")
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
setInternalValue(newValue)
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<PromptInputContext.Provider
|
||||
value={{
|
||||
isLoading,
|
||||
value: value ?? internalValue,
|
||||
setValue: onValueChange ?? handleChange,
|
||||
maxHeight,
|
||||
onSubmit,
|
||||
textareaRef,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-input bg-background cursor-text rounded-3xl border p-2 shadow-xs",
|
||||
className
|
||||
)}
|
||||
onClick={() => textareaRef.current?.focus()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</PromptInputContext.Provider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export type PromptInputTextareaProps = {
|
||||
disableAutosize?: boolean
|
||||
} & React.ComponentProps<typeof Textarea>
|
||||
|
||||
function PromptInputTextarea({
|
||||
className,
|
||||
onKeyDown,
|
||||
disableAutosize = false,
|
||||
...props
|
||||
}: PromptInputTextareaProps) {
|
||||
const { value, setValue, maxHeight, onSubmit, disabled, textareaRef } =
|
||||
usePromptInput()
|
||||
|
||||
useEffect(() => {
|
||||
if (disableAutosize) return
|
||||
|
||||
if (!textareaRef.current) return
|
||||
|
||||
if (textareaRef.current.scrollTop === 0) {
|
||||
textareaRef.current.style.height = "auto"
|
||||
}
|
||||
|
||||
textareaRef.current.style.height =
|
||||
typeof maxHeight === "number"
|
||||
? `${Math.min(textareaRef.current.scrollHeight, maxHeight)}px`
|
||||
: `min(${textareaRef.current.scrollHeight}px, ${maxHeight})`
|
||||
}, [value, maxHeight, disableAutosize])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSubmit?.()
|
||||
}
|
||||
onKeyDown?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"text-primary min-h-[44px] w-full resize-none border-none bg-transparent shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
className
|
||||
)}
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type PromptInputActionsProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
function PromptInputActions({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: PromptInputActionsProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PromptInputActionProps = {
|
||||
className?: string
|
||||
tooltip: React.ReactNode
|
||||
children: React.ReactNode
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
} & React.ComponentProps<typeof Tooltip>
|
||||
|
||||
function PromptInputAction({
|
||||
tooltip,
|
||||
children,
|
||||
className,
|
||||
side = "top",
|
||||
...props
|
||||
}: PromptInputActionProps) {
|
||||
const { disabled } = usePromptInput()
|
||||
|
||||
return (
|
||||
<Tooltip {...props}>
|
||||
<TooltipTrigger asChild disabled={disabled} onClick={event => event.stopPropagation()}>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} className={className}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
PromptInput,
|
||||
PromptInputTextarea,
|
||||
PromptInputActions,
|
||||
PromptInputAction,
|
||||
}
|
||||
@@ -44,13 +44,13 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
|
||||
10
src/formatos/plan.json
Normal file
10
src/formatos/plan.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"objetivo_general": "...",
|
||||
"sistema_evaluacion": "...",
|
||||
"perfil_ingreso": "...",
|
||||
"perfil_egreso": "...",
|
||||
"competencias_genericas": "...",
|
||||
"competencias_especificas": "...",
|
||||
"indicadores_desempeno": "...",
|
||||
"pertinencia": "..."
|
||||
}
|
||||
67
src/hooks/useSupabaseUpdateWithHistory.ts
Normal file
67
src/hooks/useSupabaseUpdateWithHistory.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/auth/supabase";
|
||||
|
||||
/**
|
||||
* Hook genérico para actualizar una tabla y guardar respaldo en historico_cambios
|
||||
*
|
||||
* @param tableName Nombre de la tabla a actualizar
|
||||
* @param idKey Campo que se usa para hacer eq (por defecto 'id')
|
||||
*/
|
||||
export function useSupabaseUpdateWithHistory<T extends Record<string, any>>(
|
||||
tableName: string,
|
||||
idKey: keyof T = "id" as keyof T
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Generar diferencias tipo JSON Patch
|
||||
function generateDiff(oldData: T, newData: Partial<T>) {
|
||||
const changes: any[] = [];
|
||||
for (const key of Object.keys(newData)) {
|
||||
const oldValue = (oldData as any)[key];
|
||||
const newValue = (newData as any)[key];
|
||||
if (newValue !== undefined && newValue !== oldValue) {
|
||||
changes.push({
|
||||
op: "replace",
|
||||
path: `/${key}`,
|
||||
from: oldValue,
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
oldData,
|
||||
newData,
|
||||
}: {
|
||||
oldData: T;
|
||||
newData: Partial<T>;
|
||||
}) => {
|
||||
const diff = generateDiff(oldData, newData);
|
||||
|
||||
if (diff.length > 0) {
|
||||
const { error: backupError } = await supabase
|
||||
.from("historico_cambios")
|
||||
.insert({
|
||||
id_plan_estudios: oldData.id ?? null, // ajusta si es otra tabla
|
||||
tabla_afectada: tableName,
|
||||
json_cambios: diff,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (backupError) throw backupError;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from(tableName)
|
||||
.update(newData)
|
||||
.eq(idKey as string, oldData[idKey]);
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
});
|
||||
|
||||
return { mutation };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// dummy test
|
||||
import { StrictMode } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
|
||||
import * as Icons from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { supabase } from "@/auth/supabase"
|
||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -403,29 +403,79 @@ function EditAsignaturaButton({ asignatura, onUpdate }: {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState<Partial<Asignatura>>({})
|
||||
const auth = useSupabaseAuth()
|
||||
|
||||
const openAndFill = () => { setForm(asignatura); setOpen(true) }
|
||||
|
||||
// ✅ Función que genera las diferencias entre los datos anteriores y los nuevos
|
||||
function generateDiff(oldData: Asignatura, newData: Partial<Asignatura>) {
|
||||
const changes: any[] = []
|
||||
for (const key of Object.keys(newData)) {
|
||||
const oldValue = (oldData as any)[key]
|
||||
const newValue = (newData as any)[key]
|
||||
if (newValue !== undefined && newValue !== oldValue) {
|
||||
changes.push({
|
||||
op: "replace",
|
||||
path: `/${key}`,
|
||||
from: oldValue,
|
||||
value: newValue
|
||||
})
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
const payload = {
|
||||
nombre: form.nombre ?? asignatura.nombre,
|
||||
clave: form.clave ?? asignatura.clave,
|
||||
tipo: form.tipo ?? asignatura.tipo,
|
||||
semestre: form.semestre ?? asignatura.semestre,
|
||||
creditos: form.creditos ?? asignatura.creditos,
|
||||
horas_teoricas: form.horas_teoricas ?? asignatura.horas_teoricas,
|
||||
horas_practicas: form.horas_practicas ?? asignatura.horas_practicas,
|
||||
try {
|
||||
// 1️⃣ Preparar el payload final
|
||||
const payload = {
|
||||
nombre: form.nombre ?? asignatura.nombre,
|
||||
clave: form.clave ?? asignatura.clave,
|
||||
tipo: form.tipo ?? asignatura.tipo,
|
||||
semestre: form.semestre ?? asignatura.semestre,
|
||||
creditos: form.creditos ?? asignatura.creditos,
|
||||
horas_teoricas: form.horas_teoricas ?? asignatura.horas_teoricas,
|
||||
horas_practicas: form.horas_practicas ?? asignatura.horas_practicas,
|
||||
}
|
||||
|
||||
// 2️⃣ Detectar cambios
|
||||
const diff = generateDiff(asignatura, payload)
|
||||
|
||||
// 3️⃣ Guardar respaldo si hubo cambios
|
||||
if (diff.length > 0) {
|
||||
const { error: backupError } = await supabase
|
||||
.from("historico_cambios_asignaturas") // 👈 usa el nombre real de tu tabla
|
||||
.insert({
|
||||
id_asignatura: asignatura.id,
|
||||
json_cambios: diff, // jsonb
|
||||
user_id: auth.user?.id,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
if (backupError) throw backupError
|
||||
}
|
||||
|
||||
// 4️⃣ Actualizar el registro principal
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.update(payload)
|
||||
.eq("id", asignatura.id)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// 5️⃣ Actualizar vista local
|
||||
if (data) {
|
||||
onUpdate(data as Asignatura)
|
||||
setOpen(false)
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(err.message ?? "Error al guardar")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.update(payload)
|
||||
.eq("id", asignatura.id)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
setSaving(false)
|
||||
if (!error && data) { onUpdate(data as Asignatura); setOpen(false) }
|
||||
else alert(error?.message ?? "Error al guardar")
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -650,6 +700,7 @@ export function EditContenidosButton({
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [units, setUnits] = useState<UnitDraft[]>([])
|
||||
const [initialUnits, setInitialUnits] = useState<UnitDraft[]>([])
|
||||
const auth = useSupabaseAuth() // 👈 para registrar el usuario que edita
|
||||
|
||||
// --- Normaliza entrada flexible a estructura estable
|
||||
const normalize = useCallback((v: any): UnitDraft[] => {
|
||||
@@ -685,7 +736,7 @@ export function EditContenidosButton({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// --- Construye payload consistente { "1": { titulo, subtemas:{ "1": "t1" } } }
|
||||
// --- Construye payload consistente
|
||||
const buildPayload = useCallback((us: UnitDraft[]) => {
|
||||
const out: Record<string, any> = {}
|
||||
us.forEach((u, idx) => {
|
||||
@@ -702,9 +753,9 @@ export function EditContenidosButton({
|
||||
return out
|
||||
}, [])
|
||||
|
||||
// --- Limpia UI: recorta espacios, elimina líneas vacías/duplicadas (case-insensitive)
|
||||
// --- Limpia UI
|
||||
const cleanUnits = useCallback((us: UnitDraft[]) => {
|
||||
return us.map((u, idx) => {
|
||||
return us.map((u) => {
|
||||
const seen = new Set<string>()
|
||||
const temas = u.temas
|
||||
.map((t) => t.trim())
|
||||
@@ -715,10 +766,7 @@ export function EditContenidosButton({
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
return {
|
||||
title: (u.title || "").trim(),
|
||||
temas,
|
||||
}
|
||||
return { title: (u.title || "").trim(), temas }
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -734,7 +782,7 @@ export function EditContenidosButton({
|
||||
[units, initialUnits, cleanUnits],
|
||||
)
|
||||
|
||||
// --- Atajos: Guardar con Ctrl/Cmd + Enter
|
||||
// --- Atajos: Ctrl/Cmd + Enter
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -746,7 +794,6 @@ export function EditContenidosButton({
|
||||
}
|
||||
window.addEventListener("keydown", handler)
|
||||
return () => window.removeEventListener("keydown", handler)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, units, saving])
|
||||
|
||||
// --- Acciones por unidad
|
||||
@@ -754,15 +801,17 @@ export function EditContenidosButton({
|
||||
if (!confirm("¿Eliminar esta unidad?")) return
|
||||
setUnits((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const moveUnit = (idx: number, dir: -1 | 1) => {
|
||||
setUnits((prev) => {
|
||||
const next = [...prev]
|
||||
const j = idx + dir
|
||||
if (j < 0 || j >= next.length) return prev
|
||||
;[next[idx], next[j]] = [next[j], next[idx]]
|
||||
;[next[idx], next[j]] = [next[j], next[idx]]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const duplicateUnit = (idx: number) => {
|
||||
setUnits((prev) => {
|
||||
const next = [...prev]
|
||||
@@ -774,24 +823,54 @@ export function EditContenidosButton({
|
||||
})
|
||||
}
|
||||
|
||||
// ✅ Función para guardar con respaldo histórico
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
const cleaned = cleanUnits(units)
|
||||
const contenidos = buildPayload(cleaned)
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.update({ contenidos })
|
||||
.eq("id", asignaturaId)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
setSaving(false)
|
||||
if (error) {
|
||||
alert(error.message || "No se pudo guardar")
|
||||
return
|
||||
try {
|
||||
const cleaned = cleanUnits(units)
|
||||
const contenidos = buildPayload(cleaned)
|
||||
|
||||
// 1️⃣ Generar diff entre valor anterior y nuevo
|
||||
const diff = [
|
||||
{
|
||||
op: "replace",
|
||||
path: "/contenidos",
|
||||
from: value,
|
||||
value: contenidos,
|
||||
},
|
||||
]
|
||||
|
||||
// 2️⃣ Guardar respaldo en tabla de histórico (solo si hay cambios)
|
||||
if (JSON.stringify(value) !== JSON.stringify(contenidos)) {
|
||||
const { error: backupError } = await supabase
|
||||
.from("historico_cambios_asignaturas") // 👈 nombre de tu tabla de respaldo
|
||||
.insert({
|
||||
id_asignatura: asignaturaId,
|
||||
json_cambios: diff,
|
||||
user_id: auth.user?.id,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
if (backupError) throw backupError
|
||||
}
|
||||
|
||||
// 3️⃣ Actualizar campo contenidos
|
||||
const { data, error } = await supabase
|
||||
.from("asignaturas")
|
||||
.update({ contenidos })
|
||||
.eq("id", asignaturaId)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
|
||||
setInitialUnits(cleaned)
|
||||
onSaved((data as any)?.contenidos ?? contenidos)
|
||||
setOpen(false)
|
||||
} catch (err: any) {
|
||||
alert(err.message || "Error al guardar contenidos")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
setInitialUnits(cleaned)
|
||||
onSaved((data as any)?.contenidos ?? contenidos)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
||||
import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { supabase } from '@/auth/supabase'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
|
||||
@@ -10,6 +10,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { toast } from 'sonner'
|
||||
import { InfoChip } from '@/components/planes/InfoChip'
|
||||
|
||||
/* ================== Tipos ================== */
|
||||
type FacMini = { id: string; nombre: string; color?: string | null; icon?: string | null }
|
||||
@@ -79,6 +80,7 @@ async function fetchPlanIdsByScope(search: Pick<SearchState, 'planId' | 'carrera
|
||||
async function fetchAsignaturas(search: SearchState): Promise<Asignatura[]> {
|
||||
const planIds = await fetchPlanIdsByScope(search)
|
||||
if (planIds && planIds.length === 0) return []
|
||||
console.log(AsignaturaCard);
|
||||
|
||||
let query = supabase
|
||||
.from('asignaturas')
|
||||
@@ -169,6 +171,60 @@ function RouteComponent() {
|
||||
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
|
||||
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
|
||||
|
||||
const [facultad, setFacultad] = useState("todas")
|
||||
const [carrera, setCarrera] = useState("todas")
|
||||
|
||||
/* useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
router.navigate({
|
||||
to: '/asignaturas',
|
||||
search: { ...search, q },
|
||||
replace: true,
|
||||
})
|
||||
}, 400)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [q]) */
|
||||
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const value = e.target.value
|
||||
setQ(value)
|
||||
router.navigate({
|
||||
to: '/asignaturas',
|
||||
search: {
|
||||
...search,
|
||||
q: value,
|
||||
},
|
||||
replace: true, // evita recargar o empujar al historial
|
||||
})
|
||||
}
|
||||
|
||||
// 🟣 Lista única de facultades
|
||||
const facultadesList = useMemo(() => {
|
||||
const unique = new Map<string, string>()
|
||||
planes?.forEach((p) => {
|
||||
const fac = p.carrera?.facultad
|
||||
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
|
||||
})
|
||||
return Array.from(unique.entries())
|
||||
}, [planes])
|
||||
|
||||
// 🎓 Lista de carreras según la facultad seleccionada
|
||||
const carrerasList = useMemo(() => {
|
||||
const unique = new Map<string, string>()
|
||||
planes?.forEach((p) => {
|
||||
if (
|
||||
p.carrera?.id &&
|
||||
p.carrera?.nombre &&
|
||||
(!facultad || facultad === "todas" || p.carrera?.facultad?.id === facultad)
|
||||
) {
|
||||
unique.set(p.carrera.id, p.carrera.nombre)
|
||||
}
|
||||
})
|
||||
return Array.from(unique.entries())
|
||||
}, [planes, facultad])
|
||||
|
||||
|
||||
// NEW: Clonado individual
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [cloneTarget, setCloneTarget] = useState<Asignatura | null>(null)
|
||||
@@ -217,28 +273,31 @@ function RouteComponent() {
|
||||
return { sinBibliografia, sinCriterios, sinContenidos }
|
||||
}, [asignaturas])
|
||||
|
||||
// Filtrado
|
||||
const filtered = useMemo(() => {
|
||||
const t = q.trim().toLowerCase()
|
||||
return asignaturas.filter(a => {
|
||||
const matchesQ =
|
||||
!t ||
|
||||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
|
||||
.filter(Boolean)
|
||||
.some(v => String(v).toLowerCase().includes(t))
|
||||
const t = q.trim().toLowerCase()
|
||||
return asignaturas.filter(a => {
|
||||
const matchesQ =
|
||||
!t ||
|
||||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
|
||||
.filter(Boolean)
|
||||
.some(v => String(v).toLowerCase().includes(t))
|
||||
|
||||
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
|
||||
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
|
||||
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
|
||||
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
|
||||
const carreraOK = carrera === 'todas' || a.plan?.carrera?.id === carrera
|
||||
const facultadOK = facultad === 'todas' || a.plan?.carrera?.facultad?.id === facultad
|
||||
const planOK = !search.planId || a.plan?.id === search.planId
|
||||
|
||||
const flagOK =
|
||||
!flag ||
|
||||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
|
||||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
|
||||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
|
||||
const flagOK =
|
||||
!flag ||
|
||||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
|
||||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
|
||||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
|
||||
|
||||
return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK && planOK
|
||||
})
|
||||
}, [q, sem, tipo, flag, carrera, facultad, asignaturas])
|
||||
|
||||
return matchesQ && semOK && tipoOK && flagOK
|
||||
})
|
||||
}, [q, sem, tipo, flag, asignaturas])
|
||||
|
||||
// Agrupación
|
||||
const groups = useMemo(() => {
|
||||
@@ -257,7 +316,19 @@ function RouteComponent() {
|
||||
}, [filtered, groupBy])
|
||||
|
||||
// Helpers
|
||||
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setFlag('') }
|
||||
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag('') ; setFacultad('todas')
|
||||
// Actualiza la URL limpiando todos los query params
|
||||
router.navigate({
|
||||
to: '/asignaturas',
|
||||
search: {
|
||||
q: '',
|
||||
planId: '',
|
||||
carreraId: '',
|
||||
facultadId: '',
|
||||
f: ''
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// NEW: util para clonar 1 asignatura
|
||||
async function cloneOne(src: Asignatura, overrides: {
|
||||
@@ -292,6 +363,8 @@ function RouteComponent() {
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
|
||||
|
||||
// NEW: abrir modal clon individual
|
||||
function openClone(a: Asignatura) {
|
||||
setCloneTarget(a)
|
||||
@@ -320,6 +393,8 @@ function RouteComponent() {
|
||||
setCart([])
|
||||
}
|
||||
|
||||
|
||||
|
||||
// NEW: clonado en lote
|
||||
async function cloneBulk() {
|
||||
if (!bulk.plan_destino_id) { toast.error('Selecciona un plan de destino'); return }
|
||||
@@ -394,51 +469,111 @@ function RouteComponent() {
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<div className="grid gap-4 sm:grid-cols-5 items-end">
|
||||
{/* 🔍 Búsqueda */}
|
||||
<div>
|
||||
<Label>Búsqueda</Label>
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onChange={handleChange}
|
||||
placeholder="Nombre, clave, plan, carrera, facultad…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 📘 Semestre */}
|
||||
<div>
|
||||
<Label>Semestre</Label>
|
||||
<Select value={sem} onValueChange={setSem}>
|
||||
<SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Todos" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
{semestres.map(s => <SelectItem key={s} value={s}>Semestre {s}</SelectItem>)}
|
||||
{semestres.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
Semestre {s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 🏛️ Facultad */}
|
||||
<div>
|
||||
<Label>Tipo</Label>
|
||||
<Select value={tipo} onValueChange={setTipo}>
|
||||
<SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
{tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
||||
<Label>Facultad</Label>
|
||||
<Select
|
||||
value={facultad ?? "todas"}
|
||||
onValueChange={(val) => {
|
||||
setFacultad(val)
|
||||
setCarrera("todas")
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Filtrar por facultad" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todas">Todas las facultades</SelectItem>
|
||||
{facultadesList.map(([id, nombre]) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Agrupación</Label>
|
||||
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
{/* 🎓 Carrera */}
|
||||
<div className={!facultad || facultad === "todas" ? "invisible" : ""}>
|
||||
<Label>Carrera</Label>
|
||||
<Select
|
||||
value={carrera ?? "todas"}
|
||||
onValueChange={(val) => setCarrera(val)}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Filtrar por carrera" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="semestre">Por semestre</SelectItem>
|
||||
<SelectItem value="ninguno">Sin agrupación</SelectItem>
|
||||
<SelectItem value="todas">Todas las carreras</SelectItem>
|
||||
{carrerasList.map(([id, nombre]) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 📜 Plan */}
|
||||
<div className={!carrera || carrera === "todas" ? "invisible" : ""}>
|
||||
<Label>Plan</Label>
|
||||
<Select
|
||||
value={search.planId ?? "todos"}
|
||||
onValueChange={(val) => {
|
||||
router.navigate({
|
||||
search: { ...search, planId: val === "todos" ? "" : val },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Filtrar por plan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todos los planes</SelectItem>
|
||||
{planes
|
||||
.filter((p) => p.carrera?.id === carrera)
|
||||
.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* Chips de salud */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<HealthChip
|
||||
@@ -462,7 +597,7 @@ function RouteComponent() {
|
||||
label="Sin contenidos"
|
||||
value={salud.sinContenidos}
|
||||
/>
|
||||
{(q || sem !== 'todos' || tipo !== 'todos' || flag) && (
|
||||
{/*(q || sem !== 'todos' || tipo !== 'todos' || flag || carrera=='todos')*/ true && (
|
||||
<Button variant="ghost" className="h-7 px-3" onClick={clearFilters}>
|
||||
Limpiar filtros
|
||||
</Button>
|
||||
@@ -694,9 +829,15 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
|
||||
const horasP = a.horas_practicas ?? 0
|
||||
const meta = tipoMeta(a.tipo)
|
||||
const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2
|
||||
console.log(a);
|
||||
|
||||
return (
|
||||
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm hover:shadow-md transition-all">
|
||||
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm hover:shadow-md transition-all"
|
||||
style={{
|
||||
borderColor: a.plan?.carrera?.facultad?.color ?? '#ccc',
|
||||
backgroundColor: `${a.plan?.carrera?.facultad?.color}15`, // 15 = transparencia HEX
|
||||
}}
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border bg-white/80">
|
||||
@@ -747,14 +888,17 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
|
||||
<Icons.ScrollText className="w-3.5 h-3.5" /> <strong>Plan:</strong>{a.plan.nombre}
|
||||
</span>
|
||||
{a.plan.carrera && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icons.GraduationCap className="w-3.5 h-3.5" /> <strong>Carrera:</strong> {a.plan.carrera.nombre}
|
||||
</span>
|
||||
<InfoChip
|
||||
icon={<Icons.GraduationCap className="h-3 w-3" />}
|
||||
label={a.plan.carrera.nombre}
|
||||
/>
|
||||
)}
|
||||
{a.plan.carrera?.facultad && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<FacIcon className="w-3.5 h-3.5" /> {a.plan.carrera.facultad.nombre}
|
||||
</span>
|
||||
<InfoChip
|
||||
icon={<Icons.Building2 className="h-3 w-3" />}
|
||||
label={a.plan.carrera.facultad.nombre}
|
||||
tint={a.plan.carrera.facultad.color}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -126,6 +126,17 @@ function RouteComponent() {
|
||||
const [detail, setDetail] = useState<CarreraRow | null>(null)
|
||||
const [editCarrera, setEditCarrera] = useState<CarreraRow | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<CarreraRow | null>(null)
|
||||
|
||||
// ✅ Se declara UNA SOLA VEZ
|
||||
const { setOpen: setDeleteOpen, dialog: deleteDialog } = useDeleteCarreraDialog(
|
||||
deleteTarget?.id ?? "",
|
||||
async () => {
|
||||
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
|
||||
router.invalidate()
|
||||
// setDeleteTarget(null)
|
||||
}
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = q.trim().toLowerCase()
|
||||
@@ -198,10 +209,7 @@ function RouteComponent() {
|
||||
const border = tint(fac?.color, 0.28)
|
||||
const chip = tint(fac?.color, 0.1)
|
||||
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
|
||||
const { setOpen: setDeleteOpen, dialog: deleteDialog } = useDeleteCarreraDialog(c.id, async () => {
|
||||
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
|
||||
router.invalidate()
|
||||
})
|
||||
|
||||
return (
|
||||
<ContextMenu key={c.id}>
|
||||
<ContextMenuTrigger onClick={(e) => openContextMenu(e)}>
|
||||
@@ -233,11 +241,14 @@ function RouteComponent() {
|
||||
<ContextMenuItem onClick={() => setEditCarrera(c)}>
|
||||
<Icons.Pencil className="w-4 h-4 mr-2" /> Editar
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => setDeleteOpen(true)}>
|
||||
<ContextMenuItem onClick={() => {
|
||||
setDeleteTarget(c)
|
||||
setDeleteOpen(true)
|
||||
}}>
|
||||
<Icons.Trash className="w-4 h-4 mr-2" /> Eliminar
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
{deleteDialog}
|
||||
|
||||
</ContextMenu>
|
||||
)
|
||||
})}
|
||||
@@ -247,6 +258,8 @@ function RouteComponent() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{deleteDialog}
|
||||
|
||||
{/* Crear / Editar */}
|
||||
<CarreraFormDialog
|
||||
open={createOpen}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { AuroraButton } from "@/components/effect/aurora-button"
|
||||
import { DeletePlanButton } from "@/components/planes/DeletePlan"
|
||||
import { AddAsignaturaButton } from "@/components/planes/AddAsignaturaButton"
|
||||
import { DescargarPdfButton } from "@/components/planes/GenerarPdfButton"
|
||||
import { DownloadPlanPDF } from "@/components/planes/DownloadPlanPDF"
|
||||
|
||||
type LoaderData = { plan: PlanFull; asignaturas: AsignaturaLite[] }
|
||||
|
||||
@@ -49,7 +50,10 @@ export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
||||
// ...existing code...
|
||||
function RouteComponent() {
|
||||
const qc = useQueryClient()
|
||||
const { plan, asignaturas: asignaturasPreview } = Route.useLoaderData() as LoaderData
|
||||
//const { plan, asignaturas: asignaturasPreview } = Route.useLoaderData() as LoaderData
|
||||
const { plan } = Route.useLoaderData() as LoaderData
|
||||
|
||||
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(plan.id))
|
||||
const auth = useSupabaseAuth()
|
||||
const asignaturasCount = asignaturasPreview.length
|
||||
|
||||
@@ -102,7 +106,8 @@ function RouteComponent() {
|
||||
{/* <div className='flex gap-2'> */}
|
||||
<EditPlanButton plan={plan} />
|
||||
<AdjustAIButton plan={plan} />
|
||||
<DescargarPdfButton planId={plan.id} opcion="plan" />
|
||||
{/* <DescargarPdfButton planId={plan.id} opcion="plan" /> */}
|
||||
<DownloadPlanPDF plan={plan} />
|
||||
<DescargarPdfButton planId={plan.id} opcion="asignaturas" />
|
||||
<DeletePlanButton planId={plan.id} />
|
||||
{/* </div> */}
|
||||
@@ -200,33 +205,77 @@ function StatCard({ label, value = "—", Icon = Icons.Info, accent, className =
|
||||
|
||||
/* ===== Editar ===== */
|
||||
function EditPlanButton({ plan }: { plan: PlanFull }) {
|
||||
const auth = useSupabaseAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [form, setForm] = useState<Partial<PlanFull>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const qc = useQueryClient()
|
||||
|
||||
// Función para comparar valores y generar diffs tipo JSON Patch
|
||||
function generateDiff(oldData: PlanFull, newData: Partial<PlanFull>) {
|
||||
const changes: any[] = []
|
||||
for (const key of Object.keys(newData)) {
|
||||
const oldValue = (oldData as any)[key]
|
||||
const newValue = (newData as any)[key]
|
||||
if (newValue !== undefined && newValue !== oldValue) {
|
||||
changes.push({
|
||||
op: "replace",
|
||||
path: `/${key}`,
|
||||
from: oldValue,
|
||||
value: newValue
|
||||
})
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (payload: Partial<PlanFull>) => {
|
||||
const { error } = await supabase.from('plan_estudios').update({
|
||||
nombre: payload.nombre ?? plan.nombre,
|
||||
nivel: payload.nivel ?? plan.nivel,
|
||||
duracion: payload.duracion ?? plan.duracion,
|
||||
total_creditos: payload.total_creditos ?? plan.total_creditos,
|
||||
}).eq('id', plan.id)
|
||||
// 1️⃣ Generar las diferencias antes del update
|
||||
const diff = generateDiff(plan, payload)
|
||||
|
||||
// 2️⃣ Guardar respaldo (solo si hay cambios)
|
||||
if (diff.length > 0) {
|
||||
const { error: backupError } = await supabase.from("historico_cambios").insert({
|
||||
id_plan_estudios: plan.id,
|
||||
json_cambios: diff, // jsonb
|
||||
user_id:auth.user?.id,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
if (backupError) throw backupError
|
||||
}
|
||||
|
||||
// 3️⃣ Actualizar el plan principal
|
||||
const { error } = await supabase
|
||||
.from("plan_estudios")
|
||||
.update({
|
||||
nombre: payload.nombre ?? plan.nombre,
|
||||
nivel: payload.nivel ?? plan.nivel,
|
||||
duracion: payload.duracion ?? plan.duracion,
|
||||
total_creditos: payload.total_creditos ?? plan.total_creditos,
|
||||
})
|
||||
.eq("id", plan.id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
onMutate: async (payload) => {
|
||||
await qc.cancelQueries({ queryKey: planKeys.byId(plan.id) })
|
||||
const prev = qc.getQueryData<PlanFull>(planKeys.byId(plan.id))
|
||||
qc.setQueryData<PlanFull>(planKeys.byId(plan.id), (old) => old ? { ...old, ...payload } as PlanFull : old as any)
|
||||
qc.setQueryData<PlanFull>(
|
||||
planKeys.byId(plan.id),
|
||||
(old) => (old ? { ...old, ...payload } as PlanFull : old as any)
|
||||
)
|
||||
return { prev }
|
||||
},
|
||||
|
||||
onError: (_e, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(planKeys.byId(plan.id), ctx.prev)
|
||||
},
|
||||
|
||||
onSettled: async () => {
|
||||
await qc.invalidateQueries({ queryKey: planKeys.byId(plan.id) })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async function save() {
|
||||
|
||||
@@ -10,26 +10,44 @@ import { Plus, RefreshCcw, Building2 } from "lucide-react"
|
||||
import { InfoChip } from "@/components/planes/InfoChip"
|
||||
import { CreatePlanDialog } from "@/components/planes/CreatePlanDialog"
|
||||
import { chipTint } from "@/components/planes/chipTint"
|
||||
import { z } from 'zod'
|
||||
|
||||
|
||||
import { z } from "zod"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
export type PlanDeEstudios = {
|
||||
id: string; nombre: string; nivel: string | null; duracion: string | null;
|
||||
total_creditos: number | null; estado: string | null; fecha_creacion: string | null; carrera_id: string | null
|
||||
id: string
|
||||
nombre: string
|
||||
nivel: string | null
|
||||
duracion: string | null
|
||||
total_creditos: number | null
|
||||
estado: string | null
|
||||
fecha_creacion: string | null
|
||||
carrera_id: string | null
|
||||
}
|
||||
type PlanRow = PlanDeEstudios & {
|
||||
carreras: {
|
||||
id: string; nombre: string;
|
||||
facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null
|
||||
id: string
|
||||
nombre: string
|
||||
facultades?: {
|
||||
id: string
|
||||
nombre: string
|
||||
color?: string | null
|
||||
icon?: string | null
|
||||
} | null
|
||||
} | null
|
||||
}
|
||||
|
||||
const planSearchSchema = z.object({
|
||||
plan: z.string().nullable()
|
||||
plan: z.string().nullable(),
|
||||
facultad: z.string().nullable().optional(),
|
||||
carrera: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/planes")({
|
||||
component: RouteComponent,
|
||||
loader: async () => {
|
||||
@@ -45,93 +63,191 @@ export const Route = createFileRoute("/_authenticated/planes")({
|
||||
`)
|
||||
.order("fecha_creacion", { ascending: false })
|
||||
.limit(100)
|
||||
console.log({ data, error })
|
||||
if (error) throw new Error(error.message)
|
||||
return (data ?? []) as PlanRow[]
|
||||
},
|
||||
validateSearch: planSearchSchema,
|
||||
})
|
||||
|
||||
|
||||
function RouteComponent() {
|
||||
const auth = useSupabaseAuth()
|
||||
const { plan } = Route.useSearch()
|
||||
const { plan, facultad, carrera } = Route.useSearch()
|
||||
const [openCreate, setOpenCreate] = useState(false)
|
||||
const data = Route.useLoaderData() as PlanRow[]
|
||||
const router = useRouter()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
|
||||
const showFacultad =
|
||||
auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
|
||||
const showCarrera =
|
||||
showFacultad || auth.claims?.role === "secretario_academico"
|
||||
|
||||
const showFacultad = auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
|
||||
const showCarrera = showFacultad || auth.claims?.role === "secretario_academico"
|
||||
// 🟣 Lista única de facultades
|
||||
const facultadesList = useMemo(() => {
|
||||
const unique = new Map<string, string>()
|
||||
data?.forEach((p) => {
|
||||
const fac = p.carreras?.facultades
|
||||
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
|
||||
})
|
||||
return Array.from(unique.entries())
|
||||
}, [data])
|
||||
|
||||
// 🎓 Lista de carreras según facultad seleccionada
|
||||
const carrerasList = useMemo(() => {
|
||||
const unique = new Map<string, string>()
|
||||
data?.forEach((p) => {
|
||||
if (
|
||||
p.carreras?.id &&
|
||||
p.carreras?.nombre &&
|
||||
(!facultad || p.carreras?.facultades?.id === facultad)
|
||||
) {
|
||||
unique.set(p.carreras.id, p.carreras.nombre)
|
||||
}
|
||||
})
|
||||
return Array.from(unique.entries())
|
||||
}, [data, facultad])
|
||||
|
||||
// 🧩 Filtrado general
|
||||
const filtered = useMemo(() => {
|
||||
const term = plan?.trim().toLowerCase()
|
||||
if (!term || !data) return data
|
||||
return data.filter((p) =>
|
||||
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(term))
|
||||
)
|
||||
}, [plan, data])
|
||||
let results = data ?? []
|
||||
|
||||
if (term) {
|
||||
results = results.filter((p) =>
|
||||
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(term))
|
||||
)
|
||||
}
|
||||
|
||||
if (facultad && facultad !== "todas") {
|
||||
results = results.filter((p) => p.carreras?.facultades?.id === facultad)
|
||||
}
|
||||
|
||||
if (carrera && carrera !== "todas") {
|
||||
results = results.filter((p) => p.carreras?.id === carrera)
|
||||
}
|
||||
|
||||
return results
|
||||
}, [plan, facultad, carrera, data])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle className="text-xl font-mono">Planes de estudio</CardTitle>
|
||||
<div className="flex w-full items-center gap-2 md:w-auto">
|
||||
|
||||
<div className="flex w-full flex-col md:flex-row items-center gap-2 md:w-auto">
|
||||
{/* 🔍 Buscador */}
|
||||
<div className="relative w-full md:w-80">
|
||||
<Input
|
||||
value={plan ?? ''}
|
||||
onChange={e => navigate({ search: { plan: e.target.value } })}
|
||||
value={plan ?? ""}
|
||||
onChange={(e) =>
|
||||
navigate({ search: { plan: e.target.value, facultad, carrera } })
|
||||
}
|
||||
placeholder="Buscar por nombre, nivel, estado…"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
|
||||
|
||||
{/* 🏛️ Filtro por facultad */}
|
||||
<Select
|
||||
value={facultad ?? "todas"}
|
||||
onValueChange={(val) =>
|
||||
navigate({ search: { plan, facultad: val, carrera: "todas" } })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Filtrar por facultad" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todas">Todas las facultades</SelectItem>
|
||||
{facultadesList.map(([id, nombre]) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 🎓 Filtro por carrera (según facultad) */}
|
||||
{facultad && facultad !== "todas" && (
|
||||
<Select
|
||||
value={carrera ?? "todas"}
|
||||
onValueChange={(val) =>
|
||||
navigate({ search: { plan, facultad, carrera: val } })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Filtrar por carrera" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todas">Todas las carreras</SelectItem>
|
||||
{carrerasList.map(([id, nombre]) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{nombre}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
|
||||
{/* 🔁 Recargar */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => router.invalidate()}
|
||||
title="Recargar"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* ➕ Nuevo plan */}
|
||||
<Button onClick={() => setOpenCreate(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Nuevo plan
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* GRID de tarjetas con estilo suave por facultad */}
|
||||
{/* GRID de tarjetas */}
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filtered?.map((p) => {
|
||||
const fac = p.carreras?.facultades
|
||||
const styles = chipTint(fac?.color)
|
||||
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Building2
|
||||
const IconComp =
|
||||
(fac?.icon && (Icons as any)[fac.icon]) || Building2
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={p.id}
|
||||
to="/plan/$planId"
|
||||
mask={{ to: '/plan/$planId', params: { planId: p.id } }}
|
||||
mask={{ to: "/plan/$planId", params: { planId: p.id } }}
|
||||
className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||
params={{ planId: p.id }}
|
||||
style={styles}
|
||||
>
|
||||
<div className="relative p-5 h-40 flex flex-col justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2"
|
||||
style={{ borderColor: styles.borderColor as string, background: 'rgba(255,255,255,.6)' }}>
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2"
|
||||
style={{
|
||||
borderColor: styles.borderColor as string,
|
||||
background: "rgba(255,255,255,.6)",
|
||||
}}
|
||||
>
|
||||
<IconComp className="w-6 h-6" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold truncate">{p.nombre}</div>
|
||||
<div className="text-xs text-neutral-600 truncate">
|
||||
{p.nivel ?? "—"} {p.duracion ? `· ${p.duracion}` : ""}
|
||||
{p.nivel ?? "—"}{" "}
|
||||
{p.duracion ? `· ${p.duracion}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dentro del map de tarjetas, sustituye SOLO el footer inferior */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{/* grupo izquierdo: chips (wrap si no caben) */}
|
||||
<div className="min-w-0 flex-1 flex flex-wrap items-center gap-2">
|
||||
{showCarrera && p.carreras?.nombre && (
|
||||
<InfoChip
|
||||
@@ -148,18 +264,21 @@ function RouteComponent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* derecha: estado */}
|
||||
{p.estado && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-white/60"
|
||||
style={{ borderColor: (chipTint(fac?.color).borderColor as string) }}
|
||||
style={{
|
||||
borderColor:
|
||||
chipTint(fac?.color).borderColor as string,
|
||||
}}
|
||||
>
|
||||
{p.estado && p.estado.length > 10 ? `${p.estado.slice(0, 10)}…` : p.estado}
|
||||
{p.estado.length > 10
|
||||
? `${p.estado.slice(0, 10)}…`
|
||||
: p.estado}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
@@ -167,16 +286,14 @@ function RouteComponent() {
|
||||
</div>
|
||||
|
||||
{!filtered?.length && (
|
||||
<div className="text-center text-sm text-muted-foreground py-10">Sin resultados</div>
|
||||
<div className="text-center text-sm text-muted-foreground py-10">
|
||||
Sin resultados
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreatePlanDialog
|
||||
open={openCreate}
|
||||
onOpenChange={setOpenCreate}
|
||||
/>
|
||||
|
||||
<CreatePlanDialog open={openCreate} onOpenChange={setOpenCreate} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -138,7 +138,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.animate-aurora {
|
||||
background: radial-gradient(at 20% 30%, oklch(27.5% 0.13488 262.73), transparent 50%),
|
||||
radial-gradient(at 80% 70%, oklch(0.704 0.191 22.216), transparent 50%),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"include": ["**/*.ts", "**/*.tsx", "src/components/ai/AIChatModal.jsx", "src/components/ai/AIChatModal.js"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
Reference in New Issue
Block a user