feat: Implement faculty management routes and UI components

- Added a new route for managing faculties with a grid display of faculties.
- Created a detailed view for each faculty including metrics and recent activities.
- Introduced a new loader for fetching faculty data and associated plans and subjects.
- Enhanced the existing plans route to include a modal for plan details.
- Updated the login and index pages with improved UI and styling.
- Integrated a progress ring component to visualize the quality of plans.
- Applied a new font style across the application for consistency.
This commit is contained in:
2025-08-20 19:09:31 -06:00
parent b33a016ee2
commit 51faa98022
17 changed files with 1279 additions and 108 deletions

View File

@@ -11,6 +11,7 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@supabase/supabase-js": "^2.55.0",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-devtools": "^0.2.2",
@@ -22,6 +23,7 @@
"lucide-react": "^0.540.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.1.2",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.12",
"zod": "^4.0.17",
@@ -238,6 +240,8 @@
"@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-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-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=="],
@@ -256,6 +260,8 @@
"@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=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.46.3", "", { "os": "android", "cpu": "arm" }, "sha512-UmTdvXnLlqQNOCJnyksjPs1G4GqXNGW1LrzCe8+8QoaLhhDeTXYBgJ3k6x61WIhlHX2U+VzEJ55TtIjR/HTySA=="],
@@ -306,6 +312,10 @@
"@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@supabase/auth-js": ["@supabase/auth-js@2.71.1", "", { "dependencies": { "@supabase/node-fetch": "^2.6.14" } }, "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q=="],
"@supabase/functions-js": ["@supabase/functions-js@2.4.5", "", { "dependencies": { "@supabase/node-fetch": "^2.6.14" } }, "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ=="],
@@ -396,6 +406,24 @@
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -408,6 +436,8 @@
"@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -478,12 +508,36 @@
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
@@ -504,6 +558,8 @@
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="],
"esbuild": ["esbuild@0.25.9", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.9", "@esbuild/android-arm": "0.25.9", "@esbuild/android-arm64": "0.25.9", "@esbuild/android-x64": "0.25.9", "@esbuild/darwin-arm64": "0.25.9", "@esbuild/darwin-x64": "0.25.9", "@esbuild/freebsd-arm64": "0.25.9", "@esbuild/freebsd-x64": "0.25.9", "@esbuild/linux-arm": "0.25.9", "@esbuild/linux-arm64": "0.25.9", "@esbuild/linux-ia32": "0.25.9", "@esbuild/linux-loong64": "0.25.9", "@esbuild/linux-mips64el": "0.25.9", "@esbuild/linux-ppc64": "0.25.9", "@esbuild/linux-riscv64": "0.25.9", "@esbuild/linux-s390x": "0.25.9", "@esbuild/linux-x64": "0.25.9", "@esbuild/netbsd-arm64": "0.25.9", "@esbuild/netbsd-x64": "0.25.9", "@esbuild/openbsd-arm64": "0.25.9", "@esbuild/openbsd-x64": "0.25.9", "@esbuild/openharmony-arm64": "0.25.9", "@esbuild/sunos-x64": "0.25.9", "@esbuild/win32-arm64": "0.25.9", "@esbuild/win32-ia32": "0.25.9", "@esbuild/win32-x64": "0.25.9" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -512,6 +568,8 @@
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -540,6 +598,10 @@
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"immer": ["immer@10.1.1", "", {}, "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -634,6 +696,8 @@
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
@@ -646,6 +710,14 @@
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
"recharts": ["recharts@3.1.2", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-vhNbYwaxNbk/IATK0Ki29k3qvTkGqwvCgyQAQ9MavvvBwjvKnMTswdbklJpcOAoMPN/qxF3Lyqob0zO+ZXkZ4g=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rollup": ["rollup@4.46.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.46.3", "@rollup/rollup-android-arm64": "4.46.3", "@rollup/rollup-darwin-arm64": "4.46.3", "@rollup/rollup-darwin-x64": "4.46.3", "@rollup/rollup-freebsd-arm64": "4.46.3", "@rollup/rollup-freebsd-x64": "4.46.3", "@rollup/rollup-linux-arm-gnueabihf": "4.46.3", "@rollup/rollup-linux-arm-musleabihf": "4.46.3", "@rollup/rollup-linux-arm64-gnu": "4.46.3", "@rollup/rollup-linux-arm64-musl": "4.46.3", "@rollup/rollup-linux-loongarch64-gnu": "4.46.3", "@rollup/rollup-linux-ppc64-gnu": "4.46.3", "@rollup/rollup-linux-riscv64-gnu": "4.46.3", "@rollup/rollup-linux-riscv64-musl": "4.46.3", "@rollup/rollup-linux-s390x-gnu": "4.46.3", "@rollup/rollup-linux-x64-gnu": "4.46.3", "@rollup/rollup-linux-x64-musl": "4.46.3", "@rollup/rollup-win32-arm64-msvc": "4.46.3", "@rollup/rollup-win32-ia32-msvc": "4.46.3", "@rollup/rollup-win32-x64-msvc": "4.46.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RZn2XTjXb8t5g13f5YclGoilU/kwT696DIkY3sywjdZidNSi3+vseaQov7D7BZXVJCPv3pDWUN69C78GGbXsKw=="],
@@ -734,6 +806,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=="],
"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-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],

View File

@@ -17,6 +17,7 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@supabase/supabase-js": "^2.55.0",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-devtools": "^0.2.2",
@@ -28,6 +29,7 @@
"lucide-react": "^0.540.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.1.2",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.12",
"zod": "^4.0.17"

View File

@@ -8,12 +8,25 @@ export const supabase = createClient(
export interface SupabaseAuthState {
isAuthenticated: boolean
user: any
user: User | null
claims: UserClaims | null
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
isLoading: boolean
}
type UserClaims = {
claims_admin: boolean,
clave: string,
nombre: string,
apellidos: string,
title: string,
avatar: string | null,
carrera_id?: string,
facultad_id?: string,
role: 'lci' | 'vicerrectoria' | 'secretario_academico' | 'jefe_carrera' | 'planeacion',
}
const SupabaseAuthContext = createContext<SupabaseAuthState | undefined>(
undefined,
)
@@ -24,6 +37,7 @@ export function SupabaseAuthProvider({
children: React.ReactNode
}) {
const [user, setUser] = useState<User | null>(null)
const [claims, setClaims] = useState<UserClaims | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
@@ -31,6 +45,10 @@ export function SupabaseAuthProvider({
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null)
setClaims({
...(session?.user?.app_metadata as Partial<UserClaims> ?? {}),
...(session?.user?.user_metadata as Partial<UserClaims> ?? {}),
} as UserClaims | null)
setIsAuthenticated(!!session?.user)
setIsLoading(false)
})
@@ -40,6 +58,10 @@ export function SupabaseAuthProvider({
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null)
setClaims({
...(session?.user?.app_metadata as Partial<UserClaims> ?? {}),
...(session?.user?.user_metadata as Partial<UserClaims> ?? {}),
} as UserClaims | null)
setIsAuthenticated(!!session?.user)
setIsLoading(false)
})
@@ -67,6 +89,7 @@ export function SupabaseAuthProvider({
value={{
isAuthenticated,
user,
claims,
login,
logout,
isLoading,

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="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 DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="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}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder: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 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -13,6 +13,10 @@ import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthenticatedPlanesRouteImport } from './routes/_authenticated/planes'
import { Route as AuthenticatedFacultadesRouteImport } from './routes/_authenticated/facultades'
import { Route as AuthenticatedPlanesPlanIdRouteImport } from './routes/_authenticated/planes/$planId'
import { Route as AuthenticatedFacultadFacultadIdRouteImport } from './routes/_authenticated/facultad/$facultadId'
import { Route as AuthenticatedPlanesPlanIdModalRouteImport } from './routes/_authenticated/planes/$planId/modal'
const LoginRoute = LoginRouteImport.update({
id: '/login',
@@ -33,30 +37,88 @@ const AuthenticatedPlanesRoute = AuthenticatedPlanesRouteImport.update({
path: '/planes',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedFacultadesRoute = AuthenticatedFacultadesRouteImport.update({
id: '/facultades',
path: '/facultades',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedPlanesPlanIdRoute =
AuthenticatedPlanesPlanIdRouteImport.update({
id: '/$planId',
path: '/$planId',
getParentRoute: () => AuthenticatedPlanesRoute,
} as any)
const AuthenticatedFacultadFacultadIdRoute =
AuthenticatedFacultadFacultadIdRouteImport.update({
id: '/facultad/$facultadId',
path: '/facultad/$facultadId',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedPlanesPlanIdModalRoute =
AuthenticatedPlanesPlanIdModalRouteImport.update({
id: '/modal',
path: '/modal',
getParentRoute: () => AuthenticatedPlanesPlanIdRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/planes': typeof AuthenticatedPlanesRoute
'/facultades': typeof AuthenticatedFacultadesRoute
'/planes': typeof AuthenticatedPlanesRouteWithChildren
'/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute
'/planes/$planId': typeof AuthenticatedPlanesPlanIdRouteWithChildren
'/planes/$planId/modal': typeof AuthenticatedPlanesPlanIdModalRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/planes': typeof AuthenticatedPlanesRoute
'/facultades': typeof AuthenticatedFacultadesRoute
'/planes': typeof AuthenticatedPlanesRouteWithChildren
'/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute
'/planes/$planId': typeof AuthenticatedPlanesPlanIdRouteWithChildren
'/planes/$planId/modal': typeof AuthenticatedPlanesPlanIdModalRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute
'/_authenticated/planes': typeof AuthenticatedPlanesRoute
'/_authenticated/facultades': typeof AuthenticatedFacultadesRoute
'/_authenticated/planes': typeof AuthenticatedPlanesRouteWithChildren
'/_authenticated/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute
'/_authenticated/planes/$planId': typeof AuthenticatedPlanesPlanIdRouteWithChildren
'/_authenticated/planes/$planId/modal': typeof AuthenticatedPlanesPlanIdModalRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/login' | '/planes'
fullPaths:
| '/'
| '/login'
| '/facultades'
| '/planes'
| '/facultad/$facultadId'
| '/planes/$planId'
| '/planes/$planId/modal'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/login' | '/planes'
id: '__root__' | '/' | '/_authenticated' | '/login' | '/_authenticated/planes'
to:
| '/'
| '/login'
| '/facultades'
| '/planes'
| '/facultad/$facultadId'
| '/planes/$planId'
| '/planes/$planId/modal'
id:
| '__root__'
| '/'
| '/_authenticated'
| '/login'
| '/_authenticated/facultades'
| '/_authenticated/planes'
| '/_authenticated/facultad/$facultadId'
| '/_authenticated/planes/$planId'
| '/_authenticated/planes/$planId/modal'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -95,15 +157,72 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedPlanesRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/facultades': {
id: '/_authenticated/facultades'
path: '/facultades'
fullPath: '/facultades'
preLoaderRoute: typeof AuthenticatedFacultadesRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/planes/$planId': {
id: '/_authenticated/planes/$planId'
path: '/$planId'
fullPath: '/planes/$planId'
preLoaderRoute: typeof AuthenticatedPlanesPlanIdRouteImport
parentRoute: typeof AuthenticatedPlanesRoute
}
'/_authenticated/facultad/$facultadId': {
id: '/_authenticated/facultad/$facultadId'
path: '/facultad/$facultadId'
fullPath: '/facultad/$facultadId'
preLoaderRoute: typeof AuthenticatedFacultadFacultadIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/planes/$planId/modal': {
id: '/_authenticated/planes/$planId/modal'
path: '/modal'
fullPath: '/planes/$planId/modal'
preLoaderRoute: typeof AuthenticatedPlanesPlanIdModalRouteImport
parentRoute: typeof AuthenticatedPlanesPlanIdRoute
}
}
}
interface AuthenticatedPlanesPlanIdRouteChildren {
AuthenticatedPlanesPlanIdModalRoute: typeof AuthenticatedPlanesPlanIdModalRoute
}
const AuthenticatedPlanesPlanIdRouteChildren: AuthenticatedPlanesPlanIdRouteChildren =
{
AuthenticatedPlanesPlanIdModalRoute: AuthenticatedPlanesPlanIdModalRoute,
}
const AuthenticatedPlanesPlanIdRouteWithChildren =
AuthenticatedPlanesPlanIdRoute._addFileChildren(
AuthenticatedPlanesPlanIdRouteChildren,
)
interface AuthenticatedPlanesRouteChildren {
AuthenticatedPlanesPlanIdRoute: typeof AuthenticatedPlanesPlanIdRouteWithChildren
}
const AuthenticatedPlanesRouteChildren: AuthenticatedPlanesRouteChildren = {
AuthenticatedPlanesPlanIdRoute: AuthenticatedPlanesPlanIdRouteWithChildren,
}
const AuthenticatedPlanesRouteWithChildren =
AuthenticatedPlanesRoute._addFileChildren(AuthenticatedPlanesRouteChildren)
interface AuthenticatedRouteChildren {
AuthenticatedPlanesRoute: typeof AuthenticatedPlanesRoute
AuthenticatedFacultadesRoute: typeof AuthenticatedFacultadesRoute
AuthenticatedPlanesRoute: typeof AuthenticatedPlanesRouteWithChildren
AuthenticatedFacultadFacultadIdRoute: typeof AuthenticatedFacultadFacultadIdRoute
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedPlanesRoute: AuthenticatedPlanesRoute,
AuthenticatedFacultadesRoute: AuthenticatedFacultadesRoute,
AuthenticatedPlanesRoute: AuthenticatedPlanesRouteWithChildren,
AuthenticatedFacultadFacultadIdRoute: AuthenticatedFacultadFacultadIdRoute,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(

View File

@@ -24,6 +24,8 @@ import {
FileText,
Settings,
LogOut,
KeySquare,
IdCard,
} from "lucide-react"
import { useSupabaseAuth } from "@/auth/supabase"
@@ -41,11 +43,39 @@ const nav = [
{ to: "/materias", label: "Materias", icon: FileText },
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/ajustes", label: "Ajustes", icon: Settings },
]
] as const
function getInitials(name?: string) {
if (!name) return "LS"
const parts = name.trim().split(/\s+/).slice(0, 2)
return parts.map(p => p[0]?.toUpperCase()).join("") || "LS"
}
function useUserDisplay() {
const { claims, user } = useSupabaseAuth()
const nombre = claims?.nombre ?? ""
const apellidos = claims?.apellidos ?? ""
const titulo = claims?.title ?? ""
const clave = claims?.clave ?? ""
const fullName = [titulo, nombre, apellidos].filter(Boolean).join(" ")
const shortName = [titulo, nombre, apellidos.split(" ")[0] ?? ""].filter(Boolean).join(" ")
const role = claims?.role ?? ""
return {
fullName,
shortName,
clave,
email: user?.email,
avatar: claims?.avatar ?? null,
initials: getInitials([nombre, apellidos].filter(Boolean).join(" ")),
role,
isAdmin: Boolean(claims?.claims_admin),
}
}
function Layout() {
const { auth } = Route.useRouteContext()
Route.useRouteContext()
const [query, setQuery] = useState("")
const user = useUserDisplay()
return (
<div className="min-h-screen bg-background text-foreground">
@@ -55,19 +85,22 @@ function Layout() {
{/* Mobile menu */}
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Button variant="ghost" size="icon" className="md:hidden" aria-label="Abrir menú">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0">
<Sidebar onNavigate={() => { }} />
<Sidebar onNavigate={() => {}} />
</SheetContent>
</Sheet>
{/* Brand */}
<Link to="/dashboard" className="hidden items-center gap-2 md:flex">
<Link to={user.isAdmin ? "/dashboard" : "/planes"} className="hidden items-center gap-2 md:flex">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-primary/10 text-primary font-bold">U</span>
<span className="font-semibold tracking-tight">La Salle · Ingeniería</span>
<div className="flex flex-col leading-tight">
<span className="font-semibold tracking-tight">La Salle · Ingeniería</span>
<span className="text-[10px] text-muted-foreground font-mono uppercase tracking-wider">Génesis</span>
</div>
</Link>
{/* Search */}
@@ -79,13 +112,14 @@ function Layout() {
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar…"
className="w-64 pl-9"
aria-label="Buscar"
/>
</div>
<Button variant="ghost" size="icon" aria-label="Notificaciones">
<Bell className="h-5 w-5" />
</Button>
<ModeToggle />
<UserMenu user={{ name: auth.user?.email ?? "Usuario", avatar: auth.user?.avatar_url }} />
<UserMenu user={user} />
</div>
</div>
</header>
@@ -99,6 +133,7 @@ function Layout() {
{/* Main content */}
<main className="min-h-[calc(100dvh-4rem)] p-4 md:p-6">
{/* Welcome strip */}
<Outlet />
</main>
</div>
@@ -107,11 +142,17 @@ function Layout() {
}
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const { claims } = useSupabaseAuth()
const isAdmin = Boolean(claims?.claims_admin)
return (
<div className="h-full">
<div className="flex items-center gap-2 p-4 md:hidden">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-primary/10 text-primary font-bold">U</span>
<span className="font-semibold">La Salle · Ingeniería</span>
<div className="leading-tight">
<span className="font-semibold">La Salle · Ingeniería</span>
<div className="text-[10px] text-muted-foreground font-mono uppercase tracking-wider">Génesis</div>
</div>
</div>
<Separator />
<ScrollArea className="h-[calc(100%-4rem)] p-2">
@@ -120,22 +161,39 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
<Link
key={item.to}
to={item.to}
activeOptions={{ exact: true }}
activeProps={{ className: "bg-primary/10 text-foreground" }}
className="group inline-flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-muted-foreground hover:bg-primary/10 hover:text-foreground"
onClick={onNavigate}
>
<item.icon className="h-4 w-4" />
{item.label}
<span className="truncate">{item.label}</span>
</Link>
))}
{isAdmin && (
<Link
to="/facultades"
activeOptions={{ exact: true }}
activeProps={{ className: "bg-primary/10 text-foreground" }}
className="group inline-flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-muted-foreground hover:bg-primary/10 hover:text-foreground"
>
<KeySquare className="h-4 w-4" />
<span className="truncate">Facultades</span>
</Link>
)}
</nav>
</ScrollArea>
<Separator className="mt-auto" />
<div className="p-3 text-xs text-muted-foreground">© {new Date().getFullYear()} La Salle</div>
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<IdCard className="h-3.5 w-3.5" />
© {new Date().getFullYear()} La Salle
</div>
</div>
)
}
function UserMenu({ user }: { user: { name?: string; avatar?: string | null } }) {
function UserMenu({ user }: { user: ReturnType<typeof useUserDisplay> }) {
const auth = useSupabaseAuth()
return (
<DropdownMenu>
@@ -143,20 +201,20 @@ function UserMenu({ user }: { user: { name?: string; avatar?: string | null } })
<Button variant="ghost" className="h-9 gap-2 px-2">
<Avatar className="h-6 w-6">
<AvatarImage src={user.avatar ?? undefined} />
<AvatarFallback className="text-xs">LS</AvatarFallback>
<AvatarFallback className="text-xs">{user.initials}</AvatarFallback>
</Avatar>
<span className="hidden max-w-[16ch] truncate text-sm md:inline">{user.name}</span>
<span className="hidden max-w-[16ch] truncate text-sm md:inline">{user.shortName}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="truncate">{user.name}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/perfil">Perfil</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/ajustes">Ajustes</Link>
</DropdownMenuItem>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="truncate">
<div className="font-mono text-sm leading-tight">
{user.fullName}
</div>
<div className="text-xs text-muted-foreground truncate">
{`Clave: ${user.clave} · ${user.email}`}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive" onClick={auth.logout}>
<LogOut className="mr-2 h-4 w-4" /> Cerrar sesión

View File

@@ -0,0 +1,234 @@
// Reemplaza la sección del sparkline por estas tarjetas y ajusta el loader.
// + Añade un ProgressRing (SVG) para el % de calidad.
import { createFileRoute, Link } from '@tanstack/react-router'
import * as Icons from 'lucide-react'
import { supabase } from '@/auth/supabase'
import { useMemo } from 'react'
type Facultad = { id: string; nombre: string; icon: string; color?: string | null }
type Plan = {
id: string; nombre: string; fecha_creacion: string | null;
objetivo_general: string | null; perfil_ingreso: string | null; perfil_egreso: string | null;
sistema_evaluacion: string | null; total_creditos: number | null;
}
type Asignatura = {
id: string; nombre: string; fecha_creacion: string | null;
contenidos: any | null; criterios_evaluacion: string | null; bibliografia: any | null;
}
type RecentItem = { id: string; tipo: 'plan' | 'asignatura'; nombre: string | null; fecha: string | null }
type LoaderData = {
facultad: Facultad
counts: { carreras: number; planes: number; asignaturas: number; criterios: number }
recientes: RecentItem[]
calidadPlanesPct: number
saludAsignaturas: { sinBibliografia: number; sinCriterios: number; sinContenidos: number }
}
export const Route = createFileRoute('/_authenticated/facultad/$facultadId')({
component: RouteComponent,
// puedes mantener tu DashboardSkeleton actual como pendingComponent si ya lo tienes
loader: async ({ params }): Promise<LoaderData> => {
const facultadId = params.facultadId
// Facultad
const { data: facultad, error: facErr } = await supabase
.from('facultades').select('id, nombre, icon, color').eq('id', facultadId).single()
if (facErr || !facultad) throw facErr ?? new Error('Facultad no encontrada')
// Carreras
const { data: carreras, error: carErr } = await supabase
.from('carreras').select('id, nombre').eq('facultad_id', facultadId)
if (carErr) throw carErr
const carreraIds = (carreras ?? []).map(c => c.id)
// Planes
let planes: Plan[] = []
if (carreraIds.length) {
const { data, error } = await supabase
.from('plan_estudios')
.select('id, nombre, fecha_creacion, objetivo_general, perfil_ingreso, perfil_egreso, sistema_evaluacion, total_creditos')
.in('carrera_id', carreraIds)
if (error) throw error
planes = (data ?? []) as Plan[]
}
// Asignaturas
let asignaturas: Asignatura[] = []
const planIds = planes.map(p => p.id)
if (planIds.length) {
const { data, error } = await supabase
.from('asignaturas')
.select('id, nombre, fecha_creacion, contenidos, criterios_evaluacion, bibliografia')
.in('plan_id', planIds)
if (error) throw error
asignaturas = (data ?? []) as Asignatura[]
}
// Criterios por carrera_id (tu cambio)
let criterios = 0
if (carreraIds.length) {
const { count, error } = await supabase
.from('criterios_carrera')
.select('*', { count: 'exact', head: true })
.in('carrera_id', carreraIds)
if (error) throw error
criterios = count ?? 0
}
// ====== KPIs de calidad ======
// Plan “completo” si tiene estos campos no vacíos:
const planKeys: (keyof Plan)[] = [
'objetivo_general', 'perfil_ingreso', 'perfil_egreso', 'sistema_evaluacion', 'total_creditos',
]
const completos = planes.filter(p =>
planKeys.every(k => p[k] !== null && p[k] !== '' && p[k] !== 0)
).length
const calidadPlanesPct = planes.length ? Math.round((completos / planes.length) * 100) : 0
// Salud de asignaturas: faltantes
const sinBibliografia = asignaturas.filter(a => !a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0)).length
const sinCriterios = asignaturas.filter(a => !a.criterios_evaluacion || a.criterios_evaluacion.trim() === '').length
const sinContenidos = asignaturas.filter(a => !a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0)).length
// Actividad reciente (planes + asignaturas)
const recientes: RecentItem[] = [
...planes.map(p => ({ id: p.id, tipo: 'plan' as const, nombre: p.nombre, fecha: p.fecha_creacion })),
...asignaturas.map(a => ({ id: a.id, tipo: 'asignatura' as const, nombre: a.nombre, fecha: a.fecha_creacion })),
]
.sort((a, b) => new Date(b.fecha ?? 0).getTime() - new Date(a.fecha ?? 0).getTime())
.slice(0, 6)
return {
facultad,
counts: {
carreras: carreras?.length ?? 0,
planes: planes.length,
asignaturas: asignaturas.length,
criterios,
},
recientes,
calidadPlanesPct,
saludAsignaturas: { sinBibliografia, sinCriterios, sinContenidos },
}
},
})
function gradientFrom(color?: string | null) {
const base = (color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color)) ? color : '#2563eb'
return `linear-gradient(135deg, ${base} 0%, ${base}CC 45%, ${base}99 75%, ${base}66 100%)`
}
// ====== UI helpers ======
function ProgressRing({ pct }: { pct: number }) {
const r = 42, c = 2 * Math.PI * r
const offset = c * (1 - Math.min(Math.max(pct, 0), 100) / 100)
return (
<div className="flex items-center gap-4">
<svg width="112" height="112" viewBox="0 0 112 112" className="drop-shadow">
<circle cx="56" cy="56" r={r} fill="none" stroke="rgba(0,0,0,.08)" strokeWidth="12" />
<circle cx="56" cy="56" r={r} fill="none" stroke="currentColor" strokeWidth="12"
strokeDasharray={c} strokeDashoffset={offset} strokeLinecap="round"
transform="rotate(-90 56 56)" />
</svg>
<div>
<div className="text-3xl font-bold tabular-nums">{pct}%</div>
<div className="text-sm text-neutral-600">Planes con información clave completa</div>
</div>
</div>
)
}
function HealthItem({ label, value, to }: { label: string; value: number; to: string }) {
const warn = value > 0
return (
<Link to={to} className={`flex items-center justify-between rounded-xl px-4 py-3 border ${warn ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-neutral-200 bg-white'}`}>
<span className="text-sm">{label}</span>
<span className="text-lg font-semibold tabular-nums">{value}</span>
</Link>
)
}
function RouteComponent() {
const { facultad, counts, recientes, calidadPlanesPct, saludAsignaturas } = Route.useLoaderData() as LoaderData
const HeaderIcon = (Icons as any)[facultad.icon] || Icons.Building
const headerBg = useMemo(() => ({ background: gradientFrom(facultad.color) }), [facultad.color])
return (
<div className="p-6 space-y-8">
{/* Header */}
<div className="relative rounded-3xl overflow-hidden text-white shadow-xl" style={headerBg}>
<div className="absolute inset-0 opacity-20" style={{ background: 'radial-gradient(800px 300px at 20% -10%, #fff, transparent 60%)' }} />
<div className="relative p-8 flex items-center gap-5">
<HeaderIcon className="w-16 h-16 md:w-20 md:h-20 drop-shadow" />
<div>
<h1 className="text-2xl md:text-3xl font-bold">{facultad.nombre}</h1>
<p className="opacity-90">Calidad y estado académico de la facultad</p>
</div>
</div>
</div>
{/* Métricas principales */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Metric to={`/_authenticated/carreras?facultadId=${facultad.id}`} label="Carreras" value={counts.carreras} Icon={Icons.GraduationCap} />
<Metric to={`/_authenticated/planes?facultadId=${facultad.id}`} label="Planes de estudio" value={counts.planes} Icon={Icons.ScrollText} />
<Metric to={`/_authenticated/asignaturas?facultadId=${facultad.id}`} label="Asignaturas" value={counts.asignaturas} Icon={Icons.BookOpen} />
<Metric to={`/_authenticated/criterios?facultadId=${facultad.id}`} label="Criterios de carrera" value={counts.criterios} Icon={Icons.CheckCircle2} />
</div>
{/* Calidad + Salud */}
<div className="grid gap-6 lg:grid-cols-3">
<div className="rounded-2xl bg-white shadow-lg ring-1 ring-black/5 p-5 lg:col-span-2">
<div className="font-semibold mb-3">Calidad de planes</div>
<ProgressRing pct={calidadPlanesPct} />
<div className="mt-3 text-sm text-neutral-600">
Considera <span className="font-medium">objetivo general, perfiles, sistema de evaluación y créditos</span>.
</div>
</div>
<div className="rounded-2xl bg-white shadow-lg ring-1 ring-black/5 p-5">
<div className="font-semibold mb-3">Salud de asignaturas</div>
<div className="space-y-2">
<HealthItem label="Sin bibliografía" value={saludAsignaturas.sinBibliografia} to={`/_authenticated/asignaturas?facultadId=${facultad.id}&f=sinBibliografia`} />
<HealthItem label="Sin criterios de evaluación" value={saludAsignaturas.sinCriterios} to={`/_authenticated/asignaturas?facultadId=${facultad.id}&f=sinCriterios`} />
<HealthItem label="Sin contenidos" value={saludAsignaturas.sinContenidos} to={`/_authenticated/asignaturas?facultadId=${facultad.id}&f=sinContenidos`} />
</div>
</div>
</div>
{/* Actividad reciente */}
<div className="rounded-2xl bg-white shadow-lg ring-1 ring-black/5 p-5">
<div className="font-semibold mb-3">Actividad reciente</div>
<ul className="space-y-2">
{recientes.length === 0 && <li className="text-sm text-neutral-500">Sin actividad</li>}
{recientes.map((r) => (
<li key={`${r.tipo}-${r.id}`} className="flex items-center justify-between gap-3">
<Link to={`/ _authenticated/${r.tipo === 'plan' ? 'planes' : 'asignaturas'}/${r.id}`} className="truncate hover:underline">
<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.nombre ?? '—'}
</span>
</Link>
<span className="text-xs text-neutral-500">{r.fecha ? new Date(r.fecha).toLocaleDateString() : ''}</span>
</li>
))}
</ul>
</div>
</div>
)
}
// Tarjeta métrica (igual a tu StatTile)
function Metric({ to, label, value, Icon }:{ to: string; label: string; value: number; Icon: any }) {
return (
<Link to={to} className="group rounded-2xl bg-white shadow-lg ring-1 ring-black/5 p-5 flex items-center justify-between hover:-translate-y-0.5 transition-all">
<div>
<div className="text-sm text-neutral-500">{label}</div>
<div className="text-3xl font-bold tabular-nums">{value}</div>
</div>
<div className="p-3 rounded-xl bg-neutral-100 group-hover:bg-neutral-200">
<Icon className="w-7 h-7" />
</div>
</Link>
)
}

View File

@@ -0,0 +1,80 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import * as Icons from 'lucide-react'
import { supabase } from '@/auth/supabase'
import { useMemo } from 'react'
type Facultad = {
id: string
nombre: string
icon: string
color?: string | null
}
export const Route = createFileRoute('/_authenticated/facultades')({
component: RouteComponent,
loader: async () => {
const { data, error } = await supabase
.from('facultades')
.select('id, nombre, icon, color')
.order('nombre')
if (error) {
console.error(error)
return { facultades: [] as Facultad[] }
}
return { facultades: (data ?? []) as Facultad[] }
},
})
function gradientFrom(color?: string | null) {
const base = (color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color)) ? color : '#2563eb' // azul por defecto
// degradado elegante con transparencia
return `linear-gradient(135deg, ${base} 0%, ${base}CC 40%, ${base}99 70%, ${base}66 100%)`
}
function RouteComponent() {
const { facultades } = Route.useLoaderData() as { facultades: Facultad[] }
return (
<div className="p-6">
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{facultades.map((fac) => {
const LucideIcon = (Icons as any)[fac.icon] || Icons.Building
const bg = useMemo(() => ({ background: gradientFrom(fac.color) }), [fac.color])
return (
<Link
key={fac.id}
to="/facultad/$facultadId"
params={{ facultadId: fac.id }}
aria-label={`Administrar ${fac.nombre}`}
className="group relative block rounded-3xl overflow-hidden shadow-xl focus:outline-none focus-visible:ring-4 ring-white/60"
style={bg}
>
{/* capa brillo */}
<div className="absolute inset-0 opacity-0 group-hover:opacity-15 transition-opacity" style={{
background: 'radial-gradient(1200px 400px at 20% -20%, rgba(255,255,255,.45), transparent 60%)'
}} />
{/* contenido */}
<div className="relative h-56 sm:h-64 lg:h-72 p-6 flex flex-col justify-between text-white">
<LucideIcon className="w-20 h-20 md:w-24 md:h-24 drop-shadow-md" />
<div className="flex items-end justify-between">
<h3 className="text-xl md:text-2xl font-bold drop-shadow-sm pr-2">
{fac.nombre}
</h3>
<Icons.ArrowRight className="w-6 h-6 opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all" />
</div>
</div>
{/* borde dinámico al hover */}
<div className="absolute inset-0 ring-0 group-hover:ring-4 group-active:ring-4 ring-white/40 transition-[ring-width]" />
{/* animación sutil */}
<div className="absolute inset-0 scale-100 group-hover:scale-[1.02] group-active:scale-[0.99] transition-transform duration-300" />
</Link>
)
})}
</div>
</div>
)
}

View File

@@ -1,32 +1,23 @@
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { useMemo, useState } from "react"
import { createFileRoute, useRouter, Link } from "@tanstack/react-router"
import { useEffect, useMemo, useState } from "react"
import { supabase, useSupabaseAuth } from "@/auth/supabase"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Plus, RefreshCcw } from "lucide-react"
import * as Icons from "lucide-react"
import { Plus, RefreshCcw, Building2, ScrollText, BookOpen } from "lucide-react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
// --- Tipo correcto según tu esquema ---
export type PlanDeEstudios = {
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
estado: string | null
fecha_creacion: string | null // timestamp with time zone → string ISO
pertinencia: string | null
prompt: string | null
carrera_id: string | null // uuid
id: string; nombre: string; nivel: string | null; duracion: string | null;
total_creditos: number | null; estado: string | null; fecha_creacion: string | null; carrera_id: string | null
}
type PlanRow = PlanDeEstudios & {
carreras: {
id: string; nombre: string;
facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null
} | null
}
export const Route = createFileRoute("/_authenticated/planes")({
@@ -34,26 +25,53 @@ export const Route = createFileRoute("/_authenticated/planes")({
loader: async () => {
const { data, error } = await supabase
.from("plan_estudios")
.select("*")
.select(`
*,
carreras (
id,
nombre,
facultades:facultades ( id, nombre, color, icon )
)
`)
.order("fecha_creacion", { ascending: false })
.limit(100)
if (error) throw new Error(error.message)
return data as PlanDeEstudios[]
}
return (data ?? []) as PlanRow[]
},
})
/* ---------- helpers de estilo suave ---------- */
function hexToRgb(hex?: string | null): [number, number, number] {
if (!hex) return [37, 99, 235] // azul por defecto
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 softCardStyles(color?: string | null) {
const [r, g, b] = hexToRgb(color)
return {
// borde + velo muy sutil del color de la facultad
borderColor: `rgba(${r},${g},${b},.28)`,
background: `linear-gradient(180deg, rgba(${r},${g},${b},.15), rgba(${r},${g},${b},.02))`,
} as React.CSSProperties
}
function RouteComponent() {
const auth = useSupabaseAuth()
const [q, setQ] = useState("")
const data = Route.useLoaderData()
const data = Route.useLoaderData() as PlanRow[]
const router = useRouter()
const search = Route.useSearch<{ planId?: string }>() // usaremos ?planId=... para el modal
const showFacultad = auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
const showCarrera = auth.claims?.role === "secretario_academico"
const filtered = useMemo(() => {
const term = q.trim().toLowerCase()
if (!term || !data) return data
return data.filter((p) =>
[p.nombre, p.nivel, p.estado]
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(term))
)
@@ -66,7 +84,7 @@ function RouteComponent() {
<CardTitle className="text-xl">Planes de estudio</CardTitle>
<div className="flex w-full items-center gap-2 md:w-auto">
<div className="relative w-full md:w-80">
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar por nombre, nivel o estado…" />
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar por nombre, nivel, estado…" />
</div>
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
<RefreshCcw className="h-4 w-4" />
@@ -76,56 +94,158 @@ function RouteComponent() {
</Button>
</div>
</CardHeader>
{/* GRID de tarjetas con estilo suave por facultad */}
<CardContent>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nombre</TableHead>
<TableHead className="hidden md:table-cell">Nivel</TableHead>
<TableHead className="hidden md:table-cell">Créditos</TableHead>
<TableHead>Duración</TableHead>
<TableHead>Estado</TableHead>
<TableHead className="hidden md:table-cell">Creado</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered?.map((p) => (
<TableRow key={p.id} className="hover:bg-muted/50">
<TableCell className="font-medium">{p.nombre}</TableCell>
<TableCell className="hidden md:table-cell">{p.nivel ?? "—"}</TableCell>
<TableCell className="hidden md:table-cell">{p.total_creditos ?? "—"}</TableCell>
<TableCell>{p.duracion ?? "—"}</TableCell>
<TableCell>
{p.estado ? (
<Badge variant={p.estado === "activo" ? "default" : p.estado === "en revisión" ? "secondary" : "outline"}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered?.map((p) => {
const fac = p.carreras?.facultades
const styles = softCardStyles(fac?.color)
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Building2
return (
<Link
key={p.id}
// Runtime navega con ?planId=... (abrimos el modal),
// pero la URL se enmascara SIN el search param:
to="/planes/$planId/modal"
search={{ planId: p.id }}
mask={{ to: '/planes/$planId', params: { planId: p.id } }}
className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5"
params={{ planId: p.id }}
style={styles}
>
<div className="relative p-5 h-40 flex flex-col justify-between">
<div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2"
style={{ borderColor: styles.borderColor as string, background: 'rgba(255,255,255,.6)' }}>
<IconComp className="w-6 h-6" />
</span>
<div className="min-w-0">
<div className="font-semibold truncate">{p.nombre}</div>
<div className="text-xs text-neutral-600 truncate">
{p.nivel ?? "—"} {p.duracion ? `· ${p.duracion}` : ""}
</div>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
{showCarrera && p.carreras?.nombre && (
<Badge variant="secondary" className="border text-neutral-700 bg-white/70">
<ScrollText className="mr-1 h-3 w-3" /> {p.carreras?.nombre}
</Badge>
)}
{showFacultad && fac?.nombre && (
<Badge variant="outline" className="bg-white/60" style={{ borderColor: styles.borderColor }}>
<BookOpen className="mr-1 h-3 w-3" /> {fac?.nombre}
</Badge>
)}
{p.estado && (
<Badge variant="outline" className="ml-auto bg-white/60" style={{ borderColor: styles.borderColor }}>
{p.estado}
</Badge>
) : (
"—"
)}
</TableCell>
<TableCell className="hidden md:table-cell">
{p.fecha_creacion ? new Date(p.fecha_creacion).toLocaleDateString() : "—"}
</TableCell>
</TableRow>
))}
{!filtered?.length && (
<TableRow>
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
Sin resultados
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</Link>
)
})}
</div>
{!filtered?.length && (
<div className="text-center text-sm text-muted-foreground py-10">Sin resultados</div>
)}
</CardContent>
</Card>
<div className="text-xs text-muted-foreground">
Logueado como: <strong>{auth.user?.email}</strong>
</div>
{/* MODAL: se muestra si existe ?planId=... */}
<PlanPreviewModal planId={search?.planId} onClose={() =>
router.navigate({ to: "/planes", replace: true })
} />
</div>
)
}
/* ---------- Modal (carga ligera por id) ---------- */
function PlanPreviewModal({ planId, onClose }: { planId?: string; onClose: () => void }) {
const [loading, setLoading] = useState(false)
const [plan, setPlan] = useState<null | {
id: string; nombre: string; nivel: string | null; duracion: string | null;
total_creditos: number | null; estado: string | null;
carreras: { nombre: string; facultades?: { nombre: string; color?: string | null; icon?: string | null } | null } | null
}>(null)
useEffect(() => {
let alive = true
async function fetchPlan() {
if (!planId) return
setLoading(true)
const { data, error } = await supabase
.from("plan_estudios")
.select(`
id, nombre, nivel, duracion, total_creditos, estado,
carreras (
nombre,
facultades:facultades ( nombre, color, icon )
)
`)
.eq("id", planId)
.single()
if (!alive) return
if (!error) setPlan(data as any)
setLoading(false)
}
fetchPlan()
return () => { alive = false }
}, [planId])
const fac = plan?.carreras?.facultades
const [r, g, b] = hexToRgb(fac?.color)
const headerStyle = { background: `linear-gradient(135deg, rgba(${r},${g},${b},.14), rgba(${r},${g},${b},.06))` }
return (
<Dialog open={!!planId} onOpenChange={() => onClose()}>
<DialogContent className="max-w-2xl p-0 overflow-hidden">
<div className="p-6" style={headerStyle}>
<DialogHeader className="space-y-1">
<DialogTitle>{plan?.nombre ?? "Cargando…"}</DialogTitle>
<div className="text-xs text-neutral-600">
{plan?.carreras?.nombre ?? "—"} {fac?.nombre ? `· ${fac?.nombre}` : ""}
</div>
</DialogHeader>
</div>
<div className="p-6 space-y-4">
{loading && <div className="text-sm text-neutral-500">Cargando</div>}
{!loading && plan && (
<>
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-neutral-500">Nivel:</span> <span className="font-medium">{plan.nivel ?? "—"}</span></div>
<div><span className="text-neutral-500">Duración:</span> <span className="font-medium">{plan.duracion ?? "—"}</span></div>
<div><span className="text-neutral-500">Créditos:</span> <span className="font-medium">{plan.total_creditos ?? "—"}</span></div>
<div><span className="text-neutral-500">Estado:</span> <span className="font-medium">{plan.estado ?? "—"}</span></div>
</div>
<div className="flex gap-2">
<Link
to="/_authenticated/planes/$planId"
params={{ planId: plan.id }}
className="inline-flex items-center gap-2 rounded-xl bg-black text-white px-4 py-2 hover:opacity-90"
>
<Icons.FileText className="w-4 h-4" /> Ver ficha
</Link>
<Link
to="/_authenticated/asignaturas"
search={{ planId: plan.id }}
className="inline-flex items-center gap-2 rounded-xl border px-4 py-2 hover:bg-neutral-50"
>
<Icons.BookOpen className="w-4 h-4" /> Ver asignaturas
</Link>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/planes/$planId')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_authenticated/planes/$planId"!</div>
}

View File

@@ -0,0 +1,105 @@
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { supabase } from "@/auth/supabase"
import * as Icons from "lucide-react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
type PlanDetail = {
id: string
nombre: string
nivel: string | null
duracion: string | null
total_creditos: number | null
estado: string | null
carreras: {
id: string
nombre: string
facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null
} | null
}
export const Route = createFileRoute("/_authenticated/planes/$planId/modal")({
component: RouteComponent,
loader: async ({ params }) => {
const { data, error } = await supabase
.from("plan_estudios")
.select(`
id, nombre, nivel, duracion, total_creditos, estado,
carreras (
id, nombre,
facultades:facultades ( id, nombre, color, icon )
)
`)
.eq("id", params.planId)
.single()
if (error) throw error
return data as PlanDetail
},
})
function gradientFrom(color?: string | null) {
const base = (color && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)) ? color : "#2563eb"
return `linear-gradient(135deg, ${base} 0%, ${base}CC 45%, ${base}99 75%, ${base}66 100%)`
}
function RouteComponent() {
const plan = Route.useLoaderData() as PlanDetail
const router = useRouter()
const fac = plan.carreras?.facultades
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
const headerBg = { background: gradientFrom(fac?.color) }
return (
<Dialog open onOpenChange={() => router.navigate({ to: "/planes", replace: true })}>
<DialogContent className="max-w-2xl p-0 overflow-hidden">
{/* Header con color/ícono de facultad */}
<div className="p-6 text-white" style={headerBg}>
<div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center rounded-2xl bg-white/15 backdrop-blur px-3 py-2">
<IconComp className="w-6 h-6" />
</span>
<div className="min-w-0">
<DialogHeader>
<DialogTitle className="truncate">{plan.nombre}</DialogTitle>
</DialogHeader>
<div className="text-xs opacity-90 truncate">
{plan.carreras?.nombre ?? "—"} {fac?.nombre ? `· ${fac?.nombre}` : ""}
</div>
</div>
{plan.estado && (
<Badge variant="outline" className="ml-auto bg-white/10 text-white border-white/40">
{plan.estado}
</Badge>
)}
</div>
</div>
{/* Cuerpo */}
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-neutral-500">Nivel:</span> <span className="font-medium">{plan.nivel ?? "—"}</span></div>
<div><span className="text-neutral-500">Duración:</span> <span className="font-medium">{plan.duracion ?? "—"}</span></div>
<div><span className="text-neutral-500">Créditos:</span> <span className="font-medium">{plan.total_creditos ?? "—"}</span></div>
<div><span className="text-neutral-500">Facultad:</span> <span className="font-medium">{fac?.nombre ?? "—"}</span></div>
</div>
<div className="flex gap-2">
<a
href={`/_authenticated/planes/${plan.id}`}
className="inline-flex items-center gap-2 rounded-xl bg-black text-white px-4 py-2 hover:opacity-90"
>
<Icons.FileText className="w-4 h-4" /> Ver ficha
</a>
<a
href={`/_authenticated/asignaturas?planId=${plan.id}`}
className="inline-flex items-center gap-2 rounded-xl border px-4 py-2 hover:bg-neutral-50"
>
<Icons.BookOpen className="w-4 h-4" /> Ver asignaturas
</a>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,4 +1,7 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { ArrowRight } from "lucide-react"
import '../App.css'
export const Route = createFileRoute('/')({
@@ -7,9 +10,59 @@ export const Route = createFileRoute('/')({
function App() {
return (
<div className="App">
<h2>Bienvenido al sistema de gestión de vuelos</h2>
<Link to="/planes">Iniciar sesión</Link>
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-950 via-slate-900 to-slate-800 text-white">
{/* Navbar */}
<header className="flex items-center justify-between px-10 py-6 border-b border-slate-700/50">
<h1 className="text-2xl font-mono tracking-tight">Génesis</h1>
<Link to="/login" search={{ redirect: '/planes' }}>
<Button variant="outline" className="text-white border-slate-500 hover:bg-slate-700/50">
Iniciar sesión
</Button>
</Link>
</header>
{/* Hero */}
<main className="flex-1 flex flex-col items-center justify-center text-center px-6">
<h2 className="text-5xl md:text-6xl font-mono font-bold mb-6">
Bienvenido a <span className="text-cyan-400">Génesis</span>
</h2>
<p className="text-lg md:text-xl text-slate-300 max-w-2xl mb-8">
El sistema académico diseñado para transformar la gestión universitaria 🚀.
Seguro, moderno y hecho para crecer contigo.
</p>
<div className="flex gap-4">
<Link to="/login" search={{ redirect: '/planes' }}>
<Button size="lg" className="rounded-2xl px-6 py-3 text-lg font-mono">
Comenzar <ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<Button size="lg" variant="outline" className="rounded-2xl px-6 py-3 text-lg font-mono border-slate-600 text-slate-200 hover:bg-slate-700/50">
Conoce más
</Button>
</div>
</main>
{/* Highlights */}
<section className="grid md:grid-cols-3 gap-6 px-10 py-16 bg-slate-900/60 backdrop-blur-md border-t border-slate-700/50">
{[
{ title: "Gestión Académica", desc: "Administra planes de estudio, carreras y facultades con total control." },
{ title: "Tecnología Moderna", desc: "Construido con React, Supabase y prácticas seguras de última generación." },
{ title: "Escalable", desc: "Diseñado para crecer junto con tu institución." }
].map((item, i) => (
<Card key={i} className="bg-slate-800/60 border-slate-700 hover:border-cyan-500 transition-colors duration-300">
<CardContent className="p-6">
<h3 className="font-mono text-xl mb-3 text-cyan-400">{item.title}</h3>
<p className="text-slate-300">{item.desc}</p>
</CardContent>
</Card>
))}
</section>
{/* Footer */}
<footer className="py-6 text-center text-slate-400 text-sm border-t border-slate-700/50">
© {new Date().getFullYear()} Génesis Universidad La Salle
</footer>
</div>
)
}

View File

@@ -6,7 +6,6 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Mail, Lock, Eye, EyeOff, Loader2, Shield } from "lucide-react"
import { useTheme } from "@/components/theme-provider"
export const Route = createFileRoute("/login")({
validateSearch: (search) => ({
@@ -58,7 +57,7 @@ function LoginComponent() {
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-muted">
<Shield className="h-6 w-6 text-foreground" aria-hidden />
</div>
<CardTitle className="text-2xl">Iniciar sesión</CardTitle>
<CardTitle className="text-2xl font-mono">Iniciar sesión</CardTitle>
<CardDescription className="text-muted-foreground">
Accede a tu panel para gestionar planes y materias
</CardDescription>
@@ -127,7 +126,7 @@ function LoginComponent() {
</div>
</div>
<Button type="submit" disabled={isLoading} className="w-full" size="lg">
<Button type="submit" disabled={isLoading} className="w-full font-mono" size="lg">
{isLoading ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Iniciando

View File

@@ -1,3 +1,4 @@
@import "https://cdn.apps.lci.ulsa.mx/styles/indivisa.css";
@import "tailwindcss";
@import "tw-animate-css";
@@ -74,6 +75,9 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--font-sans: "indivisa-text", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
--font-mono: "indivisa-text-black", Georgia, Cambria, "Times New Roman", Times, serif;
}
.dark {