diff --git a/bun.lock b/bun.lock index 4787c3c..a79c5bb 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "": { "name": "tanstack-router", "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -25,9 +26,11 @@ "cmdk": "^1.1.1", "gsap": "^3.13.0", "lucide-react": "^0.540.0", + "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^3.1.2", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.12", "zod": "^4.0.17", @@ -200,10 +203,14 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@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-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-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "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-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@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-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-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-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-compose-refs": ["@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-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], @@ -682,6 +689,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -756,6 +765,8 @@ "solid-js": ["solid-js@1.9.9", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-A0ZBPJQldAeGCTW0YRYJmt7RCeh5rbFfPZ2aOttgYnctHE7HgKeHCBB/PVc2P7eOfmNXqMFFFoYYdm3S4dcbkA=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/package.json b/package.json index 8ae51a8..d193b8f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test": "vitest run" }, "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -31,9 +32,11 @@ "cmdk": "^1.1.1", "gsap": "^3.13.0", "lucide-react": "^0.540.0", + "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^3.1.2", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.12", "zod": "^4.0.17" diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..d21b65f --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..cd62aff --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,23 @@ +import { useTheme } from "next-themes" +import { Toaster as Sonner, ToasterProps } from "sonner" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/src/components/users/procedencia-combobox.tsx b/src/components/users/procedencia-combobox.tsx index f57e7c4..eaa386a 100644 --- a/src/components/users/procedencia-combobox.tsx +++ b/src/components/users/procedencia-combobox.tsx @@ -5,30 +5,36 @@ import { Button } from "@/components/ui/button" import { Check, ChevronsUpDown, Building2, GraduationCap } from "lucide-react" import { supabase } from "@/auth/supabase" -/* Util simple */ const cls = (...a: (string | false | undefined)[]) => a.filter(Boolean).join(" ") -/* --------- COMBOBOX BASE --------- */ +/* ---------- Base reutilizable ---------- */ function ComboBase({ - placeholder, value, onChange, options, icon: Icon, + placeholder, + value, + onChange, + options, + icon: Icon, + disabled = false, }: { placeholder: string value?: string | null onChange: (id: string) => void options: { id: string; label: string }[] icon?: any + disabled?: boolean }) { const [open, setOpen] = useState(false) const current = useMemo(() => options.find(o => o.id === value), [options, value]) return ( - + + void }) { + value, + onChange, + disabled = false, + placeholder = "Selecciona facultad…", +}: { + value?: string | null + onChange: (id: string) => void + disabled?: boolean + placeholder?: string +}) { const [items, setItems] = useState<{ id: string; label: string }[]>([]) useEffect(() => { - supabase.from("facultades").select("id, nombre, color").order("nombre", { ascending: true }) + supabase + .from("facultades") + .select("id, nombre") + .order("nombre", { ascending: true }) .then(({ data }) => setItems((data ?? []).map(f => ({ id: f.id, label: f.nombre })))) }, []) - return + return ( + + ) } -/* --------- COMBO CARRERAS (filtrado por facultad) --------- */ +/* ---------- Carreras (filtra por facultad) ---------- */ export function CarreraCombobox({ - facultadId, value, onChange, disabled, -}: { facultadId?: string | null; value?: string | null; onChange: (id: string) => void; disabled?: boolean }) { + facultadId, + value, + onChange, + disabled = false, + placeholder, +}: { + facultadId?: string | null + value?: string | null + onChange: (id: string) => void + disabled?: boolean + placeholder?: string +}) { const [items, setItems] = useState<{ id: string; label: string }[]>([]) useEffect(() => { if (!facultadId) { setItems([]); return } - supabase.from("carreras") - .select("id, nombre").eq("facultad_id", facultadId).order("nombre", { ascending: true }) + supabase + .from("carreras") + .select("id, nombre") + .eq("facultad_id", facultadId) + .order("nombre", { ascending: true }) .then(({ data }) => setItems((data ?? []).map(c => ({ id: c.id, label: c.nombre })))) }, [facultadId]) + + const ph = placeholder ?? (facultadId ? "Selecciona carrera…" : "Selecciona una facultad primero") + return ( -
- -
+ ) } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index afa473e..d7b1ce2 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -16,9 +16,12 @@ import { Route as AuthenticatedUsuariosRouteImport } from './routes/_authenticat import { Route as AuthenticatedPlanesRouteImport } from './routes/_authenticated/planes' import { Route as AuthenticatedFacultadesRouteImport } from './routes/_authenticated/facultades' import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard' +import { Route as AuthenticatedAsignaturasRouteImport } from './routes/_authenticated/asignaturas' +import { Route as AuthenticatedArchivosRouteImport } from './routes/_authenticated/archivos' import { Route as AuthenticatedPlanPlanIdRouteImport } from './routes/_authenticated/plan/$planId' import { Route as AuthenticatedFacultadFacultadIdRouteImport } from './routes/_authenticated/facultad/$facultadId' import { Route as AuthenticatedAsignaturasPlanIdRouteImport } from './routes/_authenticated/asignaturas/$planId' +import { Route as AuthenticatedAsignaturaAsignaturaIdRouteImport } from './routes/_authenticated/asignatura/$asignaturaId' const LoginRoute = LoginRouteImport.update({ id: '/login', @@ -54,6 +57,17 @@ const AuthenticatedDashboardRoute = AuthenticatedDashboardRouteImport.update({ path: '/dashboard', getParentRoute: () => AuthenticatedRoute, } as any) +const AuthenticatedAsignaturasRoute = + AuthenticatedAsignaturasRouteImport.update({ + id: '/asignaturas', + path: '/asignaturas', + getParentRoute: () => AuthenticatedRoute, + } as any) +const AuthenticatedArchivosRoute = AuthenticatedArchivosRouteImport.update({ + id: '/archivos', + path: '/archivos', + getParentRoute: () => AuthenticatedRoute, +} as any) const AuthenticatedPlanPlanIdRoute = AuthenticatedPlanPlanIdRouteImport.update({ id: '/plan/$planId', path: '/plan/$planId', @@ -67,18 +81,27 @@ const AuthenticatedFacultadFacultadIdRoute = } as any) const AuthenticatedAsignaturasPlanIdRoute = AuthenticatedAsignaturasPlanIdRouteImport.update({ - id: '/asignaturas/$planId', - path: '/asignaturas/$planId', + id: '/$planId', + path: '/$planId', + getParentRoute: () => AuthenticatedAsignaturasRoute, + } as any) +const AuthenticatedAsignaturaAsignaturaIdRoute = + AuthenticatedAsignaturaAsignaturaIdRouteImport.update({ + id: '/asignatura/$asignaturaId', + path: '/asignatura/$asignaturaId', getParentRoute: () => AuthenticatedRoute, } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/login': typeof LoginRoute + '/archivos': typeof AuthenticatedArchivosRoute + '/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren '/dashboard': typeof AuthenticatedDashboardRoute '/facultades': typeof AuthenticatedFacultadesRoute '/planes': typeof AuthenticatedPlanesRoute '/usuarios': typeof AuthenticatedUsuariosRoute + '/asignatura/$asignaturaId': typeof AuthenticatedAsignaturaAsignaturaIdRoute '/asignaturas/$planId': typeof AuthenticatedAsignaturasPlanIdRoute '/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute '/plan/$planId': typeof AuthenticatedPlanPlanIdRoute @@ -86,10 +109,13 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/login': typeof LoginRoute + '/archivos': typeof AuthenticatedArchivosRoute + '/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren '/dashboard': typeof AuthenticatedDashboardRoute '/facultades': typeof AuthenticatedFacultadesRoute '/planes': typeof AuthenticatedPlanesRoute '/usuarios': typeof AuthenticatedUsuariosRoute + '/asignatura/$asignaturaId': typeof AuthenticatedAsignaturaAsignaturaIdRoute '/asignaturas/$planId': typeof AuthenticatedAsignaturasPlanIdRoute '/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute '/plan/$planId': typeof AuthenticatedPlanPlanIdRoute @@ -99,10 +125,13 @@ export interface FileRoutesById { '/': typeof IndexRoute '/_authenticated': typeof AuthenticatedRouteWithChildren '/login': typeof LoginRoute + '/_authenticated/archivos': typeof AuthenticatedArchivosRoute + '/_authenticated/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute '/_authenticated/facultades': typeof AuthenticatedFacultadesRoute '/_authenticated/planes': typeof AuthenticatedPlanesRoute '/_authenticated/usuarios': typeof AuthenticatedUsuariosRoute + '/_authenticated/asignatura/$asignaturaId': typeof AuthenticatedAsignaturaAsignaturaIdRoute '/_authenticated/asignaturas/$planId': typeof AuthenticatedAsignaturasPlanIdRoute '/_authenticated/facultad/$facultadId': typeof AuthenticatedFacultadFacultadIdRoute '/_authenticated/plan/$planId': typeof AuthenticatedPlanPlanIdRoute @@ -112,10 +141,13 @@ export interface FileRouteTypes { fullPaths: | '/' | '/login' + | '/archivos' + | '/asignaturas' | '/dashboard' | '/facultades' | '/planes' | '/usuarios' + | '/asignatura/$asignaturaId' | '/asignaturas/$planId' | '/facultad/$facultadId' | '/plan/$planId' @@ -123,10 +155,13 @@ export interface FileRouteTypes { to: | '/' | '/login' + | '/archivos' + | '/asignaturas' | '/dashboard' | '/facultades' | '/planes' | '/usuarios' + | '/asignatura/$asignaturaId' | '/asignaturas/$planId' | '/facultad/$facultadId' | '/plan/$planId' @@ -135,10 +170,13 @@ export interface FileRouteTypes { | '/' | '/_authenticated' | '/login' + | '/_authenticated/archivos' + | '/_authenticated/asignaturas' | '/_authenticated/dashboard' | '/_authenticated/facultades' | '/_authenticated/planes' | '/_authenticated/usuarios' + | '/_authenticated/asignatura/$asignaturaId' | '/_authenticated/asignaturas/$planId' | '/_authenticated/facultad/$facultadId' | '/_authenticated/plan/$planId' @@ -201,6 +239,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedDashboardRouteImport parentRoute: typeof AuthenticatedRoute } + '/_authenticated/asignaturas': { + id: '/_authenticated/asignaturas' + path: '/asignaturas' + fullPath: '/asignaturas' + preLoaderRoute: typeof AuthenticatedAsignaturasRouteImport + parentRoute: typeof AuthenticatedRoute + } + '/_authenticated/archivos': { + id: '/_authenticated/archivos' + path: '/archivos' + fullPath: '/archivos' + preLoaderRoute: typeof AuthenticatedArchivosRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/plan/$planId': { id: '/_authenticated/plan/$planId' path: '/plan/$planId' @@ -217,30 +269,56 @@ declare module '@tanstack/react-router' { } '/_authenticated/asignaturas/$planId': { id: '/_authenticated/asignaturas/$planId' - path: '/asignaturas/$planId' + path: '/$planId' fullPath: '/asignaturas/$planId' preLoaderRoute: typeof AuthenticatedAsignaturasPlanIdRouteImport + parentRoute: typeof AuthenticatedAsignaturasRoute + } + '/_authenticated/asignatura/$asignaturaId': { + id: '/_authenticated/asignatura/$asignaturaId' + path: '/asignatura/$asignaturaId' + fullPath: '/asignatura/$asignaturaId' + preLoaderRoute: typeof AuthenticatedAsignaturaAsignaturaIdRouteImport parentRoute: typeof AuthenticatedRoute } } } +interface AuthenticatedAsignaturasRouteChildren { + AuthenticatedAsignaturasPlanIdRoute: typeof AuthenticatedAsignaturasPlanIdRoute +} + +const AuthenticatedAsignaturasRouteChildren: AuthenticatedAsignaturasRouteChildren = + { + AuthenticatedAsignaturasPlanIdRoute: AuthenticatedAsignaturasPlanIdRoute, + } + +const AuthenticatedAsignaturasRouteWithChildren = + AuthenticatedAsignaturasRoute._addFileChildren( + AuthenticatedAsignaturasRouteChildren, + ) + interface AuthenticatedRouteChildren { + AuthenticatedArchivosRoute: typeof AuthenticatedArchivosRoute + AuthenticatedAsignaturasRoute: typeof AuthenticatedAsignaturasRouteWithChildren AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute AuthenticatedFacultadesRoute: typeof AuthenticatedFacultadesRoute AuthenticatedPlanesRoute: typeof AuthenticatedPlanesRoute AuthenticatedUsuariosRoute: typeof AuthenticatedUsuariosRoute - AuthenticatedAsignaturasPlanIdRoute: typeof AuthenticatedAsignaturasPlanIdRoute + AuthenticatedAsignaturaAsignaturaIdRoute: typeof AuthenticatedAsignaturaAsignaturaIdRoute AuthenticatedFacultadFacultadIdRoute: typeof AuthenticatedFacultadFacultadIdRoute AuthenticatedPlanPlanIdRoute: typeof AuthenticatedPlanPlanIdRoute } const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { + AuthenticatedArchivosRoute: AuthenticatedArchivosRoute, + AuthenticatedAsignaturasRoute: AuthenticatedAsignaturasRouteWithChildren, AuthenticatedDashboardRoute: AuthenticatedDashboardRoute, AuthenticatedFacultadesRoute: AuthenticatedFacultadesRoute, AuthenticatedPlanesRoute: AuthenticatedPlanesRoute, AuthenticatedUsuariosRoute: AuthenticatedUsuariosRoute, - AuthenticatedAsignaturasPlanIdRoute: AuthenticatedAsignaturasPlanIdRoute, + AuthenticatedAsignaturaAsignaturaIdRoute: + AuthenticatedAsignaturaAsignaturaIdRoute, AuthenticatedFacultadFacultadIdRoute: AuthenticatedFacultadFacultadIdRoute, AuthenticatedPlanPlanIdRoute: AuthenticatedPlanPlanIdRoute, } diff --git a/src/routes/_authenticated/archivos.tsx b/src/routes/_authenticated/archivos.tsx new file mode 100644 index 0000000..912e299 --- /dev/null +++ b/src/routes/_authenticated/archivos.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_authenticated/archivos')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/_authenticated/archivos"!
+} diff --git a/src/routes/_authenticated/asignatura/$asignaturaId.tsx b/src/routes/_authenticated/asignatura/$asignaturaId.tsx new file mode 100644 index 0000000..259aa33 --- /dev/null +++ b/src/routes/_authenticated/asignatura/$asignaturaId.tsx @@ -0,0 +1,352 @@ +// routes/_authenticated/asignatura/$asignaturaId.tsx +import { createFileRoute, Link, useRouter } from "@tanstack/react-router" +import * as Icons from "lucide-react" +import { useEffect, useMemo, useRef, useState } from "react" +import { supabase } from "@/auth/supabase" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Input } from "@/components/ui/input" +import { + Accordion, AccordionContent, AccordionItem, AccordionTrigger, +} from "@/components/ui/accordion" + +/* ================== Tipos ================== */ +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> | null; + bibliografia: string[] | null; criterios_evaluacion: string | null; plan_id: string | null; +} +type PlanMini = { id: string; nombre: string } + +/* ================== Ruta ================== */ +export const Route = createFileRoute("/_authenticated/asignatura/$asignaturaId")({ + component: Page, + loader: async ({ params }) => { + const { data: a, error } = await supabase + .from("asignaturas") + .select("id, nombre, clave, tipo, semestre, creditos, horas_teoricas, horas_practicas, objetivos, contenidos, bibliografia, criterios_evaluacion, plan_id") + .eq("id", params.asignaturaId) + .single() + if (error || !a) throw error ?? new Error("Asignatura no encontrada") + + let plan: PlanMini | null = null + if (a.plan_id) { + const { data: p } = await supabase + .from("plan_estudios").select("id, nombre").eq("id", a.plan_id).single() + plan = p as PlanMini | null + } + return { a: a as Asignatura, plan } + }, +}) + +/* ================== 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 ( +
+
+ +
+
+
{label}
+
{value}
+
+
+ ) +} + +function Section({ id, title, icon: Icon, children }:{ + id: string; title: string; icon: any; children: React.ReactNode +}) { + return ( +
+
+
+

{title}

+
+ {children} +
+ ) +} + +/* ================== Página ================== */ +function Page() { + const router = useRouter() + const { a, plan } = Route.useLoaderData() as { a: Asignatura; plan: PlanMini | null } + const horasT = a.horas_teoricas ?? 0 + const horasP = a.horas_practicas ?? 0 + const horas = horasT + horasP + const style = typeStyle(a.tipo) + + // ordenar unidades de forma “natural” + const unidades = useMemo(() => { + const entries = Object.entries(a.contenidos ?? {}) + const norm = (s: string) => { + const m = String(s).match(/^\s*(\d+)/) + return m ? [parseInt(m[1], 10), s] as const : [Number.POSITIVE_INFINITY, s] as const + } + return entries + .map(([k, v]) => ({ key: k, order: norm(k)[0], title: norm(k)[1], temas: Object.entries(v) })) + .sort((A, B) => (A.order === B.order ? A.title.localeCompare(B.title) : A.order - B.order)) + .map(u => ({ ...u, temas: u.temas.sort(([a],[b]) => Number(a) - Number(b)) })) + }, [a.contenidos]) + + const temasCount = useMemo(() => unidades.reduce((acc, u) => acc + u.temas.length, 0), [unidades]) + + // buscar dentro del syllabus + const [query, setQuery] = useState("") + const filteredUnidades = useMemo(() => { + const t = query.trim().toLowerCase() + if (!t) return unidades + return unidades.map(u => ({ + ...u, + temas: u.temas.filter(([, tema]) => String(tema).toLowerCase().includes(t)), + })).filter(u => u.temas.length > 0) + }, [query, unidades]) + + // atajos y compartir + const searchRef = useRef(null) + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); searchRef.current?.focus() } + if (e.key === "Escape") router.history.back() + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [router]) + + async function share() { + const url = window.location.href + try { + if (navigator.share) await navigator.share({ title: a.nombre, url }) + else { + await navigator.clipboard.writeText(url) + // feedback visual mínimo + alert("Enlace copiado") + } + } catch { /* noop */ } + } + + return ( +
+ {/* ===== Migas ===== */} + + + {/* ===== Hero ===== */} +
+
+
+
+
+ Asignatura + {plan && <> + · + + {plan.nombre} + + } +
+

{a.nombre}

+
+ {a.clave && Clave: {a.clave}} + {a.tipo && {a.tipo}} + {a.creditos != null && {a.creditos} créditos} + H T/P: {horasT}/{horasP} + Semestre {a.semestre ?? "—"} +
+
+ + {/* Acciones rápidas */} +
+ + +
+
+ + {/* Stats rápidos */} +
+
+ + + + +
+
+
+ + {/* ===== Layout principal ===== */} +
+ {/* ===== Columna principal ===== */} +
+ {/* Objetivo */} + {a.objetivos && ( +
+

{a.objetivos}

+
+ )} + + {/* Syllabus */} + {unidades.length > 0 && ( +
+
+
+ + setQuery(e.target.value)} + placeholder="Buscar tema dentro del programa (⌘/Ctrl K)…" + className="pl-8" + /> +
+ {query && ( + + )} +
+ + + {filteredUnidades.map((u, i) => ( + + +
+ + {/^\s*\d+/.test(u.key) ? `Unidad ${u.key}` : u.title} + + {u.temas.length} tema(s) +
+
+ +
    + {u.temas.map(([k, t]) =>
  • {t}
  • )} +
+
+
+ ))} + {filteredUnidades.length === 0 && ( +
No hay temas que coincidan.
+ )} +
+
+ )} + + {/* Bibliografía */} + {a.bibliografia && a.bibliografia.length > 0 && ( +
+
    + {a.bibliografia.map((ref, i) => ( +
  • + + {ref} +
  • + ))} +
+
+ )} + + {/* Evaluación */} + {a.criterios_evaluacion && ( +
+

{a.criterios_evaluacion}

+
+ )} +
+ + {/* ===== Sidebar ===== */} + +
+ + {/* ===== Volver ===== */} +
+ +
+
+ ) +} + +/* ===== Bits Sidebar ===== */ +function MiniKV({ label, value }:{ label: string; value: string | number }) { + return ( +
+
{label}
+
{value}
+
+ ) +} +function Anchor({ href, label }:{ href: string; label: string }) { + return ( + + {label} + + ) +} diff --git a/src/routes/_authenticated/asignaturas.tsx b/src/routes/_authenticated/asignaturas.tsx new file mode 100644 index 0000000..f873ebd --- /dev/null +++ b/src/routes/_authenticated/asignaturas.tsx @@ -0,0 +1,447 @@ +import { createFileRoute, Link, useRouter } from '@tanstack/react-router' +import { supabase } from '@/auth/supabase' +import * as Icons from 'lucide-react' +import { useMemo, useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select' +import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu' + +/* ================== Tipos ================== */ +type FacMini = { id: string; nombre: string; color?: string | null; icon?: string | null } +type CarMini = { id: string; nombre: string; facultad: FacMini | null } +type PlanMini = { id: string; nombre: string; carrera: CarMini | null } + +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> | null + bibliografia: string[] | null + criterios_evaluacion: string | null + fecha_creacion: string | null + plan: PlanMini | null +} + +type LoaderData = { + asignaturas: Asignatura[] +} + +/* ================== Ruta ================== */ +export const Route = createFileRoute('/_authenticated/asignaturas')({ + component: RouteComponent, + pendingComponent: PageSkeleton, + // Podemos filtrar por planId/carreraId/facultadId desde la URL si se envían + validateSearch: (search: Record) => { + return { + q: (search.q as string) ?? '', + planId: (search.planId as string) ?? '', + carreraId: (search.carreraId as string) ?? '', + facultadId: (search.facultadId as string) ?? '', + f: (search.f as 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | '') ?? '', + } + }, + loader: async (ctx): Promise => { + // TanStack: el search vive en ctx.location.search + const search = (ctx.location?.search ?? {}) as { + q?: string + planId?: string + carreraId?: string + facultadId?: string + f?: 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | '' + } + + const { planId, carreraId, facultadId } = search + + // Resolver alcance por IDs opcionales (para filtrar antes de traer asignaturas) + let planIds: string[] | null = null + + if (planId) { + planIds = [planId] + } else if (carreraId) { + const { data: planesCar, error } = await supabase + .from('plan_estudios') + .select('id') + .eq('carrera_id', carreraId) + if (error) throw error + planIds = (planesCar ?? []).map(p => p.id) + } else if (facultadId) { + const { data: carreras, error: carErr } = await supabase + .from('carreras') + .select('id') + .eq('facultad_id', facultadId) + if (carErr) throw carErr + const cIds = (carreras ?? []).map(c => c.id) + + if (!cIds.length) { + // No hay carreras en la facultad ⇒ no hay asignaturas + return { asignaturas: [] } + } + + const { data: planesFac, error: plaErr } = await supabase + .from('plan_estudios') + .select('id') + .in('carrera_id', cIds) + if (plaErr) throw plaErr + + planIds = (planesFac ?? []).map(p => p.id) + } + + // Si sabemos que no habrá resultados, evitamos pegarle a Supabase + if (planIds && planIds.length === 0) { + return { asignaturas: [] } + } + + // Traer asignaturas + contexto de plan/carrera/facultad + let query = supabase + .from('asignaturas') + .select(` + id, nombre, clave, tipo, semestre, creditos, horas_teoricas, horas_practicas, + objetivos, contenidos, bibliografia, criterios_evaluacion, fecha_creacion, plan_id, + plan:plan_estudios ( + id, nombre, + carrera:carreras ( + id, nombre, + facultad:facultades ( id, nombre, color, icon ) + ) + ) + `) + .order('semestre', { ascending: true }) + .order('nombre', { ascending: true }) + + if (planIds) { + query = query.in('plan_id', planIds) + } + + const { data, error: aErr } = await query + if (aErr) throw aErr + + return { asignaturas: (data ?? []) as unknown as Asignatura[] } + }, + +}) + +/* ================== Página ================== */ +function RouteComponent() { + const { asignaturas } = Route.useLoaderData() as LoaderData + const router = useRouter() + const search = Route.useSearch() as { q: string; f: '' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' } + + // Estado de filtros locales (arrancan con la URL) + const [q, setQ] = useState(search.q ?? '') + const [sem, setSem] = useState('todos') + const [tipo, setTipo] = useState('todos') + const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre') + const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '') + + // Valores de selects + const semestres = useMemo(() => { + const s = new Set() + asignaturas.forEach(a => s.add(String(a.semestre ?? '—'))) + return Array.from(s).sort((a, b) => (a === '—' ? 1 : 0) - (b === '—' ? 1 : 0) || Number(a) - Number(b)) + }, [asignaturas]) + + const tipos = useMemo(() => { + const s = new Set() + asignaturas.forEach(a => s.add(a.tipo ?? '—')) + return Array.from(s).sort() + }, [asignaturas]) + + // Salud (contadores) + const salud = useMemo(() => { + let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0 + for (const a of asignaturas) { + if (!a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0)) sinBibliografia++ + if (!a.criterios_evaluacion || !a.criterios_evaluacion.trim()) sinCriterios++ + if (!a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0)) sinContenidos++ + } + return { sinBibliografia, sinCriterios, sinContenidos } + }, [asignaturas]) + + // Filtrado + const filtered = useMemo(() => { + const t = q.trim().toLowerCase() + return asignaturas.filter(a => { + const matchesQ = + !t || + [a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre] + .filter(Boolean) + .some(v => String(v).toLowerCase().includes(t)) + + const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem + const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo + + const flagOK = + !flag || + (flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) || + (flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) || + (flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0)) + + return matchesQ && semOK && tipoOK && flagOK + }) + }, [q, sem, tipo, flag, asignaturas]) + + // Agrupación + const groups = useMemo(() => { + if (groupBy === 'ninguno') return [['Todas', filtered] as [string, Asignatura[]]] + const m = new Map() + for (const a of filtered) { + const k = a.semestre ?? '—' + if (!m.has(k)) m.set(k, []) + m.get(k)!.push(a) + } + return Array.from(m.entries()).sort(([a], [b]) => { + if (a === '—') return 1 + if (b === '—') return -1 + return Number(a) - Number(b) + }) + }, [filtered, groupBy]) + + // Helpers + const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setFlag('') } + + return ( +
+ {/* HEADER */} +
+
+
+

+ + Asignaturas +

+
+ {/* Crear nueva — puedes cambiar el destino si ya tienes ruta específica */} + + Nueva asignatura + + +
+
+ + {/* Filtros */} +
+ setQ(e.target.value)} + placeholder="Buscar por nombre, clave, plan, carrera, facultad…" + className="w-full" + /> + + + +
+ + {/* Chips de salud (toggle) */} +
+ setFlag(flag === 'sinBibliografia' ? '' : 'sinBibliografia')} + icon={} + label="Sin bibliografía" + value={salud.sinBibliografia} + /> + setFlag(flag === 'sinCriterios' ? '' : 'sinCriterios')} + icon={} + label="Sin criterios de evaluación" + value={salud.sinCriterios} + /> + setFlag(flag === 'sinContenidos' ? '' : 'sinContenidos')} + icon={} + label="Sin contenidos" + value={salud.sinContenidos} + /> + {(q || sem !== 'todos' || tipo !== 'todos' || flag) && ( + + )} +
+
+
+ + {/* LISTA */} +
+ {!groups.length &&
Sin asignaturas
} + + {groups.map(([key, items]) => ( +
+ {groupBy !== 'ninguno' && ( +
+ Semestre {key} +
+ )} +
    + {items.map(a => )} +
+
+ ))} +
+
+ ) +} + +/* ================== Card ================== */ +function tipoMeta(tipo?: string | null) { + const t = (tipo ?? '').toLowerCase() + if (t.includes('oblig')) return { label: 'Obligatoria', Icon: Icons.BadgeCheck, cls: 'bg-emerald-50 text-emerald-700 border-emerald-200' } + if (t.includes('opt')) return { label: 'Optativa', Icon: Icons.Wand2, cls: 'bg-amber-50 text-amber-800 border-amber-200' } + if (t.includes('taller')) return { label: 'Taller', Icon: Icons.Hammer, cls: 'bg-indigo-50 text-indigo-700 border-indigo-200' } + if (t.includes('lab')) return { label: 'Laboratorio', Icon: Icons.FlaskConical, cls: 'bg-sky-50 text-sky-700 border-sky-200' } + return { label: tipo ?? 'Genérica', Icon: Icons.BookOpen, cls: 'bg-neutral-100 text-neutral-700 border-neutral-200' } +} +function Chip({ children, className = '' }: { children: React.ReactNode; className?: string }) { + return {children} +} + +function AsignaturaCard({ a }: { a: Asignatura }) { + const horasT = a.horas_teoricas ?? 0 + const horasP = a.horas_practicas ?? 0 + const meta = tipoMeta(a.tipo) + const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2 + + return ( +
  • +
    +
    + + + + +
    +
    +

    {a.nombre}

    + {/* Menú rápido (placeholder extensible) */} + + + + + + + + Abrir + + + + + Ver plan + + + + +
    + +
    + {a.clave && {a.clave}} + {meta.label} + {a.creditos != null && {a.creditos} créditos} + {(horasT + horasP) > 0 && H T/P: {horasT}/{horasP}} + Semestre {a.semestre ?? '—'} +
    + + {/* Contexto del plan/carrera/facultad */} + {a.plan && ( +
    + + {a.plan.nombre} + + {a.plan.carrera && ( + + {a.plan.carrera.nombre} + + )} + {a.plan.carrera?.facultad && ( + + {a.plan.carrera.facultad.nombre} + + )} +
    + )} + + {/* Objetivo resumido + CTA */} +
    +

    {a.objetivos ?? '—'}

    + + Ver + +
    +
    +
    +
    +
  • + ) +} + +/* ================== UI helpers ================== */ +function HealthChip({ + active, onClick, icon, label, value, +}: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string; value: number }) { + return ( + + ) +} + +/* ================== Skeleton ================== */ +function Pulse({ className = '' }: { className?: string }) { + return
    +} +function PageSkeleton() { + return ( +
    + +
    + {Array.from({ length: 9 }).map((_, i) => )} +
    +
    + ) +} diff --git a/src/routes/_authenticated/asignaturas/$planId.tsx b/src/routes/_authenticated/asignaturas/$planId.tsx index 861389f..4868e93 100644 --- a/src/routes/_authenticated/asignaturas/$planId.tsx +++ b/src/routes/_authenticated/asignaturas/$planId.tsx @@ -1,18 +1,35 @@ -import { createFileRoute, useRouter } from "@tanstack/react-router" +// routes/_authenticated/asignaturas/$planId.tsx +import { createFileRoute, Link, useRouter } from "@tanstack/react-router" import { supabase } from "@/auth/supabase" import * as Icons from "lucide-react" -import { useMemo, useState } from "react" -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { useEffect, useMemo, useRef, useState } from "react" +import { + Dialog, DialogContent, DialogHeader, DialogTitle, +} from "@/components/ui/dialog" import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Select, SelectTrigger, SelectContent, SelectItem, SelectValue, +} from "@/components/ui/select" import { Badge } from "@/components/ui/badge" +/* ================== Tipos ================== */ 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> | null + bibliografia: string[] | null + criterios_evaluacion: string | null } type ModalData = { @@ -21,8 +38,9 @@ type ModalData = { asignaturas: Asignatura[] } +/* ================== Ruta (modal) ================== */ export const Route = createFileRoute("/_authenticated/asignaturas/$planId")({ - component: ModalComponent, + component: Page, loader: async ({ params }): Promise => { const planId = params.planId @@ -35,40 +53,105 @@ export const Route = createFileRoute("/_authenticated/asignaturas/$planId")({ const { data: asignaturas, error: aErr } = await supabase .from("asignaturas") - .select("id, nombre, semestre, creditos, horas_teoricas, horas_practicas") + .select(` + id, nombre, clave, tipo, semestre, creditos, horas_teoricas, horas_practicas, + objetivos, contenidos, bibliografia, criterios_evaluacion + `) .eq("plan_id", planId) .order("semestre", { ascending: true }) .order("nombre", { ascending: true }) - if (aErr) throw aErr - return { - planId, - planNombre: plan.nombre, - asignaturas: (asignaturas ?? []) as Asignatura[], - } + return { planId, planNombre: plan.nombre, asignaturas: (asignaturas ?? []) as Asignatura[] } }, }) -function ModalComponent() { +/* ================== Página ================== */ +function Page() { const { planId, planNombre, asignaturas } = Route.useLoaderData() as ModalData const router = useRouter() - const [q, setQ] = useState("") - const filtered = useMemo(() => { - const t = q.trim().toLowerCase() - if (!t) return asignaturas - return asignaturas.filter(a => - [a.nombre, a.semestre, a.creditos] - .filter(Boolean) - .some(v => String(v).toLowerCase().includes(t)) - ) - }, [q, asignaturas]) + // ---- estado UI + const [query, setQuery] = useState("") + const [sem, setSem] = useState("todos") + const [tipo, setTipo] = useState("todos") + const [orden, setOrden] = useState<"nombre" | "semestre" | "creditos">("semestre") + const [vista, setVista] = useState<"cards" | "tabla">("cards") - // Agrupar por semestre - const groups = useMemo(() => { + // ---- atajos + const searchRef = useRef(null) + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const meta = e.ctrlKey || e.metaKey + if (meta && e.key.toLowerCase() === "k") { + e.preventDefault() + searchRef.current?.focus() + } + if (e.key === "Escape") { + router.navigate({ to: "/plan/$planId", params: { planId }, replace: true }) + } + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [router, planId]) + + // ---- semestres y tipos disponibles + const semestres = useMemo(() => { + const s = new Set() + asignaturas.forEach(a => s.add(String(a.semestre ?? "—"))) + return Array.from(s).sort((a, b) => (a === "—" ? 1 : 0) - (b === "—" ? 1 : 0) || Number(a) - Number(b)) + }, [asignaturas]) + const tipos = useMemo(() => { + const s = new Set() + asignaturas.forEach(a => s.add(a.tipo ?? "—")) + return Array.from(s).sort() + }, [asignaturas]) + + // ---- KPIs + const kpis = useMemo(() => { + const total = asignaturas.length + const creditos = asignaturas.reduce((acc, a) => acc + (a.creditos ?? 0), 0) + const ht = asignaturas.reduce((acc, a) => acc + (a.horas_teoricas ?? 0), 0) + const hp = asignaturas.reduce((acc, a) => acc + (a.horas_practicas ?? 0), 0) + const porTipo: Record = {} + asignaturas.forEach(a => { + const key = (a.tipo ?? "—").toLowerCase() + porTipo[key] = (porTipo[key] ?? 0) + 1 + }) + return { total, creditos, ht, hp, porTipo } + }, [asignaturas]) + + // ---- filtro + orden + const filtradas = useMemo(() => { + const t = query.trim().toLowerCase() + const list = asignaturas.filter(a => { + const matchTexto = + !t || + [a.nombre, a.clave, a.tipo, a.objetivos] + .filter(Boolean) + .some(v => String(v).toLowerCase().includes(t)) + const semOK = sem === "todos" || String(a.semestre ?? "—") === sem + const tipoOK = tipo === "todos" || (a.tipo ?? "—") === tipo + return matchTexto && semOK && tipoOK + }) + + const sortList = [...list].sort((A, B) => { + if (orden === "nombre") return A.nombre.localeCompare(B.nombre) + if (orden === "creditos") return (B.creditos ?? 0) - (A.creditos ?? 0) + // semestre + const a = A.semestre ?? 999 + const b = B.semestre ?? 999 + if (a === b) return A.nombre.localeCompare(B.nombre) + return a - b + }) + + return sortList + }, [asignaturas, query, sem, tipo, orden]) + + // ---- agrupación por semestre (para la vista de cards) + const grupos = useMemo(() => { const m = new Map() - for (const a of filtered) { + for (const a of filtradas) { const k = a.semestre ?? "—" if (!m.has(k)) m.set(k, []) m.get(k)!.push(a) @@ -78,65 +161,318 @@ function ModalComponent() { if (b === "—") return -1 return Number(a) - Number(b) }) - }, [filtered]) + }, [filtradas]) + + // ---- helpers + const limpiar = () => { setQuery(""); setSem("todos"); setTipo("todos"); setOrden("semestre") } return ( - router.navigate({ to: "/plan/$planId", params: { planId }, replace: true }) - } + onOpenChange={() => router.navigate({ to: "/plan/$planId", params: { planId }, replace: true })} > - - - - - Asignaturas · {planNombre} - - + + {/* HERO ===================================================== */} +
    +
    +
    +
    +
    + + Plan +
    + + {planNombre} + +
    + + + + {Object.entries(kpis.porTipo).slice(0, 3).map(([t, n]) => ( + + {t} · {n} + + ))} +
    +
    -
    - setQ(e.target.value)} - placeholder="Buscar por nombre, semestre…" - className="w-full" - /> +
    + +
    +
    + + {/* TOOLBAR sticky ========================================= */} +
    +
    +
    + + setQuery(e.target.value)} + placeholder="Buscar (⌘/Ctrl K)…" + className="pl-8" + /> +
    + + + + + + + +
    + + {(query || sem !== "todos" || tipo !== "todos" || orden !== "semestre") && ( + + )} +
    +
    +
    -
    - {groups.length === 0 && ( -
    Sin asignaturas
    + {/* CONTENIDO scrolleable ==================================== */} +
    + {filtradas.length === 0 ? ( + + ) : vista === "tabla" ? ( + + ) : ( +
    + {grupos.map(([sem, items]) => ( +
    +

    Semestre {sem}

    +
      + {items.map(a => )} +
    +
    + ))} +
    )} - -
    - {groups.map(([sem, items]) => ( -
    -
    - Semestre {sem} -
    -
      - {items.map(a => ( -
    • -
      {a.nombre}
      -
      - {a.creditos != null && ( - Créditos: {a.creditos} - )} - {(a.horas_teoricas ?? 0) + (a.horas_practicas ?? 0) > 0 && ( - - Hrs T/P: {a.horas_teoricas ?? 0}/{a.horas_practicas ?? 0} - - )} -
      -
    • - ))} -
    -
    - ))} -
    ) } + +/* ================== UI bits ================== */ +function KpiChip({ icon: Icon, label, value }:{ icon: any; label: string; value: number | string }) { + return ( + + {label}: {value} + + ) +} + +function ViewToggle({ value, onChange }:{ value:"cards"|"tabla"; onChange:(v:"cards"|"tabla")=>void }) { + return ( +
    + + +
    + ) +} + +function EmptyState() { + return ( +
    +
    +
    + +
    +

    Sin resultados

    +

    Ajusta los filtros o la búsqueda.

    +
    +
    + ) +} + +/* ================== Card ================== */ + +function tipoMeta(tipo?: string | null) { + const t = (tipo ?? "").toLowerCase() + if (t.includes("oblig")) return { label: "Obligatoria", color: "emerald" } + if (t.includes("opt")) return { label: "Optativa", color: "amber" } + if (t.includes("taller")) return { label: "Taller", color: "indigo" } + if (t.includes("lab")) return { label: "Laboratorio", color: "sky" } + return { label: tipo ?? "Genérica", color: "neutral" } +} + +function AsignaturaCard({ a }: { a: Asignatura }) { + const horasT = a.horas_teoricas ?? 0 + const horasP = a.horas_practicas ?? 0 + const meta = tipoMeta(a.tipo) + + return ( +
  • + {/* franja lateral por tipo */} + +
    +
    +
    + +
    + +
    +

    {a.nombre}

    +
    + {a.clave && {a.clave}} + + {meta.label} + + {a.creditos != null && {a.creditos} créditos} + {(horasT + horasP) > 0 && H T/P: {horasT}/{horasP}} + Semestre {a.semestre ?? "—"} +
    +
    + + + + + + + + + Ver detalles + + + + Editar + + + Ajustar con IA + + + +
    + + {/* objetivo (clamp) */} + {a.objetivos && ( +

    {a.objetivos}

    + )} + + {/* CTA */} +
    + + Ver ficha + +
    +
    +
  • + ) +} + +function Chip({ children, className = "" }:{ children: React.ReactNode; className?: string }) { + return ( + + {children} + + ) +} + +/* ================== Tabla compacta ================== */ +function Tabla({ asignaturas }: { asignaturas: Asignatura[] }) { + return ( +
    + + + + + + + + + + + + + + {asignaturas.map(a => ( + + + + + + + + + + ))} + +
    NombreClaveTipoSem.CréditosH T/P
    +
    {a.nombre}
    + {a.objetivos &&
    {a.objetivos}
    } +
    {a.clave ?? "—"}{a.tipo ?? "—"}{a.semestre ?? "—"}{a.creditos ?? "—"} + {(a.horas_teoricas ?? 0)}/{(a.horas_practicas ?? 0)} + +
    + + + + + + + + Ver detalles + + + + Editar + + + Ajustar con IA + + + +
    +
    +
    + ) +} diff --git a/src/routes/_authenticated/dashboard.tsx b/src/routes/_authenticated/dashboard.tsx index 9e5ee54..fb83974 100644 --- a/src/routes/_authenticated/dashboard.tsx +++ b/src/routes/_authenticated/dashboard.tsx @@ -1,9 +1,402 @@ -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, Link, useRouter } from '@tanstack/react-router' +import { supabase, useSupabaseAuth } from '@/auth/supabase' +import * as Icons from 'lucide-react' +import { useMemo } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +/* ========= Tipos ========= */ +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 LoaderData = { + kpis: { facultades: number; carreras: number; planes: number; asignaturas: number } + calidadPlanesPct: number + saludAsignaturas: { sinBibliografia: number; sinCriterios: number; sinContenidos: number } + recientes: Array<{ tipo: 'plan' | 'asignatura'; id: string; nombre: string; fecha: string | null }> +} + +/* ========= Loader ========= */ export const Route = createFileRoute('/_authenticated/dashboard')({ component: RouteComponent, + pendingComponent: DashboardSkeleton, + loader: async (): Promise => { + // KPI counts + const [{ count: facCount }, { count: carCount }, { data: planesRaw }, { data: asignRaw }] = + await Promise.all([ + supabase.from('facultades').select('*', { count: 'exact', head: true }), + supabase.from('carreras').select('*', { count: 'exact', head: true }), + supabase + .from('plan_estudios') + .select( + 'id, nombre, fecha_creacion, objetivo_general, perfil_ingreso, perfil_egreso, sistema_evaluacion, total_creditos' + ), + supabase + .from('asignaturas') + .select('id, nombre, fecha_creacion, contenidos, criterios_evaluacion, bibliografia') + ]) + + const planes = (planesRaw ?? []) as Plan[] + const asignaturas = (asignRaw ?? []) as Asignatura[] + + // Calidad de planes + const needed: (keyof Plan)[] = [ + 'objetivo_general', + 'perfil_ingreso', + 'perfil_egreso', + 'sistema_evaluacion', + 'total_creditos' + ] + const completos = planes.filter(p => + needed.every(k => p[k] !== null && String(p[k] ?? '').toString().trim() !== '') + ).length + const calidadPlanesPct = planes.length ? Math.round((completos / planes.length) * 100) : 0 + + // Salud de asignaturas + const sinBibliografia = asignaturas.filter( + a => !a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0) + ).length + const sinCriterios = asignaturas.filter(a => !a.criterios_evaluacion?.trim()).length + const sinContenidos = asignaturas.filter( + a => !a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0) + ).length + + // Actividad reciente (últimos 8 ítems) + const recientes = [ + ...planes.map(p => ({ tipo: 'plan' as const, id: p.id, nombre: p.nombre, fecha: p.fecha_creacion })), + ...asignaturas.map(a => ({ tipo: 'asignatura' as const, id: a.id, nombre: a.nombre, fecha: a.fecha_creacion })) + ] + .sort((a, b) => new Date(b.fecha ?? 0).getTime() - new Date(a.fecha ?? 0).getTime()) + .slice(0, 8) + + return { + kpis: { + facultades: facCount ?? 0, + carreras: carCount ?? 0, + planes: planes.length, + asignaturas: asignaturas.length + }, + calidadPlanesPct, + saludAsignaturas: { sinBibliografia, sinCriterios, sinContenidos }, + recientes + } + } }) -function RouteComponent() { - return
    Hello "/_authenticated/dashboard"!
    +/* ========= Helpers visuales ========= */ +function gradient(bg = '#2563eb') { + return { + background: `linear-gradient(135deg, ${bg} 0%, ${bg}cc 45%, ${bg}a6 75%, ${bg}66 100%)` + } as React.CSSProperties +} +function hex(color?: string | null, fallback = '#2563eb') { + return color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color) ? color : fallback +} +function Ring({ pct, color }: { pct: number; color: string }) { + const R = 42 + const C = 2 * Math.PI * R + const off = C * (1 - Math.min(Math.max(pct, 0), 100) / 100) + return ( +
    + + + + +
    +
    {pct}%
    +
    Planes con información clave completa
    +
    +
    + ) +} + +function Tile({ + to, + label, + value, + Icon +}: { + to: string + label: string + value: number | string + Icon: React.ComponentType> +}) { + return ( + +
    +
    {label}
    +
    {value}
    +
    +
    + +
    + + ) +} + +/* ========= Página ========= */ +function RouteComponent() { + const { kpis, calidadPlanesPct, saludAsignaturas, recientes } = Route.useLoaderData() as LoaderData + const auth = useSupabaseAuth() + const router = useRouter() + const primary = hex(auth.claims?.facultad_color, '#1d4ed8') // si guardan color de facultad en claims + + const name = auth.user?.user_metadata?.nombre || auth.user?.email?.split('@')[0] || '¡Hola!' + + const isAdmin = !!auth.claims?.claims_admin + const role = auth.claims?.role as + | 'lci' + | 'vicerrectoria' + | 'secretario_academico' + | 'jefe_carrera' + | 'planeacion' + | undefined + + // Mensaje contextual + const roleHint = useMemo(() => { + switch (role) { + case 'vicerrectoria': + return 'Panorama académico, calidad y actividad reciente.' + case 'secretario_academico': + return 'Enfócate en tu facultad: salud de asignaturas y avance de planes.' + case 'jefe_carrera': + return 'Accede rápido a planes y asignaturas de tu carrera.' + case 'planeacion': + return 'Monitorea consistencia de planes y evidencias de evaluación.' + default: + return 'Atajos para crear, revisar y mejorar contenido.' + } + }, [role]) + + return ( +
    + {/* Header con saludo y búsqueda global */} +
    +
    +
    +
    +
    +

    Hola, {name}

    +

    {roleHint}

    +
    +
    + {role && {role}} + {isAdmin && ( + + admin + + )} +
    +
    + +
    + { + if (e.key === 'Enter') { + const q = (e.target as HTMLInputElement).value.trim() + if (!q) return + router.navigate({ to: '/planes', search: { q } }) + } + }} + /> + +
    + + {/* Atajos rápidos (según rol) */} +
    + + Nuevo plan + + + Nueva asignatura + + {isAdmin && ( + + Invitar usuario + + )} +
    +
    +
    + + {/* KPIs principales */} +
    + + + + +
    + + {/* Calidad + Salud */} +
    + + + + Calidad de planes + + + + +

    + Considera objetivo general, perfiles, sistema de evaluación y créditos. +

    +
    +
    + + + + + Salud de asignaturas + + + + + + + + +
    + + {/* Actividad reciente */} + + + + Actividad reciente + + + + {recientes.length === 0 && ( +
    Sin actividad registrada.
    + )} +
      + {recientes.map(r => ( +
    • + + {r.tipo === 'plan' ? ( + + ) : ( + + )} + {r.nombre} + + + {r.fecha ? new Date(r.fecha).toLocaleDateString() : ''} + +
    • + ))} +
    +
    +
    +
    + ) +} + +/* ========= Subcomponentes ========= */ +function HealthRow({ label, value, to }: { label: string; value: number; to: string }) { + const warn = value > 0 + return ( + + {label} + {value} + + ) +} + +/* ========= Skeleton (cuando carga) ========= */ +function Pulse({ className = '' }: { className?: string }) { + return
    +} +function DashboardSkeleton() { + return ( +
    +
    + +
    +
    + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
    +
    + + +
    + +
    + ) } diff --git a/src/routes/_authenticated/facultad/$facultadId.tsx b/src/routes/_authenticated/facultad/$facultadId.tsx index bebd4a0..43cf6c1 100644 --- a/src/routes/_authenticated/facultad/$facultadId.tsx +++ b/src/routes/_authenticated/facultad/$facultadId.tsx @@ -129,8 +129,7 @@ function ProgressRing({ pct, color }: { pct: number, color: string }) { return (
    - - diff --git a/src/routes/_authenticated/plan/$planId.tsx b/src/routes/_authenticated/plan/$planId.tsx index d5cd0c7..83d7f37 100644 --- a/src/routes/_authenticated/plan/$planId.tsx +++ b/src/routes/_authenticated/plan/$planId.tsx @@ -216,13 +216,7 @@ function RouteComponent() { {plan.estado} )} - - Ver asignaturas - +
    diff --git a/src/routes/_authenticated/planes.tsx b/src/routes/_authenticated/planes.tsx index d108365..b4a59d2 100644 --- a/src/routes/_authenticated/planes.tsx +++ b/src/routes/_authenticated/planes.tsx @@ -8,6 +8,12 @@ import { Badge } from "@/components/ui/badge" import * as Icons from "lucide-react" import { Plus, RefreshCcw, Building2 } from "lucide-react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox" + + export type PlanDeEstudios = { id: string; nombre: string; nivel: string | null; duracion: string | null; total_creditos: number | null; estado: string | null; fecha_creacion: string | null; carrera_id: string | null @@ -64,7 +70,7 @@ function InfoChip({ return ( {icon} @@ -77,6 +83,7 @@ function InfoChip({ function RouteComponent() { const auth = useSupabaseAuth() const [q, setQ] = useState("") + const [openCreate, setOpenCreate] = useState(false) const data = Route.useLoaderData() as PlanRow[] const router = useRouter() @@ -105,9 +112,10 @@ function RouteComponent() { - +
    @@ -184,6 +192,193 @@ function RouteComponent() { )} + + { + setOpenCreate(false) + router.invalidate() + router.navigate({ to: "/plan/$planId", params: { planId: id } }) + }} + /> +
    ) } + + +function CreatePlanDialog({ + open, onOpenChange, onCreated, +}: { + open: boolean + onOpenChange: (v: boolean) => void + onCreated: (newId: string) => void +}) { + const auth = useSupabaseAuth() + const role = auth.claims?.role + const defaultFac = auth.claims?.facultad_id ?? "" + const defaultCar = auth.claims?.carrera_id ?? "" + + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [form, setForm] = useState<{ + nombre: string + nivel: string + duracion: string + total_creditos: number | null + facultad_id: string + carrera_id: string + objetivo_general?: string + }>({ + nombre: "", + nivel: "", + duracion: "", + total_creditos: null, + facultad_id: defaultFac, + carrera_id: defaultCar, + objetivo_general: "", + }) + + // Reglas por rol: + const lockFacultad = role === "secretario_academico" || role === "jefe_carrera" + const lockCarrera = role === "jefe_carrera" + const needsFacultad = role === "secretario_academico" || role === "jefe_carrera" || role === "vicerrectoria" || role === "lci" + const needsCarrera = role !== "planeacion" // en general todos crean sobre una carrera + + async function createPlan() { + setError(null) + if (!form.nombre.trim()) return setError("El nombre es obligatorio.") + if (needsCarrera && !form.carrera_id) return setError("Selecciona una carrera.") + + setSaving(true) + const { data, error } = await supabase + .from("plan_estudios") + .insert({ + nombre: form.nombre.trim(), + nivel: form.nivel || null, + duracion: form.duracion || null, + total_creditos: form.total_creditos ?? null, + objetivo_general: form.objetivo_general || null, + carrera_id: form.carrera_id, + estado: "activo", + }) + .select("id") + .single() + + setSaving(false) + if (error) { + setError(error.message) + return + } + onCreated(data!.id) + } + + return ( + + + + Nuevo plan de estudios + + +
    +
    + + setForm(s => ({ ...s, nombre: e.target.value }))} + placeholder="Ej. Licenciatura en Ciberseguridad" + /> +
    + +
    + + setForm(s => ({ ...s, nivel: e.target.value }))} + placeholder="Licenciatura / Maestría…" + /> +
    + +
    + + setForm(s => ({ ...s, duracion: e.target.value }))} + placeholder="9 semestres / 3 años…" + /> +
    + +
    + + { + const v = e.target.value.trim() + setForm(s => ({ ...s, total_creditos: v === "" ? null : Number(v) || 0 })) + }} + placeholder="270" + /> +
    + +
    + +