feat: add Usuarios route and user management functionality
- Introduced a new route for user management under /usuarios. - Implemented user listing with search and edit capabilities. - Added role management with visual indicators for user roles. - Created a modal for editing user details, including role and permissions. - Integrated Supabase for user data retrieval and updates. - Enhanced UI components for better user experience. - Removed unused planes route and related components. - Added a new plan detail modal for displaying plan information. - Updated navigation to include new Usuarios link.
This commit is contained in:
16
bun.lock
16
bun.lock
@@ -8,7 +8,9 @@
|
|||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
@@ -20,6 +22,8 @@
|
|||||||
"@tanstack/router-plugin": "^1.121.2",
|
"@tanstack/router-plugin": "^1.121.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"gsap": "^3.13.0",
|
||||||
"lucide-react": "^0.540.0",
|
"lucide-react": "^0.540.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -224,6 +228,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
|
||||||
|
|
||||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||||
|
|
||||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||||
@@ -236,6 +242,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
|
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
|
||||||
|
|
||||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
@@ -254,10 +262,14 @@
|
|||||||
|
|
||||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||||
|
|
||||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||||
|
|
||||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||||
|
|
||||||
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.8.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^10.0.3", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A=="],
|
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.8.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^10.0.3", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A=="],
|
||||||
@@ -500,6 +512,8 @@
|
|||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
||||||
|
|
||||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
|
"cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
|
||||||
@@ -590,6 +604,8 @@
|
|||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"gsap": ["gsap@3.13.0", "", {}, "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw=="],
|
||||||
|
|
||||||
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
|
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
|
||||||
|
|
||||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
@@ -26,6 +28,8 @@
|
|||||||
"@tanstack/router-plugin": "^1.121.2",
|
"@tanstack/router-plugin": "^1.121.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"gsap": "^3.13.0",
|
||||||
"lucide-react": "^0.540.0",
|
"lucide-react": "^0.540.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
153
src/components/planes/academic-sections.tsx
Normal file
153
src/components/planes/academic-sections.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import * as Icons from "lucide-react"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||||
|
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"
|
||||||
|
|
||||||
|
/* color helpers */
|
||||||
|
function hexToRgb(hex?: string | null): [number, number, number] {
|
||||||
|
if (!hex) return [37, 99, 235]
|
||||||
|
const h = hex.replace("#", ""); const v = h.length === 3 ? h.split("").map(c => c + c).join("") : h
|
||||||
|
const n = parseInt(v, 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||||||
|
}
|
||||||
|
const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`
|
||||||
|
|
||||||
|
/* texto con clamp */
|
||||||
|
function ExpandableText({ text, mono = false }: { text?: string | null; mono?: boolean }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
if (!text) return <span className="text-neutral-400">—</span>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={`${mono ? 'font-mono whitespace-pre-wrap' : ''} text-sm ${open ? '' : 'line-clamp-10'}`}>{text}</div>
|
||||||
|
{text.length > 220 && (
|
||||||
|
<button onClick={() => setOpen(v => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline">
|
||||||
|
{open ? 'Ver menos' : 'Ver más'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* panel con estilo */
|
||||||
|
function SectionPanel({
|
||||||
|
title, icon: Icon, color, children,
|
||||||
|
}: { title: string; icon: any; color?: string | null; children: React.ReactNode }) {
|
||||||
|
const rgb = hexToRgb(color)
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border backdrop-blur shadow-sm overflow-hidden">
|
||||||
|
<div className="px-4 py-3 flex items-center gap-2"
|
||||||
|
style={{ background: `linear-gradient(180deg, ${rgba(rgb, .10)}, transparent)` }}>
|
||||||
|
<span className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2"
|
||||||
|
style={{ borderColor: rgba(rgb, .25), background: "rgba(255,255,255,.75)" }}>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<h3 className="font-semibold">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- TABS + EDIT DIALOG ---------- */
|
||||||
|
type PlanTextFields = {
|
||||||
|
objetivo_general?: string | null; sistema_evaluacion?: string | null;
|
||||||
|
perfil_ingreso?: string | null; perfil_egreso?: string | null;
|
||||||
|
competencias_genericas?: string | null; competencias_especificas?: string | null;
|
||||||
|
indicadores_desempeno?: string | null; pertinencia?: string | null; prompt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AcademicSections({
|
||||||
|
planId, plan, color,
|
||||||
|
}: { planId: string; plan: PlanTextFields; color?: string | null }) {
|
||||||
|
// estado local editable
|
||||||
|
const [local, setLocal] = useState<PlanTextFields>({ ...plan })
|
||||||
|
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
|
||||||
|
const [draft, setDraft] = useState("")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const sections = useMemo(() => [
|
||||||
|
{ id: "obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false },
|
||||||
|
{ id: "eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false },
|
||||||
|
{ id: "ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false },
|
||||||
|
{ id: "egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false },
|
||||||
|
{ id: "cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false },
|
||||||
|
{ id: "ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" as const, mono: false },
|
||||||
|
{ id: "ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false },
|
||||||
|
{ id: "pert", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false },
|
||||||
|
{ id: "prompt", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true },
|
||||||
|
], [])
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!editing) return
|
||||||
|
setSaving(true)
|
||||||
|
const payload: any = { [editing.key]: draft }
|
||||||
|
const { error } = await supabase.from("plan_estudios").update(payload).eq("id", planId)
|
||||||
|
setSaving(false)
|
||||||
|
if (error) {
|
||||||
|
console.error(error)
|
||||||
|
alert("No se pudo guardar 😓")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLocal(prev => ({ ...prev, [editing.key]: draft }))
|
||||||
|
setEditing(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tabs defaultValue={sections[0].id} className="w-full">
|
||||||
|
{/* nav sticky con píldoras scrollables */}
|
||||||
|
<div className="sticky top-0 z-10 backdrop-blur border-b">
|
||||||
|
<TabsList className="w-full flex gap-2 p-3 scrollbar-none">
|
||||||
|
{sections.map(s => (
|
||||||
|
<TabsTrigger key={s.id} value={s.id}
|
||||||
|
className="data-[state=active]:bg-primary data-[state=active]:text-white p-3 text-xs">
|
||||||
|
{s.title}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* contenido */}
|
||||||
|
{sections.map(s => {
|
||||||
|
const text = local[s.key] ?? null
|
||||||
|
return (
|
||||||
|
<TabsContent key={s.id} value={s.id} className="mt-4">
|
||||||
|
<SectionPanel title={s.title} icon={s.icon} color={color}>
|
||||||
|
<ExpandableText text={text} mono={s.mono} />
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={!text}
|
||||||
|
onClick={() => text && navigator.clipboard.writeText(text)}>Copiar</Button>
|
||||||
|
<Button variant="ghost" size="sm"
|
||||||
|
onClick={() => { setEditing({ key: s.key, title: s.title }); setDraft(text ?? "") }}>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
|
</TabsContent>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Dialog de edición */}
|
||||||
|
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{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={handleSave} disabled={saving}>{saving ? "Guardando…" : "Guardar"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
184
src/components/ui/command.tsx
Normal file
184
src/components/ui/command.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn("overflow-hidden p-0", className)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
46
src/components/ui/popover.tsx
Normal file
46
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
183
src/components/ui/select.tsx
Normal file
183
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
104
src/components/users/procedencia-combobox.tsx
Normal file
104
src/components/users/procedencia-combobox.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
|
||||||
|
import { Command, CommandInput, CommandList, CommandGroup, CommandItem, CommandEmpty } from "@/components/ui/command"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Check, ChevronsUpDown, Building2, GraduationCap } from "lucide-react"
|
||||||
|
import { supabase } from "@/auth/supabase"
|
||||||
|
|
||||||
|
/* Util simple */
|
||||||
|
const cls = (...a: (string|false|undefined)[]) => a.filter(Boolean).join(" ")
|
||||||
|
|
||||||
|
/* --------- COMBOBOX BASE --------- */
|
||||||
|
function ComboBase({
|
||||||
|
placeholder, value, onChange, options, icon:Icon,
|
||||||
|
}: {
|
||||||
|
placeholder: string
|
||||||
|
value?: string | null
|
||||||
|
onChange: (id: string) => void
|
||||||
|
options: { id: string; label: string }[]
|
||||||
|
icon?: any
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const current = useMemo(() => options.find(o => o.id === value), [options, value])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="w-full sm:w-[420px] justify-between truncate"
|
||||||
|
title={current?.label ?? placeholder}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 truncate">
|
||||||
|
{Icon ? <Icon className="h-4 w-4 opacity-70" /> : null}
|
||||||
|
<span className="truncate">{current?.label ?? placeholder}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
sideOffset={6}
|
||||||
|
className="w-[--radix-popover-trigger-width] max-w-[min(92vw,28rem)] p-0"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Buscar..." />
|
||||||
|
<CommandList className="max-h-72">
|
||||||
|
<CommandEmpty>Sin resultados</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map(o => (
|
||||||
|
<CommandItem
|
||||||
|
key={o.id}
|
||||||
|
value={o.label}
|
||||||
|
onSelect={() => { onChange(o.id); setOpen(false) }}
|
||||||
|
className="whitespace-normal"
|
||||||
|
>
|
||||||
|
<Check className={cls("mr-2 h-4 w-4", value === o.id ? "opacity-100" : "opacity-0")} />
|
||||||
|
{o.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------- COMBO FACULTADES --------- */
|
||||||
|
export function FacultadCombobox({
|
||||||
|
value, onChange,
|
||||||
|
}: { value?: string | null; onChange: (id: string) => void }) {
|
||||||
|
const [items, setItems] = useState<{ id: string; label: string }[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
supabase.from("facultades").select("id, nombre, color").order("nombre", { ascending: true })
|
||||||
|
.then(({ data }) => setItems((data ?? []).map(f => ({ id: f.id, label: f.nombre }))))
|
||||||
|
}, [])
|
||||||
|
return <ComboBase placeholder="Selecciona facultad…" value={value} onChange={onChange} options={items} icon={Building2} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------- COMBO CARRERAS (filtrado por facultad) --------- */
|
||||||
|
export function CarreraCombobox({
|
||||||
|
facultadId, value, onChange, disabled,
|
||||||
|
}: { facultadId?: string | null; value?: string | null; onChange: (id: string) => void; disabled?: boolean }) {
|
||||||
|
const [items, setItems] = useState<{ id: string; label: string }[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!facultadId) { setItems([]); return }
|
||||||
|
supabase.from("carreras")
|
||||||
|
.select("id, nombre").eq("facultad_id", facultadId).order("nombre", { ascending: true })
|
||||||
|
.then(({ data }) => setItems((data ?? []).map(c => ({ id: c.id, label: c.nombre }))))
|
||||||
|
}, [facultadId])
|
||||||
|
return (
|
||||||
|
<div className={disabled ? "opacity-60 pointer-events-none" : ""}>
|
||||||
|
<ComboBase
|
||||||
|
placeholder={facultadId ? "Selecciona carrera…" : "Selecciona una facultad primero"}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={items}
|
||||||
|
icon={GraduationCap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,9 +17,11 @@ const router = createRouter({
|
|||||||
defaultPreloadStaleTime: 0,
|
defaultPreloadStaleTime: 0,
|
||||||
context:{
|
context:{
|
||||||
auth: undefined!,
|
auth: undefined!,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Register the router instance for type safety
|
// Register the router instance for type safety
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface Register {
|
interface Register {
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as AuthenticatedUsuariosRouteImport } from './routes/_authenticated/usuarios'
|
||||||
import { Route as AuthenticatedPlanesRouteImport } from './routes/_authenticated/planes'
|
import { Route as AuthenticatedPlanesRouteImport } from './routes/_authenticated/planes'
|
||||||
import { Route as AuthenticatedFacultadesRouteImport } from './routes/_authenticated/facultades'
|
import { Route as AuthenticatedFacultadesRouteImport } from './routes/_authenticated/facultades'
|
||||||
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
|
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
|
||||||
import { Route as AuthenticatedAsignaturasRouteImport } from './routes/_authenticated/asignaturas'
|
import { Route as AuthenticatedAsignaturasRouteImport } from './routes/_authenticated/asignaturas'
|
||||||
import { Route as AuthenticatedPlanesPlanIdRouteImport } from './routes/_authenticated/planes/$planId'
|
import { Route as AuthenticatedPlanPlanIdRouteImport } from './routes/_authenticated/plan/$planId'
|
||||||
import { Route as AuthenticatedFacultadFacultadIdRouteImport } from './routes/_authenticated/facultad/$facultadId'
|
import { Route as AuthenticatedFacultadFacultadIdRouteImport } from './routes/_authenticated/facultad/$facultadId'
|
||||||
import { Route as AuthenticatedPlanesPlanIdModalRouteImport } from './routes/_authenticated/planes/$planId/modal'
|
import { Route as AuthenticatedPlanPlanIdModalRouteImport } from './routes/_authenticated/plan/$planId/modal'
|
||||||
|
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
id: '/login',
|
id: '/login',
|
||||||
@@ -34,6 +35,11 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthenticatedUsuariosRoute = AuthenticatedUsuariosRouteImport.update({
|
||||||
|
id: '/usuarios',
|
||||||
|
path: '/usuarios',
|
||||||
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
|
} as any)
|
||||||
const AuthenticatedPlanesRoute = AuthenticatedPlanesRouteImport.update({
|
const AuthenticatedPlanesRoute = AuthenticatedPlanesRouteImport.update({
|
||||||
id: '/planes',
|
id: '/planes',
|
||||||
path: '/planes',
|
path: '/planes',
|
||||||
@@ -55,23 +61,22 @@ const AuthenticatedAsignaturasRoute =
|
|||||||
path: '/asignaturas',
|
path: '/asignaturas',
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthenticatedPlanesPlanIdRoute =
|
const AuthenticatedPlanPlanIdRoute = AuthenticatedPlanPlanIdRouteImport.update({
|
||||||
AuthenticatedPlanesPlanIdRouteImport.update({
|
id: '/plan/$planId',
|
||||||
id: '/$planId',
|
path: '/plan/$planId',
|
||||||
path: '/$planId',
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
getParentRoute: () => AuthenticatedPlanesRoute,
|
} as any)
|
||||||
} as any)
|
|
||||||
const AuthenticatedFacultadFacultadIdRoute =
|
const AuthenticatedFacultadFacultadIdRoute =
|
||||||
AuthenticatedFacultadFacultadIdRouteImport.update({
|
AuthenticatedFacultadFacultadIdRouteImport.update({
|
||||||
id: '/facultad/$facultadId',
|
id: '/facultad/$facultadId',
|
||||||
path: '/facultad/$facultadId',
|
path: '/facultad/$facultadId',
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthenticatedPlanesPlanIdModalRoute =
|
const AuthenticatedPlanPlanIdModalRoute =
|
||||||
AuthenticatedPlanesPlanIdModalRouteImport.update({
|
AuthenticatedPlanPlanIdModalRouteImport.update({
|
||||||
id: '/modal',
|
id: '/modal',
|
||||||
path: '/modal',
|
path: '/modal',
|
||||||
getParentRoute: () => AuthenticatedPlanesPlanIdRoute,
|
getParentRoute: () => AuthenticatedPlanPlanIdRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
@@ -80,10 +85,11 @@ export interface FileRoutesByFullPath {
|
|||||||
'/asignaturas': typeof AuthenticatedAsignaturasRoute
|
'/asignaturas': typeof AuthenticatedAsignaturasRoute
|
||||||
'/dashboard': typeof AuthenticatedDashboardRoute
|
'/dashboard': typeof AuthenticatedDashboardRoute
|
||||||
'/facultades': typeof AuthenticatedFacultadesRoute
|
'/facultades': typeof AuthenticatedFacultadesRoute
|
||||||
'/planes': typeof AuthenticatedPlanesRouteWithChildren
|
'/planes': typeof AuthenticatedPlanesRoute
|
||||||
|
'/usuarios': typeof AuthenticatedUsuariosRoute
|
||||||
'/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute
|
'/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute
|
||||||
'/planes/$planId': typeof AuthenticatedPlanesPlanIdRouteWithChildren
|
'/plan/$planId': typeof AuthenticatedPlanPlanIdRouteWithChildren
|
||||||
'/planes/$planId/modal': typeof AuthenticatedPlanesPlanIdModalRoute
|
'/plan/$planId/modal': typeof AuthenticatedPlanPlanIdModalRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
@@ -91,10 +97,11 @@ export interface FileRoutesByTo {
|
|||||||
'/asignaturas': typeof AuthenticatedAsignaturasRoute
|
'/asignaturas': typeof AuthenticatedAsignaturasRoute
|
||||||
'/dashboard': typeof AuthenticatedDashboardRoute
|
'/dashboard': typeof AuthenticatedDashboardRoute
|
||||||
'/facultades': typeof AuthenticatedFacultadesRoute
|
'/facultades': typeof AuthenticatedFacultadesRoute
|
||||||
'/planes': typeof AuthenticatedPlanesRouteWithChildren
|
'/planes': typeof AuthenticatedPlanesRoute
|
||||||
|
'/usuarios': typeof AuthenticatedUsuariosRoute
|
||||||
'/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute
|
'/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute
|
||||||
'/planes/$planId': typeof AuthenticatedPlanesPlanIdRouteWithChildren
|
'/plan/$planId': typeof AuthenticatedPlanPlanIdRouteWithChildren
|
||||||
'/planes/$planId/modal': typeof AuthenticatedPlanesPlanIdModalRoute
|
'/plan/$planId/modal': typeof AuthenticatedPlanPlanIdModalRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -104,10 +111,11 @@ export interface FileRoutesById {
|
|||||||
'/_authenticated/asignaturas': typeof AuthenticatedAsignaturasRoute
|
'/_authenticated/asignaturas': typeof AuthenticatedAsignaturasRoute
|
||||||
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
|
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
|
||||||
'/_authenticated/facultades': typeof AuthenticatedFacultadesRoute
|
'/_authenticated/facultades': typeof AuthenticatedFacultadesRoute
|
||||||
'/_authenticated/planes': typeof AuthenticatedPlanesRouteWithChildren
|
'/_authenticated/planes': typeof AuthenticatedPlanesRoute
|
||||||
|
'/_authenticated/usuarios': typeof AuthenticatedUsuariosRoute
|
||||||
'/_authenticated/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute
|
'/_authenticated/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute
|
||||||
'/_authenticated/planes/$planId': typeof AuthenticatedPlanesPlanIdRouteWithChildren
|
'/_authenticated/plan/$planId': typeof AuthenticatedPlanPlanIdRouteWithChildren
|
||||||
'/_authenticated/planes/$planId/modal': typeof AuthenticatedPlanesPlanIdModalRoute
|
'/_authenticated/plan/$planId/modal': typeof AuthenticatedPlanPlanIdModalRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -118,9 +126,10 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/facultades'
|
| '/facultades'
|
||||||
| '/planes'
|
| '/planes'
|
||||||
|
| '/usuarios'
|
||||||
| '/facultad/$facultadId'
|
| '/facultad/$facultadId'
|
||||||
| '/planes/$planId'
|
| '/plan/$planId'
|
||||||
| '/planes/$planId/modal'
|
| '/plan/$planId/modal'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
@@ -129,9 +138,10 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/facultades'
|
| '/facultades'
|
||||||
| '/planes'
|
| '/planes'
|
||||||
|
| '/usuarios'
|
||||||
| '/facultad/$facultadId'
|
| '/facultad/$facultadId'
|
||||||
| '/planes/$planId'
|
| '/plan/$planId'
|
||||||
| '/planes/$planId/modal'
|
| '/plan/$planId/modal'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
@@ -141,9 +151,10 @@ export interface FileRouteTypes {
|
|||||||
| '/_authenticated/dashboard'
|
| '/_authenticated/dashboard'
|
||||||
| '/_authenticated/facultades'
|
| '/_authenticated/facultades'
|
||||||
| '/_authenticated/planes'
|
| '/_authenticated/planes'
|
||||||
|
| '/_authenticated/usuarios'
|
||||||
| '/_authenticated/facultad/$facultadId'
|
| '/_authenticated/facultad/$facultadId'
|
||||||
| '/_authenticated/planes/$planId'
|
| '/_authenticated/plan/$planId'
|
||||||
| '/_authenticated/planes/$planId/modal'
|
| '/_authenticated/plan/$planId/modal'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -175,6 +186,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/_authenticated/usuarios': {
|
||||||
|
id: '/_authenticated/usuarios'
|
||||||
|
path: '/usuarios'
|
||||||
|
fullPath: '/usuarios'
|
||||||
|
preLoaderRoute: typeof AuthenticatedUsuariosRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRoute
|
||||||
|
}
|
||||||
'/_authenticated/planes': {
|
'/_authenticated/planes': {
|
||||||
id: '/_authenticated/planes'
|
id: '/_authenticated/planes'
|
||||||
path: '/planes'
|
path: '/planes'
|
||||||
@@ -203,12 +221,12 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedAsignaturasRouteImport
|
preLoaderRoute: typeof AuthenticatedAsignaturasRouteImport
|
||||||
parentRoute: typeof AuthenticatedRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/_authenticated/planes/$planId': {
|
'/_authenticated/plan/$planId': {
|
||||||
id: '/_authenticated/planes/$planId'
|
id: '/_authenticated/plan/$planId'
|
||||||
path: '/$planId'
|
path: '/plan/$planId'
|
||||||
fullPath: '/planes/$planId'
|
fullPath: '/plan/$planId'
|
||||||
preLoaderRoute: typeof AuthenticatedPlanesPlanIdRouteImport
|
preLoaderRoute: typeof AuthenticatedPlanPlanIdRouteImport
|
||||||
parentRoute: typeof AuthenticatedPlanesRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/_authenticated/facultad/$facultadId': {
|
'/_authenticated/facultad/$facultadId': {
|
||||||
id: '/_authenticated/facultad/$facultadId'
|
id: '/_authenticated/facultad/$facultadId'
|
||||||
@@ -217,55 +235,48 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedFacultadFacultadIdRouteImport
|
preLoaderRoute: typeof AuthenticatedFacultadFacultadIdRouteImport
|
||||||
parentRoute: typeof AuthenticatedRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/_authenticated/planes/$planId/modal': {
|
'/_authenticated/plan/$planId/modal': {
|
||||||
id: '/_authenticated/planes/$planId/modal'
|
id: '/_authenticated/plan/$planId/modal'
|
||||||
path: '/modal'
|
path: '/modal'
|
||||||
fullPath: '/planes/$planId/modal'
|
fullPath: '/plan/$planId/modal'
|
||||||
preLoaderRoute: typeof AuthenticatedPlanesPlanIdModalRouteImport
|
preLoaderRoute: typeof AuthenticatedPlanPlanIdModalRouteImport
|
||||||
parentRoute: typeof AuthenticatedPlanesPlanIdRoute
|
parentRoute: typeof AuthenticatedPlanPlanIdRoute
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthenticatedPlanesPlanIdRouteChildren {
|
interface AuthenticatedPlanPlanIdRouteChildren {
|
||||||
AuthenticatedPlanesPlanIdModalRoute: typeof AuthenticatedPlanesPlanIdModalRoute
|
AuthenticatedPlanPlanIdModalRoute: typeof AuthenticatedPlanPlanIdModalRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticatedPlanesPlanIdRouteChildren: AuthenticatedPlanesPlanIdRouteChildren =
|
const AuthenticatedPlanPlanIdRouteChildren: AuthenticatedPlanPlanIdRouteChildren =
|
||||||
{
|
{
|
||||||
AuthenticatedPlanesPlanIdModalRoute: AuthenticatedPlanesPlanIdModalRoute,
|
AuthenticatedPlanPlanIdModalRoute: AuthenticatedPlanPlanIdModalRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticatedPlanesPlanIdRouteWithChildren =
|
const AuthenticatedPlanPlanIdRouteWithChildren =
|
||||||
AuthenticatedPlanesPlanIdRoute._addFileChildren(
|
AuthenticatedPlanPlanIdRoute._addFileChildren(
|
||||||
AuthenticatedPlanesPlanIdRouteChildren,
|
AuthenticatedPlanPlanIdRouteChildren,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface AuthenticatedPlanesRouteChildren {
|
|
||||||
AuthenticatedPlanesPlanIdRoute: typeof AuthenticatedPlanesPlanIdRouteWithChildren
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthenticatedPlanesRouteChildren: AuthenticatedPlanesRouteChildren = {
|
|
||||||
AuthenticatedPlanesPlanIdRoute: AuthenticatedPlanesPlanIdRouteWithChildren,
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthenticatedPlanesRouteWithChildren =
|
|
||||||
AuthenticatedPlanesRoute._addFileChildren(AuthenticatedPlanesRouteChildren)
|
|
||||||
|
|
||||||
interface AuthenticatedRouteChildren {
|
interface AuthenticatedRouteChildren {
|
||||||
AuthenticatedAsignaturasRoute: typeof AuthenticatedAsignaturasRoute
|
AuthenticatedAsignaturasRoute: typeof AuthenticatedAsignaturasRoute
|
||||||
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
|
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
|
||||||
AuthenticatedFacultadesRoute: typeof AuthenticatedFacultadesRoute
|
AuthenticatedFacultadesRoute: typeof AuthenticatedFacultadesRoute
|
||||||
AuthenticatedPlanesRoute: typeof AuthenticatedPlanesRouteWithChildren
|
AuthenticatedPlanesRoute: typeof AuthenticatedPlanesRoute
|
||||||
|
AuthenticatedUsuariosRoute: typeof AuthenticatedUsuariosRoute
|
||||||
AuthenticatedFacultadFacultadIdRoute: typeof AuthenticatedFacultadFacultadIdRoute
|
AuthenticatedFacultadFacultadIdRoute: typeof AuthenticatedFacultadFacultadIdRoute
|
||||||
|
AuthenticatedPlanPlanIdRoute: typeof AuthenticatedPlanPlanIdRouteWithChildren
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||||
AuthenticatedAsignaturasRoute: AuthenticatedAsignaturasRoute,
|
AuthenticatedAsignaturasRoute: AuthenticatedAsignaturasRoute,
|
||||||
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
|
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
|
||||||
AuthenticatedFacultadesRoute: AuthenticatedFacultadesRoute,
|
AuthenticatedFacultadesRoute: AuthenticatedFacultadesRoute,
|
||||||
AuthenticatedPlanesRoute: AuthenticatedPlanesRouteWithChildren,
|
AuthenticatedPlanesRoute: AuthenticatedPlanesRoute,
|
||||||
|
AuthenticatedUsuariosRoute: AuthenticatedUsuariosRoute,
|
||||||
AuthenticatedFacultadFacultadIdRoute: AuthenticatedFacultadFacultadIdRoute,
|
AuthenticatedFacultadFacultadIdRoute: AuthenticatedFacultadFacultadIdRoute,
|
||||||
|
AuthenticatedPlanPlanIdRoute: AuthenticatedPlanPlanIdRouteWithChildren,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
KeySquare,
|
KeySquare,
|
||||||
IdCard,
|
IdCard,
|
||||||
|
Users2Icon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useSupabaseAuth } from "@/auth/supabase"
|
import { useSupabaseAuth } from "@/auth/supabase"
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ const nav = [
|
|||||||
{ to: "/planes", label: "Planes", icon: GraduationCap },
|
{ to: "/planes", label: "Planes", icon: GraduationCap },
|
||||||
{ to: "/asignaturas", label: "Asignaturas", icon: FileText },
|
{ to: "/asignaturas", label: "Asignaturas", icon: FileText },
|
||||||
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
{ to: "/usuarios", label: "Usuarios", icon: Users2Icon },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function getInitials(name?: string) {
|
function getInitials(name?: string) {
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ function RouteComponent() {
|
|||||||
{recientes.length === 0 && <li className="text-sm text-neutral-500">Sin actividad</li>}
|
{recientes.length === 0 && <li className="text-sm text-neutral-500">Sin actividad</li>}
|
||||||
{recientes.map((r) => (
|
{recientes.map((r) => (
|
||||||
<li key={`${r.tipo}-${r.id}`} className="flex items-center justify-between gap-3">
|
<li key={`${r.tipo}-${r.id}`} className="flex items-center justify-between gap-3">
|
||||||
<Link to={`/${r.tipo === 'plan' ? 'planes' : 'asignaturas'}/${r.id}`} className="truncate hover:underline">
|
<Link to={`/${r.tipo === 'plan' ? 'plan' : 'plan'}/$planId`} className="truncate hover:underline" params={{planId: r.id}}>
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
{r.tipo === 'plan' ? <Icons.ScrollText className="w-4 h-4" /> : <Icons.BookOpen className="w-4 h-4" />}
|
{r.tipo === 'plan' ? <Icons.ScrollText className="w-4 h-4" /> : <Icons.BookOpen className="w-4 h-4" />}
|
||||||
{r.nombre ?? '—'}
|
{r.nombre ?? '—'}
|
||||||
|
|||||||
359
src/routes/_authenticated/plan/$planId.tsx
Normal file
359
src/routes/_authenticated/plan/$planId.tsx
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import { createFileRoute, Link, useParams } from '@tanstack/react-router'
|
||||||
|
import { supabase, useSupabaseAuth } from '@/auth/supabase'
|
||||||
|
import * as Icons from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import gsap from 'gsap'
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
|
import { AcademicSections } from '@/components/planes/academic-sections'
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
|
||||||
|
type PlanFull = {
|
||||||
|
id: string; nombre: string; nivel: string | null;
|
||||||
|
objetivo_general: string | null; perfil_ingreso: string | null; perfil_egreso: string | null;
|
||||||
|
duracion: string | null; total_creditos: number | null;
|
||||||
|
competencias_genericas: string | null; competencias_especificas: string | null;
|
||||||
|
sistema_evaluacion: string | null; indicadores_desempeno: string | null;
|
||||||
|
pertinencia: string | null; prompt: string | null;
|
||||||
|
estado: string | null; fecha_creacion: string | null;
|
||||||
|
carreras: { id: string; nombre: string; facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null } | null
|
||||||
|
}
|
||||||
|
type LoaderData = { plan: PlanFull; asignaturasCount: number }
|
||||||
|
|
||||||
|
/* ============== ROUTE ============== */
|
||||||
|
export const Route = createFileRoute('/_authenticated/plan/$planId')({
|
||||||
|
component: RouteComponent,
|
||||||
|
pendingComponent: PageSkeleton,
|
||||||
|
loader: async ({ params }): Promise<LoaderData> => {
|
||||||
|
const { data: plan, error } = await supabase
|
||||||
|
.from('plan_estudios')
|
||||||
|
.select(`
|
||||||
|
id, nombre, nivel, objetivo_general, perfil_ingreso, perfil_egreso, duracion, total_creditos,
|
||||||
|
competencias_genericas, competencias_especificas, sistema_evaluacion, indicadores_desempeno,
|
||||||
|
pertinencia, prompt, estado, fecha_creacion,
|
||||||
|
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
|
||||||
|
`)
|
||||||
|
.eq('id', params.planId)
|
||||||
|
.single()
|
||||||
|
if (error || !plan) throw error ?? new Error('Plan no encontrado')
|
||||||
|
|
||||||
|
const { count } = await supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('plan_id', params.planId)
|
||||||
|
|
||||||
|
return { plan: plan as unknown as PlanFull, asignaturasCount: count ?? 0 }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ============== COLOR / MESH HELPERS ============== */
|
||||||
|
function hexToRgb(hex?: string | null): [number, number, number] {
|
||||||
|
if (!hex) return [37, 99, 235]
|
||||||
|
const h = hex.replace('#', '')
|
||||||
|
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
|
||||||
|
const n = parseInt(v, 16)
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||||||
|
}
|
||||||
|
function softAccentStyle(color?: string | null) {
|
||||||
|
const [r, g, b] = hexToRgb(color)
|
||||||
|
return {
|
||||||
|
borderColor: `rgba(${r},${g},${b},.28)`,
|
||||||
|
background: `linear-gradient(180deg, rgba(${r},${g},${b},.06), rgba(${r},${g},${b},.02))`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
function lighten([r, g, b]: [number, number, number], amt = 30) { return [r + amt, g + amt, b + amt].map(v => Math.max(0, Math.min(255, v))) as [number, number, number] }
|
||||||
|
function toRGBA([r, g, b]: [number, number, number], a: number) { return `rgba(${r},${g},${b},${a})` }
|
||||||
|
|
||||||
|
/* ============== GRADIENT MESH LAYER ============== */
|
||||||
|
function GradientMesh({ color }: { color?: string | null }) {
|
||||||
|
const meshRef = useRef<HTMLDivElement>(null)
|
||||||
|
const base = hexToRgb(color)
|
||||||
|
const soft = lighten(base, 20)
|
||||||
|
const pop = lighten(base, -20)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!meshRef.current) return
|
||||||
|
const blobs = meshRef.current.querySelectorAll('.blob')
|
||||||
|
blobs.forEach((el, i) => {
|
||||||
|
gsap.to(el, {
|
||||||
|
x: gsap.utils.random(-30, 30),
|
||||||
|
y: gsap.utils.random(-20, 20),
|
||||||
|
rotate: gsap.utils.random(-6, 6),
|
||||||
|
duration: gsap.utils.random(6, 10),
|
||||||
|
ease: 'sine.inOut',
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
delay: i * 0.2,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return () => gsap.killTweensOf(blobs)
|
||||||
|
}, [color])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={meshRef} className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
|
||||||
|
<div className="blob absolute -top-24 -left-24 w-[38rem] h-[38rem] rounded-full blur-3xl"
|
||||||
|
style={{ background: `radial-gradient(circle, ${toRGBA(soft, .35)}, transparent 60%)` }} />
|
||||||
|
<div className="blob absolute -bottom-28 -right-20 w-[34rem] h-[34rem] rounded-full blur-[60px]"
|
||||||
|
style={{ background: `radial-gradient(circle, ${toRGBA(base, .28)}, transparent 60%)` }} />
|
||||||
|
<div className="blob absolute top-1/3 left-1/2 -translate-x-1/2 w-[22rem] h-[22rem] rounded-full blur-[50px]"
|
||||||
|
style={{ background: `radial-gradient(circle, ${toRGBA(pop, .22)}, transparent 60%)` }} />
|
||||||
|
<div className="absolute inset-0 opacity-40"
|
||||||
|
style={{ background: 'radial-gradient(800px 300px at 20% -10%, #fff, transparent 60%)' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============== PAGE ============== */
|
||||||
|
function RouteComponent() {
|
||||||
|
const { plan, asignaturasCount } = Route.useLoaderData() as LoaderData
|
||||||
|
const auth = useSupabaseAuth()
|
||||||
|
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
|
||||||
|
const showCarrera = auth.claims?.role === 'secretario_academico'
|
||||||
|
|
||||||
|
|
||||||
|
const fac = plan.carreras?.facultades
|
||||||
|
const accent = useMemo(() => softAccentStyle(fac?.color), [fac?.color])
|
||||||
|
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
|
||||||
|
|
||||||
|
// Refs para animaciones
|
||||||
|
const headerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const statsRef = useRef<HTMLDivElement>(null)
|
||||||
|
const fieldsRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Header intro
|
||||||
|
if (headerRef.current) {
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
|
||||||
|
tl.from('.hdr-icon', { y: 12, opacity: 0, duration: .5 })
|
||||||
|
.from('.hdr-title', { y: 8, opacity: 0, duration: .4 }, '-=.25')
|
||||||
|
.from('.hdr-chips > *', { y: 6, opacity: 0, stagger: .06, duration: .35 }, '-=.25')
|
||||||
|
}, headerRef)
|
||||||
|
return () => ctx.revert()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Stats y campos con ScrollTrigger
|
||||||
|
if (statsRef.current) {
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
gsap.from('.kv', {
|
||||||
|
y: 14, opacity: 0, stagger: .08, duration: .4,
|
||||||
|
scrollTrigger: { trigger: statsRef.current, start: 'top 85%' }
|
||||||
|
})
|
||||||
|
}, statsRef)
|
||||||
|
return () => ctx.revert()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useEffect(() => {
|
||||||
|
if (fieldsRef.current) {
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
gsap.utils.toArray<HTMLElement>('.long-field').forEach((el, i) => {
|
||||||
|
gsap.from(el, {
|
||||||
|
y: 22, opacity: 0, duration: .45, delay: i * 0.03,
|
||||||
|
scrollTrigger: { trigger: el, start: 'top 90%' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, fieldsRef)
|
||||||
|
return () => ctx.revert()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative p-6 space-y-6">
|
||||||
|
{/* Mesh global */}
|
||||||
|
<GradientMesh color={fac?.color} />
|
||||||
|
|
||||||
|
<nav className="relative text-sm text-neutral-500">
|
||||||
|
<Link to="/planes" className="hover:underline">Planes de estudio</Link>
|
||||||
|
<span className="mx-1">/</span>
|
||||||
|
<span className="text-primary">{plan.nombre}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Header con acciones y brillo */}
|
||||||
|
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
|
||||||
|
{/* velo de color muy suave */}
|
||||||
|
<div className="absolute inset-0 -z-0" style={accent} />
|
||||||
|
<CardHeader className="relative z-10 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<span className="hdr-icon inline-flex items-center justify-center rounded-2xl border px-3 py-2 bg-white/70"
|
||||||
|
style={{ borderColor: accent.borderColor as string }}>
|
||||||
|
<IconComp className="w-6 h-6" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="hdr-title truncate">{plan.nombre}</CardTitle>
|
||||||
|
<div className="hdr-chips text-xs text-neutral-600 truncate">
|
||||||
|
{showCarrera && plan.carreras?.nombre ? `Carrera: ${plan.carreras.nombre}` : null}
|
||||||
|
{showFacultad && fac?.nombre ? `${showCarrera ? ' · ' : ''}Facultad: ${fac.nombre}` : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hdr-chips flex flex-wrap items-center gap-2">
|
||||||
|
{plan.estado && (
|
||||||
|
<Badge variant="outline" className="bg-white/60" style={{ borderColor: accent.borderColor }}>
|
||||||
|
{plan.estado}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to="/asignaturas"
|
||||||
|
search={{ planId: plan.id }}
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
<Icons.BookOpen className="w-4 h-4" /> Ver asignaturas
|
||||||
|
</Link>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<EditPlanButton plan={plan} />
|
||||||
|
<AdjustAIButton plan={plan} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* stats */}
|
||||||
|
<CardContent ref={statsRef} className="relative z-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<KV className="kv" label="Nivel" value={plan.nivel} />
|
||||||
|
<KV className="kv" label="Duración" value={plan.duracion} />
|
||||||
|
<KV className="kv" label="Créditos" value={plan.total_creditos} />
|
||||||
|
<KV className="kv" label="Asignaturas" value={asignaturasCount} />
|
||||||
|
<KV className="kv" label="Creado" value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : '—'} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<AcademicSections planId={plan.id} plan={plan} color={fac?.color} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== UI bits ===== */
|
||||||
|
function KV({ label, value, className = '' }: { label: string; value?: string | number | null; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border p-4 shadow-sm hover:shadow-md transition-shadow ${className}`}>
|
||||||
|
<div className="text-xs text-neutral-500">{label}</div>
|
||||||
|
<div className="text-base font-medium">{value ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Editar ===== */
|
||||||
|
function EditPlanButton({ plan }: { plan: PlanFull }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [form, setForm] = useState<Partial<PlanFull>>({})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true)
|
||||||
|
const { error } = await supabase.from('plan_estudios').update({
|
||||||
|
nombre: form.nombre ?? plan.nombre,
|
||||||
|
nivel: form.nivel ?? plan.nivel,
|
||||||
|
duracion: form.duracion ?? plan.duracion,
|
||||||
|
total_creditos: form.total_creditos ?? plan.total_creditos,
|
||||||
|
}).eq('id', plan.id)
|
||||||
|
setSaving(false)
|
||||||
|
if (!error) setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={() => { setForm(plan); setOpen(true) }}>
|
||||||
|
<Icons.Pencil className="w-4 h-4 mr-2" /> Editar
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Editar plan</DialogTitle>
|
||||||
|
<DialogDescription>Actualiza datos básicos.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Field label="Nombre"><Input value={form.nombre ?? ''} onChange={(e) => setForm({ ...form, nombre: e.target.value })} /></Field>
|
||||||
|
<Field label="Nivel"><Input value={form.nivel ?? ''} onChange={(e) => setForm({ ...form, nivel: e.target.value })} /></Field>
|
||||||
|
<Field label="Duración"><Input value={form.duracion ?? ''} onChange={(e) => setForm({ ...form, duracion: e.target.value })} /></Field>
|
||||||
|
<Field label="Créditos totales"><Input value={String(form.total_creditos ?? '')} onChange={(e) => setForm({ ...form, total_creditos: Number(e.target.value) || null })} /></Field>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-neutral-600">{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Ajustar IA ===== */
|
||||||
|
function AdjustAIButton({ plan }: { plan: PlanFull }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [prompt, setPrompt] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
setLoading(true)
|
||||||
|
await fetch('https://genesis-engine.apps.lci.ulsa.mx/ajustar/plan', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt, plan }),
|
||||||
|
}).catch(() => { })
|
||||||
|
setLoading(false)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setOpen(true)}>
|
||||||
|
<Icons.Sparkles className="w-4 h-4 mr-2" /> Ajustar con IA
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Ajustar con IA</DialogTitle>
|
||||||
|
<DialogDescription>Describe cómo quieres modificar el plan actual.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Ej.: Enfatiza ciberseguridad y proyectos prácticos…" className="min-h-[120px]" />
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={apply} disabled={!prompt.trim() || loading}>
|
||||||
|
{loading ? 'Aplicando…' : 'Aplicar ajuste'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Skeleton ===== */
|
||||||
|
function Pulse({ className = '' }: { className?: string }) {
|
||||||
|
return <div className={`animate-pulse bg-neutral-200 rounded-xl ${className}`} />
|
||||||
|
}
|
||||||
|
function PageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="border rounded-2xl p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Pulse className="w-10 h-10" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Pulse className="h-5 w-64" />
|
||||||
|
<Pulse className="h-3 w-48" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 mt-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => <Pulse key={i} className="h-14" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => <Pulse key={i} className="h-40" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||||
import { supabase } from "@/auth/supabase"
|
import { supabase } from "@/auth/supabase"
|
||||||
import * as Icons from "lucide-react"
|
import * as Icons from "lucide-react"
|
||||||
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
|
|
||||||
type PlanDetail = {
|
type PlanDetail = {
|
||||||
id: string
|
id: string
|
||||||
nombre: string
|
nombre: string
|
||||||
@@ -18,19 +20,16 @@ type PlanDetail = {
|
|||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/planes/$planId/modal")({
|
export const Route = createFileRoute('/_authenticated/plan/$planId/modal')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
loader: async ({ params }) => {
|
loader: async ({ params }) => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("plan_estudios")
|
.from('plan_estudios')
|
||||||
.select(`
|
.select(`
|
||||||
id, nombre, nivel, duracion, total_creditos, estado,
|
id, nombre, nivel, duracion, total_creditos, estado,
|
||||||
carreras (
|
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
|
||||||
id, nombre,
|
|
||||||
facultades:facultades ( id, nombre, color, icon )
|
|
||||||
)
|
|
||||||
`)
|
`)
|
||||||
.eq("id", params.planId)
|
.eq('id', params.planId)
|
||||||
.single()
|
.single()
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
return data
|
return data
|
||||||
@@ -45,14 +44,22 @@ function gradientFrom(color?: string | null) {
|
|||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const plan = Route.useLoaderData() as PlanDetail
|
const plan = Route.useLoaderData() as PlanDetail
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const fac = plan.carreras?.facultades
|
const fac = plan.carreras?.facultades
|
||||||
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
|
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
|
||||||
const headerBg = { background: gradientFrom(fac?.color) }
|
const headerBg = { background: gradientFrom(fac?.color) }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={() => router.navigate({ to: "/planes", replace: true })}>
|
<Dialog
|
||||||
<DialogContent className="max-w-2xl p-0 overflow-hidden">
|
open
|
||||||
|
onOpenChange={() =>
|
||||||
|
router.navigate({
|
||||||
|
to: '/plan/$planId',
|
||||||
|
params: { planId: plan.id },
|
||||||
|
replace: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-2xl p-0 overflow-hidden" aria-describedby="">
|
||||||
{/* Header con color/ícono de facultad */}
|
{/* Header con color/ícono de facultad */}
|
||||||
<div className="p-6 text-white" style={headerBg}>
|
<div className="p-6 text-white" style={headerBg}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import * as Icons from "lucide-react"
|
import * as Icons from "lucide-react"
|
||||||
import { Plus, RefreshCcw, Building2, ScrollText, BookOpen } from "lucide-react"
|
import { Plus, RefreshCcw, Building2 } from "lucide-react"
|
||||||
|
|
||||||
export type PlanDeEstudios = {
|
export type PlanDeEstudios = {
|
||||||
id: string; nombre: string; nivel: string | null; duracion: string | null;
|
id: string; nombre: string; nivel: string | null; duracion: string | null;
|
||||||
@@ -48,15 +48,32 @@ function hexToRgb(hex?: string | null): [number, number, number] {
|
|||||||
const n = parseInt(v, 16)
|
const n = parseInt(v, 16)
|
||||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||||||
}
|
}
|
||||||
function softCardStyles(color?: string | null) {
|
/* ---------- helpers ---------- */
|
||||||
|
function chipTint(color?: string | null) {
|
||||||
const [r, g, b] = hexToRgb(color)
|
const [r, g, b] = hexToRgb(color)
|
||||||
return {
|
return {
|
||||||
// borde + velo muy sutil del color de la facultad
|
borderColor: `rgba(${r},${g},${b},.30)`,
|
||||||
borderColor: `rgba(${r},${g},${b},.28)`,
|
background: `rgba(${r},${g},${b},.10)`,
|
||||||
background: `linear-gradient(180deg, rgba(${r},${g},${b},.15), rgba(${r},${g},${b},.02))`,
|
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InfoChip({
|
||||||
|
icon, label, tint,
|
||||||
|
}: { icon: React.ReactNode; label: string; tint?: string | null }) {
|
||||||
|
const style = tint ? chipTint(tint) : undefined
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title={label}
|
||||||
|
className="inline-flex max-w-full items-center gap-1 rounded-lg border px-2.5 py-1 text-xs leading-none bg-white/70 text-neutral-800"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const auth = useSupabaseAuth()
|
const auth = useSupabaseAuth()
|
||||||
const [q, setQ] = useState("")
|
const [q, setQ] = useState("")
|
||||||
@@ -99,13 +116,14 @@ function RouteComponent() {
|
|||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{filtered?.map((p) => {
|
{filtered?.map((p) => {
|
||||||
const fac = p.carreras?.facultades
|
const fac = p.carreras?.facultades
|
||||||
const styles = softCardStyles(fac?.color)
|
const styles = chipTint(fac?.color)
|
||||||
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Building2
|
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Building2
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={p.id}
|
key={p.id}
|
||||||
to="/planes/$planId/modal"
|
to="/plan/$planId/modal"
|
||||||
|
mask={{ to: '/plan/$planId', params: { planId: p.id } }}
|
||||||
className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5"
|
className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||||
params={{ planId: p.id }}
|
params={{ planId: p.id }}
|
||||||
style={styles}
|
style={styles}
|
||||||
@@ -124,23 +142,37 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
{/* Dentro del map de tarjetas, sustituye SOLO el footer inferior */}
|
||||||
{showCarrera && p.carreras?.nombre && (
|
<div className="mt-3 flex items-center gap-2">
|
||||||
<Badge variant="secondary" className="border text-neutral-700 bg-white/70 w-fit">
|
{/* grupo izquierdo: chips (wrap si no caben) */}
|
||||||
<ScrollText className="mr-1 h-3 w-3" /> {p.carreras?.nombre}
|
<div className="min-w-0 flex-1 flex flex-wrap items-center gap-2">
|
||||||
</Badge>
|
{showCarrera && p.carreras?.nombre && (
|
||||||
)}
|
<InfoChip
|
||||||
{showFacultad && fac?.nombre && (
|
icon={<Icons.GraduationCap className="h-3 w-3" />}
|
||||||
<Badge variant="outline" className="bg-white/60 w-fit" style={{ borderColor: styles.borderColor }}>
|
label={p.carreras.nombre}
|
||||||
<BookOpen className="mr-1 h-3 w-3" /> {fac?.nombre}
|
/>
|
||||||
</Badge>
|
)}
|
||||||
)}
|
{showFacultad && fac?.nombre && (
|
||||||
|
<InfoChip
|
||||||
|
icon={<Icons.Building2 className="h-3 w-3" />}
|
||||||
|
label={fac.nombre}
|
||||||
|
tint={fac.color} // tinte sutil por facultad
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* derecha: estado */}
|
||||||
{p.estado && (
|
{p.estado && (
|
||||||
<Badge variant="outline" className="ml-auto bg-white/60" style={{ borderColor: styles.borderColor }}>
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-white/60"
|
||||||
|
style={{ borderColor: (chipTint(fac?.color).borderColor as string) }}
|
||||||
|
>
|
||||||
{p.estado}
|
{p.estado}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/planes/$planId')({
|
|
||||||
component: RouteComponent,
|
|
||||||
})
|
|
||||||
|
|
||||||
function RouteComponent() {
|
|
||||||
return <div>Hello "/_authenticated/planes/$planId"!</div>
|
|
||||||
}
|
|
||||||
351
src/routes/_authenticated/usuarios.tsx
Normal file
351
src/routes/_authenticated/usuarios.tsx
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
// routes/_authenticated/usuarios.tsx
|
||||||
|
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
RefreshCcw, ShieldCheck, ShieldAlert, Pencil, Mail, CheckCircle2, XCircle,
|
||||||
|
Cpu, Building2, ScrollText, GraduationCap, GanttChart
|
||||||
|
} from "lucide-react"
|
||||||
|
import { SupabaseClient } from "@supabase/supabase-js"
|
||||||
|
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
||||||
|
|
||||||
|
/* ---------- Tipos ---------- */
|
||||||
|
type AdminUser = {
|
||||||
|
id: string
|
||||||
|
email: string | null
|
||||||
|
created_at: string
|
||||||
|
last_sign_in_at: string | null
|
||||||
|
user_metadata: any
|
||||||
|
app_metadata: any
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLES = ["lci", "vicerrectoria", "secretario_academico", "jefe_carrera", "planeacion"] as const
|
||||||
|
export type Role = typeof ROLES[number]
|
||||||
|
|
||||||
|
/* ---------- Meta bonita de roles (codificado internamente) ---------- */
|
||||||
|
const ROLE_META: Record<Role, {
|
||||||
|
label: string
|
||||||
|
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
|
||||||
|
className: string
|
||||||
|
}> = {
|
||||||
|
lci: {
|
||||||
|
label: "Laboratorio de Cómputo de Ingeniería",
|
||||||
|
Icon: Cpu,
|
||||||
|
className: "bg-neutral-900 text-white"
|
||||||
|
},
|
||||||
|
vicerrectoria: {
|
||||||
|
label: "Vicerrectoría Académica",
|
||||||
|
Icon: Building2,
|
||||||
|
className: "bg-indigo-600 text-white"
|
||||||
|
},
|
||||||
|
secretario_academico: {
|
||||||
|
label: "Secretario Académico",
|
||||||
|
Icon: ScrollText,
|
||||||
|
className: "bg-emerald-600 text-white"
|
||||||
|
},
|
||||||
|
jefe_carrera: {
|
||||||
|
label: "Jefe de Carrera",
|
||||||
|
Icon: GraduationCap,
|
||||||
|
className: "bg-orange-600 text-white"
|
||||||
|
},
|
||||||
|
planeacion: {
|
||||||
|
label: "Planeación Curricular",
|
||||||
|
Icon: GanttChart,
|
||||||
|
className: "bg-sky-600 text-white"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function RolePill({ role }: { role: Role }) {
|
||||||
|
const meta = ROLE_META[role]
|
||||||
|
if (!meta) return null
|
||||||
|
const { Icon, className, label } = meta
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[10px] ${className}`}>
|
||||||
|
<Icon className="h-3 w-3" /> {label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Página ---------- */
|
||||||
|
export const Route = createFileRoute("/_authenticated/usuarios")({
|
||||||
|
component: RouteComponent,
|
||||||
|
loader: async () => {
|
||||||
|
// ⚠️ Asumes service role en cliente (mejor mover a Edge Function en producción)
|
||||||
|
const supabsaeAdmin = new SupabaseClient(
|
||||||
|
import.meta.env.VITE_SUPABASE_URL,
|
||||||
|
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY,
|
||||||
|
)
|
||||||
|
const { data: data_users } = await supabsaeAdmin.auth.admin.listUsers()
|
||||||
|
return { data: data_users.users as AdminUser[] }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const auth = useSupabaseAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
const { data } = Route.useLoaderData()
|
||||||
|
const [q, setQ] = useState("")
|
||||||
|
const [editing, setEditing] = useState<AdminUser | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
// state del formulario
|
||||||
|
const [form, setForm] = useState<{
|
||||||
|
role?: Role;
|
||||||
|
claims_admin?: boolean;
|
||||||
|
nombre?: string; apellidos?: string; title?: string; clave?: string; avatar?: string;
|
||||||
|
facultad_id?: string | null;
|
||||||
|
carrera_id?: string | null;
|
||||||
|
}>({})
|
||||||
|
|
||||||
|
if (!auth.claims?.claims_admin) {
|
||||||
|
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const t = q.trim().toLowerCase()
|
||||||
|
if (!t) return data
|
||||||
|
return data.filter(u => {
|
||||||
|
const role: Role | undefined = u.app_metadata?.role
|
||||||
|
const label = role ? ROLE_META[role]?.label : ""
|
||||||
|
return [u.email, u.user_metadata?.nombre, u.user_metadata?.apellidos, label]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some(v => String(v).toLowerCase().includes(t))
|
||||||
|
})
|
||||||
|
}, [q, data])
|
||||||
|
|
||||||
|
function openEdit(u: AdminUser) {
|
||||||
|
setEditing(u)
|
||||||
|
setForm({
|
||||||
|
role: u.app_metadata?.role,
|
||||||
|
claims_admin: !!u.app_metadata?.claims_admin,
|
||||||
|
nombre: u.user_metadata?.nombre ?? "",
|
||||||
|
apellidos: u.user_metadata?.apellidos ?? "",
|
||||||
|
title: u.user_metadata?.title ?? "",
|
||||||
|
clave: u.user_metadata?.clave ?? "",
|
||||||
|
avatar: u.user_metadata?.avatar ?? "",
|
||||||
|
facultad_id: u.app_metadata?.facultad_id ?? null,
|
||||||
|
carrera_id: u.app_metadata?.carrera_id ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!editing) return
|
||||||
|
setSaving(true)
|
||||||
|
const { error } = await supabase.functions.invoke("admin-update-user", {
|
||||||
|
body: {
|
||||||
|
id: editing.id,
|
||||||
|
app_metadata: {
|
||||||
|
role: form.role,
|
||||||
|
claims_admin: form.claims_admin,
|
||||||
|
facultad_id: form.facultad_id ?? null,
|
||||||
|
carrera_id: form.carrera_id ?? null,
|
||||||
|
},
|
||||||
|
user_metadata: {
|
||||||
|
nombre: form.nombre, apellidos: form.apellidos, title: form.title,
|
||||||
|
clave: form.clave, avatar: form.avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setSaving(false)
|
||||||
|
if (error) { console.error(error); return }
|
||||||
|
router.invalidate(); setEditing(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<CardTitle>Usuarios</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input placeholder="Buscar por nombre, email o rol…" value={q} onChange={e => setQ(e.target.value)} className="w-72" />
|
||||||
|
<Button variant="outline" size="icon" title="Recargar" onClick={() => router.invalidate()}>
|
||||||
|
<RefreshCcw className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{filtered.map(u => {
|
||||||
|
const m = u.user_metadata || {}
|
||||||
|
const a = u.app_metadata || {}
|
||||||
|
const roleCode: Role | undefined = a.role
|
||||||
|
return (
|
||||||
|
<div key={u.id} className="flex items-center gap-4 rounded-2xl border p-3">
|
||||||
|
<img
|
||||||
|
src={m.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(m.nombre || u.email || 'U')}`}
|
||||||
|
alt="" className="h-10 w-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}
|
||||||
|
</div>
|
||||||
|
{roleCode && <RolePill role={roleCode} />}
|
||||||
|
{a.claims_admin ? (
|
||||||
|
<Badge className="gap-1" variant="secondary"><ShieldCheck className="w-3 h-3" /> Administrador</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="gap-1" variant="outline"><ShieldAlert className="w-3 h-3" /> Usuario</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-600 flex flex-wrap items-center gap-3">
|
||||||
|
<span className="inline-flex items-center gap-1"><Mail className="w-3 h-3" /> {u.email ?? "—"}</span>
|
||||||
|
<span>Creado: {new Date(u.created_at).toLocaleDateString()}</span>
|
||||||
|
<span>Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}</span>
|
||||||
|
{m.email_verified ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-emerald-600"><CheckCircle2 className="w-3 h-3" /> Verificado</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-neutral-500"><XCircle className="w-3 h-3" /> No verificado</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}>
|
||||||
|
<Pencil className="w-4 h-4 mr-1" /> Editar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{!filtered.length && <div className="text-sm text-neutral-500 text-center py-10">Sin usuarios</div>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Dialog de edición */}
|
||||||
|
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
|
||||||
|
<DialogContent className="w-[min(92vw,720px)] sm:max-w-2xl">
|
||||||
|
<DialogHeader><DialogTitle>Editar usuario</DialogTitle></DialogHeader>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Nombre</Label>
|
||||||
|
<Input value={form.nombre ?? ""} onChange={(e) => setForm(s => ({ ...s, nombre: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Apellidos</Label>
|
||||||
|
<Input value={form.apellidos ?? ""} onChange={(e) => setForm(s => ({ ...s, apellidos: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Título</Label>
|
||||||
|
<Input value={form.title ?? ""} onChange={(e) => setForm(s => ({ ...s, title: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Clave</Label>
|
||||||
|
<Input value={form.clave ?? ""} onChange={(e) => setForm(s => ({ ...s, clave: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Avatar (URL)</Label>
|
||||||
|
<Input value={form.avatar ?? ""} onChange={(e) => setForm(s => ({ ...s, avatar: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Rol</Label>
|
||||||
|
<Select
|
||||||
|
value={form.role ?? ""}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setForm(s => {
|
||||||
|
const role = v as Role
|
||||||
|
// limpiar/aplicar campos según rol
|
||||||
|
if (role === "jefe_carrera") {
|
||||||
|
return { ...s, role, /* conserva si ya venían */ facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
|
||||||
|
}
|
||||||
|
if (role === "secretario_academico") {
|
||||||
|
return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
|
||||||
|
}
|
||||||
|
return { ...s, role, facultad_id: null, carrera_id: null }
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Hace que el popper herede ancho del trigger y no se salga */}
|
||||||
|
<SelectTrigger className="w-full sm:w-[420px]">
|
||||||
|
<SelectValue placeholder="Selecciona un rol" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
position="popper"
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className="min-w-fit max-w-full max-h-72 overflow-auto"
|
||||||
|
>
|
||||||
|
{ROLES.map(code => {
|
||||||
|
const meta = ROLE_META[code]; const Icon = meta.Icon
|
||||||
|
return (
|
||||||
|
<SelectItem
|
||||||
|
key={code}
|
||||||
|
value={code}
|
||||||
|
className="whitespace-normal text-sm leading-snug py-2"
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Solo SECRETARIO: facultad */}
|
||||||
|
{/* SECRETARIO: solo facultad */}
|
||||||
|
{form.role === "secretario_academico" && (
|
||||||
|
<div className="md:col-span-2 space-y-1">
|
||||||
|
<Label>Facultad</Label>
|
||||||
|
<FacultadCombobox
|
||||||
|
value={form.facultad_id ?? ""}
|
||||||
|
onChange={(id) => setForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-neutral-500">Este rol solo requiere <strong>Facultad</strong>.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* JEFE DE CARRERA: ambos */}
|
||||||
|
{form.role === "jefe_carrera" && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Facultad</Label>
|
||||||
|
<FacultadCombobox
|
||||||
|
value={form.facultad_id ?? ""}
|
||||||
|
onChange={(id) => setForm(s => ({ ...s, facultad_id: id, carrera_id: "" }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Carrera</Label>
|
||||||
|
<CarreraCombobox
|
||||||
|
facultadId={form.facultad_id ?? ""}
|
||||||
|
value={form.carrera_id ?? ""}
|
||||||
|
onChange={(id) => setForm(s => ({ ...s, carrera_id: id }))}
|
||||||
|
disabled={!form.facultad_id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Permisos</Label>
|
||||||
|
<Select value={String(!!form.claims_admin)} onValueChange={(v) => setForm(s => ({ ...s, claims_admin: v === 'true' }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">Administrador</SelectItem>
|
||||||
|
<SelectItem value="false">Usuario</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
||||||
|
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : "Guardar"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user