feat: add EditAsignaturaButton and EditBibliografiaButton components for managing asignaturas
- Introduced EditAsignaturaButton for editing asignatura details with a dialog interface. - Added EditBibliografiaButton for managing bibliographic references with various utility actions (trim, dedupe, sort, import, export). - Created reusable Field, Section, and Stat components for better UI structure. - Implemented typeStyle utility for styling based on asignatura type. - Integrated new components into the existing asignatura route. - Updated package.json to include new dependencies for alert dialogs and tooltips. - Defined Asignatura type in a new types file for better type safety.
This commit is contained in:
9
bun.lock
9
bun.lock
@@ -5,6 +5,7 @@
|
|||||||
"name": "tanstack-router",
|
"name": "tanstack-router",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@supabase/supabase-js": "^2.55.0",
|
"@supabase/supabase-js": "^2.55.0",
|
||||||
"@tailwindcss/vite": "^4.1.12",
|
"@tailwindcss/vite": "^4.1.12",
|
||||||
"@tanstack/react-devtools": "^0.2.2",
|
"@tanstack/react-devtools": "^0.2.2",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.12",
|
"tailwindcss": "^4.1.12",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.0.17",
|
"zod": "^4.0.17",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -210,6 +213,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@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-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "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-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="],
|
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@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-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "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-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@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-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.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-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
|
||||||
|
|
||||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@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-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@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-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||||
|
|
||||||
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@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-is-hydrated": "0.1.0", "@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-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
|
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@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-is-hydrated": "0.1.0", "@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-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
|
||||||
@@ -266,6 +271,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@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-use-controllable-state": "1.2.2" }, "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-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@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-use-controllable-state": "1.2.2" }, "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-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "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-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", "@radix-ui/react-visually-hidden": "1.2.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-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
|
||||||
|
|
||||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||||
|
|
||||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@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-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@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-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||||
@@ -850,6 +857,8 @@
|
|||||||
|
|
||||||
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
|
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
|
||||||
|
|
||||||
|
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
|
||||||
|
|
||||||
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||||
|
|
||||||
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
|
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@supabase/supabase-js": "^2.55.0",
|
"@supabase/supabase-js": "^2.55.0",
|
||||||
"@tailwindcss/vite": "^4.1.12",
|
"@tailwindcss/vite": "^4.1.12",
|
||||||
"@tanstack/react-devtools": "^0.2.2",
|
"@tanstack/react-devtools": "^0.2.2",
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.12",
|
"tailwindcss": "^4.1.12",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.0.17"
|
"zod": "^4.0.17"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
107
src/components/asignaturas/EditAsignaturaButton.tsx
Normal file
107
src/components/asignaturas/EditAsignaturaButton.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { supabase } from "@/auth/supabase"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
||||||
|
import { typeStyle } from "./typeStyle"
|
||||||
|
import type { Asignatura } from "@/types/asignatura"
|
||||||
|
import { Field } from "./Field"
|
||||||
|
|
||||||
|
export function EditAsignaturaButton({ asignatura, onUpdate }: {
|
||||||
|
asignatura: Asignatura; onUpdate: (a: Asignatura) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [form, setForm] = useState<Partial<Asignatura>>({})
|
||||||
|
|
||||||
|
const openAndFill = () => { setForm(asignatura); setOpen(true) }
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true)
|
||||||
|
const payload = {
|
||||||
|
nombre: form.nombre ?? asignatura.nombre,
|
||||||
|
clave: form.clave ?? asignatura.clave,
|
||||||
|
tipo: form.tipo ?? asignatura.tipo,
|
||||||
|
semestre: form.semestre ?? asignatura.semestre,
|
||||||
|
creditos: form.creditos ?? asignatura.creditos,
|
||||||
|
horas_teoricas: form.horas_teoricas ?? asignatura.horas_teoricas,
|
||||||
|
horas_practicas: form.horas_practicas ?? asignatura.horas_practicas,
|
||||||
|
}
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("asignaturas")
|
||||||
|
.update(payload)
|
||||||
|
.eq("id", asignatura.id)
|
||||||
|
.select()
|
||||||
|
.maybeSingle()
|
||||||
|
setSaving(false)
|
||||||
|
if (!error && data) { onUpdate(data as Asignatura); setOpen(false) }
|
||||||
|
else alert(error?.message ?? "Error al guardar")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" size="sm" onClick={openAndFill}>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Editar asignatura</DialogTitle>
|
||||||
|
<DialogDescription>Actualiza campos básicos.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Nombre">
|
||||||
|
<Input value={form.nombre ?? ""} onChange={e => setForm((s: Partial<Asignatura>) => ({ ...s, nombre: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Clave">
|
||||||
|
<Input value={form.clave ?? ""} onChange={e => setForm((s: Partial<Asignatura>) => ({ ...s, clave: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Tipo">
|
||||||
|
<Select value={form.tipo ?? ""} onValueChange={v => setForm((s: Partial<Asignatura>) => ({ ...s, tipo: v }))}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Selecciona tipo…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Obligatoria">
|
||||||
|
<span className={typeStyle("Obligatoria").chip}>Obligatoria</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Optativa">
|
||||||
|
<span className={typeStyle("Optativa").chip}>Optativa</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Taller">
|
||||||
|
<span className={typeStyle("Taller").chip}>Taller</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Laboratorio">
|
||||||
|
<span className={typeStyle("Laboratorio").chip}>Laboratorio</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Otro">
|
||||||
|
<span className={typeStyle("Otro").chip}>Otro</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Semestre">
|
||||||
|
<Input value={String(form.semestre ?? "")} onChange={e => setForm((s: Partial<Asignatura>) => ({ ...s, semestre: Number(e.target.value) || null }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Créditos">
|
||||||
|
<Input value={String(form.creditos ?? "")} onChange={e => setForm((s: Partial<Asignatura>) => ({ ...s, creditos: Number(e.target.value) || null }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Horas teóricas">
|
||||||
|
<Input value={String(form.horas_teoricas ?? "")} onChange={e => setForm((s: Partial<Asignatura>) => ({ ...s, horas_teoricas: Number(e.target.value) || null }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Horas prácticas">
|
||||||
|
<Input value={String(form.horas_practicas ?? "")} onChange={e => setForm((s: Partial<Asignatura>) => ({ ...s, horas_practicas: Number(e.target.value) || null }))} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : "Guardar"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
228
src/components/asignaturas/EditBibliografíaButton.tsx
Normal file
228
src/components/asignaturas/EditBibliografíaButton.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { supabase } from "@/auth/supabase"
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../ui/dialog"
|
||||||
|
import { Textarea } from "../ui/textarea"
|
||||||
|
import { Separator } from "../ui/separator"
|
||||||
|
import { ScrollArea } from "../ui/scroll-area"
|
||||||
|
import { Badge } from "../ui/badge"
|
||||||
|
import * as Icons from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EditBibliografiaButton v3 (simple y aireado)
|
||||||
|
* - Layout limpio: una sola columna + barra mínima.
|
||||||
|
* - Acciones esenciales: Recortar, Dedupe, A–Z, Importar, Copiar.
|
||||||
|
* - Toasts con sonner: toast.success / toast.error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function EditBibliografiaButton({
|
||||||
|
asignaturaId,
|
||||||
|
value,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
asignaturaId: string
|
||||||
|
value: string[]
|
||||||
|
onSaved: (refs: string[]) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [text, setText] = useState("")
|
||||||
|
|
||||||
|
const initialTextRef = useRef("")
|
||||||
|
const lines = useMemo(() => parseLines(text), [text])
|
||||||
|
const dirty = useMemo(() => initialTextRef.current !== text, [text])
|
||||||
|
|
||||||
|
function openEditor() {
|
||||||
|
const start = (value ?? []).join("\n")
|
||||||
|
setText(start)
|
||||||
|
initialTextRef.current = start
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
try {
|
||||||
|
setSaving(true)
|
||||||
|
const refs = parseLines(text)
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("asignaturas")
|
||||||
|
.update({ bibliografia: refs })
|
||||||
|
.eq("id", asignaturaId)
|
||||||
|
.select()
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
onSaved((data as any)?.bibliografia ?? refs)
|
||||||
|
initialTextRef.current = refs.join("\n")
|
||||||
|
toast.success(`${refs.length} referencia(s) guardada(s).`)
|
||||||
|
setOpen(false)
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message ?? "No se pudo guardar")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acciones
|
||||||
|
function actionTrim() {
|
||||||
|
const next = parseLines(text).map((s) => s.replace(/\s+/g, " ").trim())
|
||||||
|
setText(next.join("\n"))
|
||||||
|
}
|
||||||
|
function actionDedupe() {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const next: string[] = []
|
||||||
|
for (const l of parseLines(text)) {
|
||||||
|
const k = l.toLowerCase()
|
||||||
|
if (!seen.has(k)) { seen.add(k); next.push(l) }
|
||||||
|
}
|
||||||
|
setText(next.join("\n"))
|
||||||
|
}
|
||||||
|
function actionSort() {
|
||||||
|
const next = [...parseLines(text)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
|
||||||
|
setText(next.join("\n"))
|
||||||
|
}
|
||||||
|
async function actionImportClipboard() {
|
||||||
|
try {
|
||||||
|
const clip = await navigator.clipboard.readText()
|
||||||
|
if (!clip) { toast("Portapapeles vacío"); return }
|
||||||
|
const next = [...parseLines(text), ...parseLines(clip)]
|
||||||
|
setText(next.join("\n"))
|
||||||
|
toast.success("Texto importado")
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message ?? "No se pudo leer el portapapeles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function actionExportClipboard() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(parseLines(text).join("\n"))
|
||||||
|
toast.success("Copiado al portapapeles")
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message ?? "No se pudo copiar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atajo guardar
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (!open) return
|
||||||
|
if (e.key === "s" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!saving && dirty) void save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey)
|
||||||
|
return () => window.removeEventListener("keydown", onKey)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, saving, dirty, text])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" onClick={openEditor} className="gap-2">
|
||||||
|
<Icons.Library className="h-4 w-4" /> Editar bibliografía
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-xl p-0 overflow-hidden">
|
||||||
|
<DialogHeader className="px-6 pt-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="tracking-tight">Editar bibliografía</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Una referencia por línea. Guarda con <kbd className="border px-1 rounded">Ctrl/⌘+S</kbd>.
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="rounded-full">{lines.length}</Badge>
|
||||||
|
{dirty ? <Badge variant="destructive" className="rounded-full">Sin guardar</Badge> : <Badge className="rounded-full">OK</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Barra de acciones (compacta) */}
|
||||||
|
<div className="px-6 pb-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" variant="outline" className="gap-2" onClick={actionTrim}>
|
||||||
|
<Icons.Scissors className="h-4 w-4" /> Recortar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" className="gap-2" onClick={actionDedupe}>
|
||||||
|
<Icons.CopyMinus className="h-4 w-4" /> Quitar duplicados
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" className="gap-2" onClick={actionSort}>
|
||||||
|
<Icons.SortAsc className="h-4 w-4" /> A–Z
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" className="gap-2" onClick={actionImportClipboard}>
|
||||||
|
<Icons.ClipboardPaste className="h-4 w-4" /> Importar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" className="gap-2" onClick={actionExportClipboard}>
|
||||||
|
<Icons.ClipboardCopy className="h-4 w-4" /> Copiar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Editor */}
|
||||||
|
<div className="p-6">
|
||||||
|
<Textarea
|
||||||
|
className="min-h-[320px] resize-y"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder={`Autor, Título, Editorial, Año\nDOI/URL\n…`}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Consejo: pega desde tu gestor y usa los botones para limpiar.</span>
|
||||||
|
<span>{new Intl.NumberFormat().format(text.length)} caracteres</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Previsualización (compacta) */}
|
||||||
|
{lines.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<div className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||||
|
<Icons.Eye className="h-4 w-4" /> Vista previa
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-40 rounded-md border">
|
||||||
|
<ol className="list-decimal pl-6 pr-3 py-3 space-y-1 text-sm">
|
||||||
|
{lines.map((l, i) => (
|
||||||
|
<li key={i} className="text-muted-foreground">{l}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<DialogFooter className="px-6 py-4">
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)} className="gap-2">
|
||||||
|
<Icons.X className="h-4 w-4" /> Cerrar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={save} disabled={saving || !dirty} className="gap-2">
|
||||||
|
{saving ? <Icons.Loader2 className="h-4 w-4 animate-spin" /> : <Icons.Save className="h-4 w-4" />}
|
||||||
|
{saving ? "Guardando…" : "Guardar"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Utilidades ---------- */
|
||||||
|
function parseLines(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
10
src/components/asignaturas/Field.tsx
Normal file
10
src/components/asignaturas/Field.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/components/asignaturas/Section.tsx
Normal file
13
src/components/asignaturas/Section.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
export function Section({ id, title, icon: Icon, children }: { id: string; title: string; icon: any; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section id={id} className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 scroll-mt-24">
|
||||||
|
<header className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="h-8 w-8 rounded-lg grid place-items-center border bg-white/80"><Icon className="h-4 w-4" /></div>
|
||||||
|
<h3 className="text-sm font-semibold">{title}</h3>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/components/asignaturas/Stat.tsx
Normal file
13
src/components/asignaturas/Stat.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function Stat({ icon: Icon, label, value }: { icon: any; label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 flex items-center gap-3">
|
||||||
|
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-neutral-500">{label}</div>
|
||||||
|
<div className="text-lg font-semibold tabular-nums">{value}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
src/components/asignaturas/typeStyle.ts
Normal file
8
src/components/asignaturas/typeStyle.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export function typeStyle(tipo?: string | null) {
|
||||||
|
const t = (tipo ?? "").toLowerCase()
|
||||||
|
if (t.includes("oblig")) return { chip: "bg-emerald-50 text-emerald-700 border-emerald-200", halo: "from-emerald-100/60" }
|
||||||
|
if (t.includes("opt")) return { chip: "bg-amber-50 text-amber-800 border-amber-200", halo: "from-amber-100/60" }
|
||||||
|
if (t.includes("taller")) return { chip: "bg-indigo-50 text-indigo-700 border-indigo-200", halo: "from-indigo-100/60" }
|
||||||
|
if (t.includes("lab")) return { chip: "bg-sky-50 text-sky-700 border-sky-200", halo: "from-sky-100/60" }
|
||||||
|
return { chip: "bg-neutral-100 text-neutral-700 border-neutral-200", halo: "from-primary/10" }
|
||||||
|
}
|
||||||
155
src/components/ui/alert-dialog.tsx
Normal file
155
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
135
src/components/ui/drawer.tsx
Normal file
135
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||||
|
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||||
|
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||||
|
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
59
src/components/ui/tooltip.tsx
Normal file
59
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@@ -25,6 +25,10 @@ import { Textarea } from "@/components/ui/textarea"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { typeStyle } from "@/components/asignaturas/typeStyle"
|
||||||
|
import { Stat } from "@/components/asignaturas/Stat"
|
||||||
|
import { Section } from "@/components/asignaturas/Section"
|
||||||
|
import { EditBibliografiaButton } from "@/components/asignaturas/EditBibliografíaButton"
|
||||||
|
|
||||||
/* ================== Tipos ================== */
|
/* ================== Tipos ================== */
|
||||||
type Asignatura = {
|
type Asignatura = {
|
||||||
@@ -57,42 +61,7 @@ export const Route = createFileRoute("/_authenticated/asignatura/$asignaturaId")
|
|||||||
})
|
})
|
||||||
|
|
||||||
/* ================== Helpers UI ================== */
|
/* ================== Helpers UI ================== */
|
||||||
function typeStyle(tipo?: string | null) {
|
|
||||||
const t = (tipo ?? "").toLowerCase()
|
|
||||||
if (t.includes("oblig")) return { chip: "bg-emerald-50 text-emerald-700 border-emerald-200", halo: "from-emerald-100/60" }
|
|
||||||
if (t.includes("opt")) return { chip: "bg-amber-50 text-amber-800 border-amber-200", halo: "from-amber-100/60" }
|
|
||||||
if (t.includes("taller")) return { chip: "bg-indigo-50 text-indigo-700 border-indigo-200", halo: "from-indigo-100/60" }
|
|
||||||
if (t.includes("lab")) return { chip: "bg-sky-50 text-sky-700 border-sky-200", halo: "from-sky-100/60" }
|
|
||||||
return { chip: "bg-neutral-100 text-neutral-700 border-neutral-200", halo: "from-primary/10" }
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stat({ icon: Icon, label, value }: { icon: any; label: string; value: string | number }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 flex items-center gap-3">
|
|
||||||
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-neutral-500">{label}</div>
|
|
||||||
<div className="text-lg font-semibold tabular-nums">{value}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Section({ id, title, icon: Icon, children }: { id: string; title: string; icon: any; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<section id={id} className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 scroll-mt-24">
|
|
||||||
<header className="flex items-center gap-2 mb-2">
|
|
||||||
<div className="h-8 w-8 rounded-lg grid place-items-center border bg-white/80"><Icon className="h-4 w-4" /></div>
|
|
||||||
<h3 className="text-sm font-semibold">{title}</h3>
|
|
||||||
</header>
|
|
||||||
{children}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================== Página ================== */
|
|
||||||
function Page() {
|
function Page() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { a: aFromLoader, plan } = Route.useLoaderData() as { a: Asignatura; plan: PlanMini | null }
|
const { a: aFromLoader, plan } = Route.useLoaderData() as { a: Asignatura; plan: PlanMini | null }
|
||||||
@@ -256,7 +225,7 @@ function Page() {
|
|||||||
const t = (u.temas || []).find(([k]: any[]) => String(k).toLowerCase() === "titulo")?.[1]
|
const t = (u.temas || []).find(([k]: any[]) => String(k).toLowerCase() === "titulo")?.[1]
|
||||||
if (typeof t === "string" && t.trim()) return t
|
if (typeof t === "string" && t.trim()) return t
|
||||||
return /^\s*\d+/.test(String(u.key))
|
return /^\s*\d+/.test(String(u.key))
|
||||||
? (u.title && u.title !== u.key ? u.title : `Unidad ${u.key && Number(u.key) ? Number(u.key) + 1 : 1}`)
|
? (u.title && u.title !== u.key ? u.title : `Unidad ${u.key && Number(u.key) ? Number(u.key) : 1}`)
|
||||||
: (u.title || String(u.key))
|
: (u.title || String(u.key))
|
||||||
}
|
}
|
||||||
const temasOf = (u: any): string[] => {
|
const temasOf = (u: any): string[] => {
|
||||||
@@ -294,7 +263,7 @@ function Page() {
|
|||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{/^\s*\d+/.test(String(u.key))
|
{/^\s*\d+/.test(String(u.key))
|
||||||
? `Unidad ${u.key && Number(u.key) ? Number(u.key) + 1 : 1}${u.__title ? `: ${u.__title}` : ""}`
|
? `Unidad ${u.key && Number(u.key) ? Number(u.key) : 1}${u.__title ? `: ${u.__title}` : ""}`
|
||||||
: u.__title}
|
: u.__title}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-neutral-500">{u.__temas.length} tema(s)</span>
|
<span className="text-[11px] text-neutral-500">{u.__temas.length} tema(s)</span>
|
||||||
@@ -896,64 +865,3 @@ export function EditContenidosButton({
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function EditBibliografiaButton({
|
|
||||||
asignaturaId,
|
|
||||||
value,
|
|
||||||
onSaved,
|
|
||||||
}: {
|
|
||||||
asignaturaId: string
|
|
||||||
value: string[]
|
|
||||||
onSaved: (refs: string[]) => void
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [text, setText] = useState('')
|
|
||||||
|
|
||||||
function openEditor() {
|
|
||||||
setText((value ?? []).join('\n'))
|
|
||||||
setOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
setSaving(true)
|
|
||||||
const refs = text.split('\n').map(s => s.trim()).filter(Boolean)
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('asignaturas')
|
|
||||||
.update({ bibliografia: refs })
|
|
||||||
.eq('id', asignaturaId)
|
|
||||||
.select()
|
|
||||||
.maybeSingle()
|
|
||||||
setSaving(false)
|
|
||||||
if (error) { alert(error.message || 'No se pudo guardar'); return }
|
|
||||||
onSaved((data as any)?.bibliografia ?? refs)
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button size="sm" variant="outline" onClick={openEditor}>
|
|
||||||
<Icons.Pencil className="h-4 w-4 mr-2" /> Editar bibliografía
|
|
||||||
</Button>
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="font-mono" >Editar bibliografía</DialogTitle>
|
|
||||||
<DialogDescription>Escribe una referencia por línea.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Textarea
|
|
||||||
className="min-h-[260px]"
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
placeholder={`Autor, Título, Editorial, Año\nDOI/URL\n…`}
|
|
||||||
/>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
|
||||||
<Button onClick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
15
src/types/asignatura.ts
Normal file
15
src/types/asignatura.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export type Asignatura = {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
clave: string | null
|
||||||
|
tipo: string | null
|
||||||
|
semestre: number | null
|
||||||
|
creditos: number | null
|
||||||
|
horas_teoricas: number | null
|
||||||
|
horas_practicas: number | null
|
||||||
|
objetivos: string | null
|
||||||
|
contenidos: Record<string, Record<string, string>> | null
|
||||||
|
bibliografia: string[] | null
|
||||||
|
criterios_evaluacion: string | null
|
||||||
|
plan_id: string | null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user