feat: implement dashboard with KPIs, recent activity, and health metrics
- Added dashboard route with loader fetching KPIs, recent plans and subjects, and health metrics. - Created visual components for displaying KPIs and recent activity. - Implemented gradient background and user greeting based on role. - Added input for global search and quick links for creating new plans and subjects. refactor: update facultad progress ring rendering - Fixed rendering of progress ring in facultad detail view. fix: remove unnecessary link to subjects in plan detail view - Removed link to view subjects from the plan detail page for cleaner UI. feat: add create plan dialog in planes route - Introduced a dialog for creating new plans with form validation and role-based field visibility. - Integrated Supabase for creating plans and handling user roles. feat: enhance user management with create user dialog - Added functionality to create new users with role and claims management. - Implemented password generation and input handling for user creation. fix: update login redirect to dashboard - Changed default redirect after login from /planes to /dashboard for better user experience.
This commit is contained in:
64
src/components/ui/accordion.tsx
Normal file
64
src/components/ui/accordion.tsx
Normal file
@@ -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<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
23
src/components/ui/sonner.tsx
Normal file
23
src/components/ui/sonner.tsx
Normal file
@@ -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 (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={disabled ? false : open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between truncate"
|
||||
disabled={disabled}
|
||||
className={cls("w-full justify-between truncate", disabled && "opacity-60 cursor-not-allowed")}
|
||||
title={current?.label ?? placeholder}
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
@@ -38,6 +44,7 @@ function ComboBase({
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
@@ -67,38 +74,73 @@ function ComboBase({
|
||||
)
|
||||
}
|
||||
|
||||
/* --------- COMBO FACULTADES --------- */
|
||||
/* ---------- Facultades ---------- */
|
||||
export function FacultadCombobox({
|
||||
value, onChange,
|
||||
}: { value?: string | null; onChange: (id: string) => 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 <ComboBase placeholder="Selecciona facultad…" value={value} onChange={onChange} options={items} icon={Building2} />
|
||||
return (
|
||||
<ComboBase
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={items}
|
||||
icon={Building2}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/* --------- 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 (
|
||||
<div className={disabled ? "opacity-60 pointer-events-none" : ""}>
|
||||
<ComboBase
|
||||
placeholder={facultadId ? "Selecciona carrera…" : "Selecciona una facultad primero"}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={items}
|
||||
icon={GraduationCap}
|
||||
/>
|
||||
</div>
|
||||
<ComboBase
|
||||
placeholder={ph}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={items}
|
||||
icon={GraduationCap}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
9
src/routes/_authenticated/archivos.tsx
Normal file
9
src/routes/_authenticated/archivos.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/archivos')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/_authenticated/archivos"!</div>
|
||||
}
|
||||
352
src/routes/_authenticated/asignatura/$asignaturaId.tsx
Normal file
352
src/routes/_authenticated/asignatura/$asignaturaId.tsx
Normal file
@@ -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<string, Record<string, string>> | 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 (
|
||||
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-neutral-500">{label}</div>
|
||||
<div className="text-lg font-semibold tabular-nums">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ id, title, icon: Icon, children }:{
|
||||
id: string; title: string; icon: any; children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 scroll-mt-24">
|
||||
<header className="flex items-center gap-2 mb-2">
|
||||
<div className="h-8 w-8 rounded-lg grid place-items-center border bg-white/80"><Icon className="h-4 w-4" /></div>
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* ================== Página ================== */
|
||||
function Page() {
|
||||
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<HTMLInputElement | null>(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 (
|
||||
<div className="relative p-6 space-y-6">
|
||||
{/* ===== Migas ===== */}
|
||||
<nav className="text-sm text-neutral-500">
|
||||
<Link
|
||||
to={plan ? "/plan/$planId" : "/planes"}
|
||||
params={plan ? { planId: plan.id } : undefined}
|
||||
className="hover:underline"
|
||||
>
|
||||
{plan ? plan.nombre : "Planes"}
|
||||
</Link>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="text-neutral-900">{a.nombre}</span>
|
||||
</nav>
|
||||
|
||||
{/* ===== Hero ===== */}
|
||||
<div className="relative overflow-hidden rounded-3xl border shadow-sm">
|
||||
<div className={`absolute inset-0 bg-gradient-to-br ${style.halo} via-white to-transparent`} />
|
||||
<div className="relative p-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="inline-flex items-center gap-2 text-xs text-neutral-600">
|
||||
<Icons.BookOpen className="h-4 w-4" /> Asignatura
|
||||
{plan && <>
|
||||
<span>·</span>
|
||||
<Link to="/plan/$planId" params={{ planId: plan.id }} className="hover:underline">
|
||||
{plan.nombre}
|
||||
</Link>
|
||||
</>}
|
||||
</div>
|
||||
<h1 className="mt-1 text-2xl md:text-3xl font-bold truncate">{a.nombre}</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px]">
|
||||
{a.clave && <Badge variant="outline">Clave: {a.clave}</Badge>}
|
||||
{a.tipo && <Badge className={style.chip} variant="secondary">{a.tipo}</Badge>}
|
||||
{a.creditos != null && <Badge variant="outline">{a.creditos} créditos</Badge>}
|
||||
<Badge variant="outline">H T/P: {horasT}/{horasP}</Badge>
|
||||
<Badge variant="outline">Semestre {a.semestre ?? "—"}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Acciones rápidas */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => window.print()}>
|
||||
<Icons.Printer className="h-4 w-4 mr-2" /> Imprimir
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={share}>
|
||||
<Icons.Share2 className="h-4 w-4 mr-2" /> Compartir
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats rápidos */}
|
||||
<div className="relative px-6 pb-6">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Stat icon={Icons.Coins} label="Créditos" value={a.creditos ?? "—"} />
|
||||
<Stat icon={Icons.Clock} label="Horas totales" value={horas} />
|
||||
<Stat icon={Icons.ListTree} label="Unidades" value={unidades.length} />
|
||||
<Stat icon={Icons.BookMarked} label="Temas" value={temasCount} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Layout principal ===== */}
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr,320px]">
|
||||
{/* ===== Columna principal ===== */}
|
||||
<div className="space-y-6">
|
||||
{/* Objetivo */}
|
||||
{a.objetivos && (
|
||||
<Section id="objetivo" title="Objetivo de la asignatura" icon={Icons.Target}>
|
||||
<p className="text-sm leading-relaxed text-neutral-800">{a.objetivos}</p>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Syllabus */}
|
||||
{unidades.length > 0 && (
|
||||
<Section id="syllabus" title="Programa / Contenidos" icon={Icons.ListTree}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="relative flex-1">
|
||||
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||
<Input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar tema dentro del programa (⌘/Ctrl K)…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
{query && (
|
||||
<Button variant="ghost" onClick={() => setQuery("")}>
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Accordion type="multiple" className="mt-2">
|
||||
{filteredUnidades.map((u, i) => (
|
||||
<AccordionItem key={u.key} value={`u-${i}`} className="border rounded-xl mb-2 overflow-hidden">
|
||||
<AccordionTrigger className="px-4 py-2 hover:no-underline text-left">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="font-medium">
|
||||
{/^\s*\d+/.test(u.key) ? `Unidad ${u.key}` : u.title}
|
||||
</span>
|
||||
<span className="text-[11px] text-neutral-500">{u.temas.length} tema(s)</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-5 pb-3">
|
||||
<ul className="list-disc ml-5 text-[13px] text-neutral-700 space-y-1">
|
||||
{u.temas.map(([k, t]) => <li key={k} className="break-words">{t}</li>)}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
{filteredUnidades.length === 0 && (
|
||||
<div className="text-sm text-neutral-500 py-6 text-center">No hay temas que coincidan.</div>
|
||||
)}
|
||||
</Accordion>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Bibliografía */}
|
||||
{a.bibliografia && a.bibliografia.length > 0 && (
|
||||
<Section id="bibliografia" title="Bibliografía" icon={Icons.LibraryBig}>
|
||||
<ul className="space-y-2 text-sm text-neutral-800">
|
||||
{a.bibliografia.map((ref, i) => (
|
||||
<li key={i} className="flex items-start gap-2 leading-relaxed">
|
||||
<span className="mt-1 text-neutral-400">•</span>
|
||||
<span className="break-words">{ref}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Evaluación */}
|
||||
{a.criterios_evaluacion && (
|
||||
<Section id="evaluacion" title="Criterios de evaluación" icon={Icons.ClipboardCheck}>
|
||||
<p className="text-sm text-neutral-800 leading-relaxed">{a.criterios_evaluacion}</p>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== Sidebar ===== */}
|
||||
<aside className="space-y-4 lg:sticky lg:top-6 self-start">
|
||||
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4">
|
||||
<h4 className="font-semibold text-sm mb-2">Resumen</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<MiniKV label="Créditos" value={a.creditos ?? "—"} />
|
||||
<MiniKV label="Semestre" value={a.semestre ?? "—"} />
|
||||
<MiniKV label="Horas teóricas" value={horasT} />
|
||||
<MiniKV label="Horas prácticas" value={horasP} />
|
||||
<MiniKV label="Unidades" value={unidades.length} />
|
||||
<MiniKV label="Temas" value={temasCount} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4">
|
||||
<h4 className="font-semibold text-sm mb-2">Navegación</h4>
|
||||
<nav className="text-sm space-y-1">
|
||||
{a.objetivos && <Anchor href="#objetivo" label="Objetivo" />}
|
||||
{unidades.length > 0 && <Anchor href="#syllabus" label="Programa / Contenidos" />}
|
||||
{a.bibliografia && a.bibliografia.length > 0 && <Anchor href="#bibliografia" label="Bibliografía" />}
|
||||
{a.criterios_evaluacion && <Anchor href="#evaluacion" label="Evaluación" />}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{plan && (
|
||||
<Link
|
||||
to="/plan/$planId"
|
||||
params={{ planId: plan.id }}
|
||||
className="block rounded-2xl border p-4 hover:bg-neutral-50 transition"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
|
||||
<Icons.ScrollText className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-neutral-500">Plan de estudios</div>
|
||||
<div className="font-medium truncate">{plan.nombre}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* ===== Volver ===== */}
|
||||
<div className="pt-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={plan ? "/plan/$planId" : "/planes"} params={plan ? { planId: plan.id } : undefined}>
|
||||
<Icons.ArrowLeft className="h-4 w-4 mr-2" /> Volver
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== Bits Sidebar ===== */
|
||||
function MiniKV({ label, value }:{ label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-white/60 p-2">
|
||||
<div className="text-[11px] text-neutral-500">{label}</div>
|
||||
<div className="font-medium tabular-nums">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function Anchor({ href, label }:{ href: string; label: string }) {
|
||||
return (
|
||||
<a href={href} className="flex items-center gap-2 text-neutral-700 hover:underline">
|
||||
<Icons.Dot className="h-5 w-5 -ml-1" /> {label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
447
src/routes/_authenticated/asignaturas.tsx
Normal file
447
src/routes/_authenticated/asignaturas.tsx
Normal file
@@ -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<string, Record<string, string>> | 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<string, unknown>) => {
|
||||
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<LoaderData> => {
|
||||
// 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<string>('todos')
|
||||
const [tipo, setTipo] = useState<string>('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<string>()
|
||||
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<string>()
|
||||
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<number | string, Asignatura[]>()
|
||||
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 (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* HEADER */}
|
||||
<div className="rounded-3xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm">
|
||||
<div className="p-5 flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<Icons.BookOpen className="w-5 h-5" />
|
||||
Asignaturas
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Crear nueva — puedes cambiar el destino si ya tienes ruta específica */}
|
||||
<Link
|
||||
to="/planes"
|
||||
className="hidden sm:inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
|
||||
search={{ crear: 'asignatura' }}
|
||||
>
|
||||
<Icons.Plus className="w-4 h-4" /> Nueva asignatura
|
||||
</Link>
|
||||
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
|
||||
<Icons.RefreshCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr,140px,180px,150px]">
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Buscar por nombre, clave, plan, carrera, facultad…"
|
||||
className="w-full"
|
||||
/>
|
||||
<Select value={sem} onValueChange={setSem}>
|
||||
<SelectTrigger><SelectValue placeholder="Semestre" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
{semestres.map(s => <SelectItem key={s} value={s}>Semestre {s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={tipo} onValueChange={setTipo}>
|
||||
<SelectTrigger><SelectValue placeholder="Tipo" /></SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
{tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}>
|
||||
<SelectTrigger><SelectValue placeholder="Agrupar por" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="semestre">Agrupar por semestre</SelectItem>
|
||||
<SelectItem value="ninguno">Sin agrupación</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Chips de salud (toggle) */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<HealthChip
|
||||
active={flag === 'sinBibliografia'}
|
||||
onClick={() => setFlag(flag === 'sinBibliografia' ? '' : 'sinBibliografia')}
|
||||
icon={<Icons.BookMarked className="w-3.5 h-3.5" />}
|
||||
label="Sin bibliografía"
|
||||
value={salud.sinBibliografia}
|
||||
/>
|
||||
<HealthChip
|
||||
active={flag === 'sinCriterios'}
|
||||
onClick={() => setFlag(flag === 'sinCriterios' ? '' : 'sinCriterios')}
|
||||
icon={<Icons.ClipboardX className="w-3.5 h-3.5" />}
|
||||
label="Sin criterios de evaluación"
|
||||
value={salud.sinCriterios}
|
||||
/>
|
||||
<HealthChip
|
||||
active={flag === 'sinContenidos'}
|
||||
onClick={() => setFlag(flag === 'sinContenidos' ? '' : 'sinContenidos')}
|
||||
icon={<Icons.ListX className="w-3.5 h-3.5" />}
|
||||
label="Sin contenidos"
|
||||
value={salud.sinContenidos}
|
||||
/>
|
||||
{(q || sem !== 'todos' || tipo !== 'todos' || flag) && (
|
||||
<Button variant="ghost" className="h-7 px-3" onClick={clearFilters}>
|
||||
Limpiar filtros
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LISTA */}
|
||||
<div className="space-y-6">
|
||||
{!groups.length && <div className="text-sm text-neutral-500 text-center py-16">Sin asignaturas</div>}
|
||||
|
||||
{groups.map(([key, items]) => (
|
||||
<section key={String(key)} className="space-y-2">
|
||||
{groupBy !== 'ninguno' && (
|
||||
<div className="sticky top-[64px] -mx-6 px-6 py-1 bg-background/90 backdrop-blur border-l-4 border-primary/30 text-xs font-semibold text-neutral-600 z-10">
|
||||
Semestre {key}
|
||||
</div>
|
||||
)}
|
||||
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map(a => <AsignaturaCard key={a.id} a={a} />)}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ================== 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 <span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] ${className}`}>{children}</span>
|
||||
}
|
||||
|
||||
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 (
|
||||
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm hover:shadow-md transition-all">
|
||||
<div className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border bg-white/80">
|
||||
<meta.Icon className="h-4 w-4" />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="font-semibold leading-tight truncate" title={a.nombre}>{a.nombre}</h4>
|
||||
{/* Menú rápido (placeholder extensible) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="mt-[-2px]"><Icons.MoreVertical className="w-4 h-4" /></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
<DropdownMenuItem asChild className="gap-2">
|
||||
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: a.id }}>
|
||||
<Icons.FolderOpen className="w-4 h-4" /> Abrir
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="gap-2">
|
||||
<Link to="/plan/$planId" params={{ planId: a.plan?.id ?? '' }}>
|
||||
<Icons.ScrollText className="w-4 h-4" /> Ver plan
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
{a.clave && <Chip className="bg-white/70"><Icons.KeyRound className="h-3 w-3" /> {a.clave}</Chip>}
|
||||
<Chip className={meta.cls}><meta.Icon className="h-3 w-3" /> {meta.label}</Chip>
|
||||
{a.creditos != null && <Chip className="bg-white/70"><Icons.Coins className="h-3 w-3" /> {a.creditos} créditos</Chip>}
|
||||
{(horasT + horasP) > 0 && <Chip className="bg-white/70"><Icons.Clock className="h-3 w-3" /> H T/P: {horasT}/{horasP}</Chip>}
|
||||
<Chip className="bg-white/70"><Icons.CalendarDays className="h-3 w-3" /> Semestre {a.semestre ?? '—'}</Chip>
|
||||
</div>
|
||||
|
||||
{/* Contexto del plan/carrera/facultad */}
|
||||
{a.plan && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-neutral-600">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icons.ScrollText className="w-3.5 h-3.5" /> {a.plan.nombre}
|
||||
</span>
|
||||
{a.plan.carrera && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icons.GraduationCap className="w-3.5 h-3.5" /> {a.plan.carrera.nombre}
|
||||
</span>
|
||||
)}
|
||||
{a.plan.carrera?.facultad && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<FacIcon className="w-3.5 h-3.5" /> {a.plan.carrera.facultad.nombre}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Objetivo resumido + CTA */}
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<p className="text-xs text-neutral-700 line-clamp-2">{a.objetivos ?? '—'}</p>
|
||||
<Link
|
||||
to="/asignatura/$asignaturaId"
|
||||
params={{ asignaturaId: a.id }}
|
||||
className="ml-3 inline-flex items-center gap-1 rounded-lg border px-3 py-1.5 text-xs hover:bg-neutral-50"
|
||||
>
|
||||
Ver <Icons.ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/* ================== UI helpers ================== */
|
||||
function HealthChip({
|
||||
active, onClick, icon, label, value,
|
||||
}: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string; value: number }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`inline-flex items-center gap-2 rounded-xl px-3 py-1.5 text-xs ring-1 transition-colors
|
||||
${active
|
||||
? 'bg-amber-50 text-amber-800 ring-amber-300'
|
||||
: 'bg-white/70 text-neutral-700 ring-neutral-200 hover:bg-neutral-50'}`}
|
||||
>
|
||||
{icon} {label}
|
||||
<span className={`ml-1 inline-flex h-5 min-w-[1.5rem] items-center justify-center rounded-full px-1 text-[11px]
|
||||
${active ? 'bg-amber-100 text-amber-900' : 'bg-neutral-100 text-neutral-800'}`}>
|
||||
{value}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* ================== Skeleton ================== */
|
||||
function Pulse({ className = '' }: { className?: string }) {
|
||||
return <div className={`animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-xl ${className}`} />
|
||||
}
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Pulse className="h-36" />
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 9 }).map((_, i) => <Pulse key={i} className="h-32" />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, Record<string, string>> | 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<ModalData> => {
|
||||
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<string>("todos")
|
||||
const [tipo, setTipo] = useState<string>("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<HTMLInputElement | null>(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<string>()
|
||||
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<string>()
|
||||
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<string, number> = {}
|
||||
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<number | string, Asignatura[]>()
|
||||
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 (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={() =>
|
||||
router.navigate({ to: "/plan/$planId", params: { planId }, replace: true })
|
||||
}
|
||||
onOpenChange={() => router.navigate({ to: "/plan/$planId", params: { planId }, replace: true })}
|
||||
>
|
||||
<DialogContent className="w-[min(92vw,900px)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Icons.BookOpen className="w-5 h-5" />
|
||||
Asignaturas · <span className="font-normal">{planNombre}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogContent className="p-0 min-w-[94vw] h-[min(94vh,920px)] sm:rounded-2xl overflow-hidden flex flex-col">
|
||||
{/* HERO ===================================================== */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-primary/10 to-transparent" />
|
||||
<div className="relative px-5 pt-5 pb-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-500">
|
||||
<Icons.ScrollText className="h-4 w-4" />
|
||||
Plan
|
||||
</div>
|
||||
<DialogHeader className="p-0">
|
||||
<DialogTitle className="truncate text-xl sm:text-2xl">{planNombre}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<KpiChip icon={Icons.BookOpen} label="Asignaturas" value={kpis.total} />
|
||||
<KpiChip icon={Icons.Coins} label="Créditos" value={kpis.creditos} />
|
||||
<KpiChip icon={Icons.Clock} label="Horas T/P" value={`${kpis.ht}/${kpis.hp}`} />
|
||||
{Object.entries(kpis.porTipo).slice(0, 3).map(([t, n]) => (
|
||||
<Badge key={t} variant="secondary" className="text-[10px]">
|
||||
{t} · {n}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pb-3">
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Buscar por nombre, semestre…"
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/plan/$planId" params={{ planId }}>Ir al plan</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TOOLBAR sticky ========================================= */}
|
||||
<div className="sticky top-0 z-10 border-y bg-white/90 backdrop-blur">
|
||||
<div className="px-5 py-3 grid gap-2 items-center
|
||||
sm:grid-cols-[1fr,140px,160px,160px,auto]">
|
||||
<div className="relative">
|
||||
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||
<Input
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar (⌘/Ctrl K)…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={sem} onValueChange={setSem}>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder="Semestre" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
{semestres.map(s => <SelectItem key={s} value={s}>Semestre {s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={tipo} onValueChange={setTipo}>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder="Tipo" /></SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
{tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={orden} onValueChange={(v) => setOrden(v as any)}>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder="Ordenar por" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="semestre">Semestre</SelectItem>
|
||||
<SelectItem value="nombre">Nombre</SelectItem>
|
||||
<SelectItem value="creditos">Créditos</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ViewToggle value={vista} onChange={setVista} />
|
||||
{(query || sem !== "todos" || tipo !== "todos" || orden !== "semestre") && (
|
||||
<Button variant="ghost" size="sm" onClick={limpiar}>
|
||||
<Icons.Eraser className="h-4 w-4 mr-1" /> Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[65vh] overflow-auto pr-1">
|
||||
{groups.length === 0 && (
|
||||
<div className="text-sm text-neutral-500 py-8 text-center">Sin asignaturas</div>
|
||||
{/* CONTENIDO scrolleable ==================================== */}
|
||||
<div className="flex-1 overflow-auto px-5 py-5">
|
||||
{filtradas.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : vista === "tabla" ? (
|
||||
<Tabla asignaturas={filtradas} />
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{grupos.map(([sem, items]) => (
|
||||
<section key={String(sem)} className="space-y-3">
|
||||
<h3 className="text-xs font-semibold text-neutral-600">Semestre {sem}</h3>
|
||||
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{items.map(a => <AsignaturaCard key={a.id} a={a} />)}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-5">
|
||||
{groups.map(([sem, items]) => (
|
||||
<div key={String(sem)}>
|
||||
<div className="mb-2 text-xs font-semibold text-neutral-500">
|
||||
Semestre {sem}
|
||||
</div>
|
||||
<ul className="grid gap-2 sm:grid-cols-2">
|
||||
{items.map(a => (
|
||||
<li key={a.id} className="rounded-xl border p-3 bg-white/70 dark:bg-neutral-900/60">
|
||||
<div className="font-medium truncate">{a.nombre}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-neutral-600">
|
||||
{a.creditos != null && (
|
||||
<Badge variant="outline" className="bg-white/60">Créditos: {a.creditos}</Badge>
|
||||
)}
|
||||
{(a.horas_teoricas ?? 0) + (a.horas_practicas ?? 0) > 0 && (
|
||||
<Badge variant="secondary" className="bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-100">
|
||||
Hrs T/P: {a.horas_teoricas ?? 0}/{a.horas_practicas ?? 0}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/* ================== UI bits ================== */
|
||||
function KpiChip({ icon: Icon, label, value }:{ icon: any; label: string; value: number | string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border bg-white/60 px-2 py-0.5 text-[11px]">
|
||||
<Icon className="h-3.5 w-3.5" /> {label}: <span className="font-medium">{value}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ViewToggle({ value, onChange }:{ value:"cards"|"tabla"; onChange:(v:"cards"|"tabla")=>void }) {
|
||||
return (
|
||||
<div className="inline-flex rounded-lg border bg-white overflow-hidden">
|
||||
<button
|
||||
className={`px-2.5 py-1.5 text-xs flex items-center gap-1 ${value==="cards" ? "bg-neutral-100" : ""}`}
|
||||
onClick={() => onChange("cards")}
|
||||
title="Tarjetas"
|
||||
>
|
||||
<Icons.LayoutGrid className="h-4 w-4" /> Cards
|
||||
</button>
|
||||
<button
|
||||
className={`px-2.5 py-1.5 text-xs flex items-center gap-1 border-l ${value==="tabla" ? "bg-neutral-100" : ""}`}
|
||||
onClick={() => onChange("tabla")}
|
||||
title="Tabla compacta"
|
||||
>
|
||||
<Icons.Table2 className="h-4 w-4" /> Tabla
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="grid place-items-center h-[48vh]">
|
||||
<div className="text-center max-w-sm">
|
||||
<div className="mx-auto w-12 h-12 rounded-2xl grid place-items-center bg-neutral-100">
|
||||
<Icons.Inbox className="h-6 w-6 text-neutral-500" />
|
||||
</div>
|
||||
<h4 className="mt-3 font-semibold">Sin resultados</h4>
|
||||
<p className="text-sm text-neutral-600">Ajusta los filtros o la búsqueda.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ================== 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 (
|
||||
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 shadow-sm hover:shadow-md transition-all">
|
||||
{/* franja lateral por tipo */}
|
||||
<span
|
||||
aria-hidden
|
||||
className={`absolute left-0 top-0 h-full w-1 bg-${meta.color}-500/80`}
|
||||
/>
|
||||
<div className="p-3 pl-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`mt-0.5 h-8 w-8 rounded-xl grid place-items-center border bg-white/80`}>
|
||||
<Icons.BookOpen className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="font-semibold leading-tight truncate" title={a.nombre}>{a.nombre}</h4>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
{a.clave && <Chip><Icons.KeyRound className="h-3 w-3" /> {a.clave}</Chip>}
|
||||
<Chip className={`bg-${meta.color}-50 text-${meta.color}-800 border-${meta.color}-200`}>
|
||||
<Icons.BadgeCheck className="h-3 w-3" /> {meta.label}
|
||||
</Chip>
|
||||
{a.creditos != null && <Chip><Icons.Coins className="h-3 w-3" /> {a.creditos} créditos</Chip>}
|
||||
{(horasT + horasP) > 0 && <Chip><Icons.Clock className="h-3 w-3" /> H T/P: {horasT}/{horasP}</Chip>}
|
||||
<Chip><Icons.CalendarDays className="h-3 w-3" /> Semestre {a.semestre ?? "—"}</Chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="mt-[-2px]">
|
||||
<Icons.MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[190px]">
|
||||
<DropdownMenuItem className="gap-2" asChild>
|
||||
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: a.id }}>
|
||||
<Icons.FileText className="h-4 w-4" /> Ver detalles
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2">
|
||||
<Icons.Pencil className="h-4 w-4" /> Editar
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2">
|
||||
<Icons.Sparkles className="h-4 w-4" /> Ajustar con IA
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* objetivo (clamp) */}
|
||||
{a.objetivos && (
|
||||
<p className="mt-2 text-xs text-neutral-700 line-clamp-2">{a.objetivos}</p>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Link
|
||||
to="/asignatura/$asignaturaId"
|
||||
params={{ asignaturaId: a.id }}
|
||||
className="inline-flex items-center gap-1 rounded-lg border px-3 py-1.5 text-xs hover:bg-neutral-50"
|
||||
>
|
||||
Ver ficha <Icons.ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ children, className = "" }:{ children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full border bg-white/70 px-2 py-0.5 text-[11px] ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/* ================== Tabla compacta ================== */
|
||||
function Tabla({ asignaturas }: { asignaturas: Asignatura[] }) {
|
||||
return (
|
||||
<div className="rounded-2xl border bg-white/70 overflow-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white/90 backdrop-blur text-[12px]">
|
||||
<tr className="[&>th]:px-3 [&>th]:py-2 text-left text-neutral-500">
|
||||
<th>Nombre</th>
|
||||
<th className="whitespace-nowrap">Clave</th>
|
||||
<th>Tipo</th>
|
||||
<th>Sem.</th>
|
||||
<th>Créditos</th>
|
||||
<th className="whitespace-nowrap">H T/P</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="[&>tr:nth-child(odd)]:bg-neutral-50/40">
|
||||
{asignaturas.map(a => (
|
||||
<tr key={a.id} className="align-top">
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium truncate max-w-[36ch]" title={a.nombre}>{a.nombre}</div>
|
||||
{a.objetivos && <div className="text-xs text-neutral-600 line-clamp-1 max-w-[56ch]">{a.objetivos}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-neutral-700">{a.clave ?? "—"}</td>
|
||||
<td className="px-3 py-2">{a.tipo ?? "—"}</td>
|
||||
<td className="px-3 py-2 tabular-nums">{a.semestre ?? "—"}</td>
|
||||
<td className="px-3 py-2 tabular-nums">{a.creditos ?? "—"}</td>
|
||||
<td className="px-3 py-2 tabular-nums">
|
||||
{(a.horas_teoricas ?? 0)}/{(a.horas_practicas ?? 0)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Icons.MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
<DropdownMenuItem asChild className="gap-2">
|
||||
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: a.id }}>
|
||||
<Icons.FileText className="h-4 w-4" /> Ver detalles
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2">
|
||||
<Icons.Pencil className="h-4 w-4" /> Editar
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2">
|
||||
<Icons.Sparkles className="h-4 w-4" /> Ajustar con IA
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<LoaderData> => {
|
||||
// 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 <div>Hello "/_authenticated/dashboard"!</div>
|
||||
/* ========= 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 (
|
||||
<div className="flex items-center gap-4">
|
||||
<svg width="112" height="112" viewBox="0 0 112 112">
|
||||
<circle cx="56" cy="56" r={R} fill="none" stroke="#e5e7eb" strokeWidth="12" />
|
||||
<circle
|
||||
cx="56"
|
||||
cy="56"
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="12"
|
||||
strokeDasharray={C}
|
||||
strokeDashoffset={off}
|
||||
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 Tile({
|
||||
to,
|
||||
label,
|
||||
value,
|
||||
Icon
|
||||
}: {
|
||||
to: string
|
||||
label: string
|
||||
value: number | string
|
||||
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="group rounded-2xl ring-1 ring-black/5 bg-white/80 dark:bg-neutral-900/60 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 dark:bg-neutral-800 group-hover:bg-neutral-200">
|
||||
<Icon className="w-7 h-7" />
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/* ========= 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 (
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Header con saludo y búsqueda global */}
|
||||
<div className="relative rounded-3xl overflow-hidden shadow-xl text-white" style={gradient(primary)}>
|
||||
<div
|
||||
className="absolute inset-0 opacity-25"
|
||||
style={{ background: 'radial-gradient(800px 300px at 20% -10%, #fff, transparent 60%)' }}
|
||||
/>
|
||||
<div className="relative p-6 md:p-8 flex flex-col gap-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl md:text-3xl font-bold leading-tight">Hola, {name}</h1>
|
||||
<p className="opacity-95">{roleHint}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{role && <Badge variant="secondary" className="bg-white/20 text-white border-white/30">{role}</Badge>}
|
||||
{isAdmin && (
|
||||
<Badge className="bg-white/20 text-white border-white/30 flex items-center gap-1">
|
||||
<Icons.ShieldCheck className="w-3.5 h-3.5" /> admin
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
placeholder="Buscar planes, asignaturas o personas… (Enter)"
|
||||
className="bg-white/90 text-neutral-800 placeholder:text-neutral-400"
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
const q = (e.target as HTMLInputElement).value.trim()
|
||||
if (!q) return
|
||||
router.navigate({ to: '/planes', search: { q } })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="bg-white/20 text-white hover:bg-white/30 border-white/30"
|
||||
onClick={() => router.invalidate()}
|
||||
title="Actualizar"
|
||||
>
|
||||
<Icons.RefreshCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Atajos rápidos (según rol) */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
to="/planes"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30"
|
||||
>
|
||||
<Icons.ScrollText className="w-4 h-4" /> Nuevo plan
|
||||
</Link>
|
||||
<Link
|
||||
to="/asignaturas"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30">
|
||||
<Icons.BookOpen className="w-4 h-4" /> Nueva asignatura
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
to="/usuarios"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30"
|
||||
>
|
||||
<Icons.UserPlus className="w-4 h-4" /> Invitar usuario
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPIs principales */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Tile to="/_authenticated/facultades" label="Facultades" value={kpis.facultades} Icon={Icons.Building2} />
|
||||
<Tile to="/_authenticated/carreras" label="Carreras" value={kpis.carreras} Icon={Icons.GraduationCap} />
|
||||
<Tile to="/_authenticated/planes" label="Planes de estudio" value={kpis.planes} Icon={Icons.ScrollText} />
|
||||
<Tile to="/_authenticated/asignaturas" label="Asignaturas" value={kpis.asignaturas} Icon={Icons.BookOpen} />
|
||||
</div>
|
||||
|
||||
{/* Calidad + Salud */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icons.CheckCircle2 className="w-5 h-5" /> Calidad de planes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Ring pct={calidadPlanesPct} color={hex(auth.claims?.facultad_color, '#2563eb')} />
|
||||
<p className="mt-3 text-sm text-neutral-600">
|
||||
Considera <span className="font-medium">objetivo general, perfiles, sistema de evaluación y créditos</span>.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icons.HeartPulse className="w-5 h-5" /> Salud de asignaturas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<HealthRow
|
||||
to="/_authenticated/asignaturas?f=sinBibliografia"
|
||||
label="Sin bibliografía"
|
||||
value={saludAsignaturas.sinBibliografia}
|
||||
/>
|
||||
<HealthRow
|
||||
to="/_authenticated/asignaturas?f=sinCriterios"
|
||||
label="Sin criterios de evaluación"
|
||||
value={saludAsignaturas.sinCriterios}
|
||||
/>
|
||||
<HealthRow
|
||||
to="/_authenticated/asignaturas?f=sinContenidos"
|
||||
label="Sin contenidos"
|
||||
value={saludAsignaturas.sinContenidos}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Actividad reciente */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Icons.Activity className="w-5 h-5" /> Actividad reciente
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recientes.length === 0 && (
|
||||
<div className="text-sm text-neutral-500">Sin actividad registrada.</div>
|
||||
)}
|
||||
<ul className="divide-y">
|
||||
{recientes.map(r => (
|
||||
<li key={`${r.tipo}-${r.id}`} className="py-2 flex items-center justify-between gap-3">
|
||||
<Link
|
||||
to={r.tipo === 'plan' ? '/plan/$planId' : '/asignatura/$asignaturaId'}
|
||||
params={r.tipo === 'plan' ? { planId: r.id } : { asignaturaId: r.id }}
|
||||
className="truncate inline-flex items-center gap-2 hover:underline"
|
||||
title={r.nombre}
|
||||
>
|
||||
{r.tipo === 'plan' ? (
|
||||
<Icons.ScrollText className="w-4 h-4 text-neutral-500" />
|
||||
) : (
|
||||
<Icons.BookOpen className="w-4 h-4 text-neutral-500" />
|
||||
)}
|
||||
<span className="truncate">{r.nombre}</span>
|
||||
</Link>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{r.fecha ? new Date(r.fecha).toLocaleDateString() : ''}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ========= Subcomponentes ========= */
|
||||
function HealthRow({ 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 ring-1 ${warn
|
||||
? 'ring-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100'
|
||||
: 'ring-neutral-200 hover:bg-neutral-50'
|
||||
} transition-colors`}
|
||||
>
|
||||
<span className="text-sm">{label}</span>
|
||||
<span className="text-lg font-semibold tabular-nums">{value}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/* ========= Skeleton (cuando carga) ========= */
|
||||
function Pulse({ className = '' }: { className?: string }) {
|
||||
return <div className={`animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-xl ${className}`} />
|
||||
}
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="p-6 space-y-8">
|
||||
<div className="rounded-3xl p-8">
|
||||
<Pulse className="h-24" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Pulse key={i} className="h-28" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Pulse className="h-64 lg:col-span-2" />
|
||||
<Pulse className="h-64" />
|
||||
</div>
|
||||
<Pulse className="h-72" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,8 +129,7 @@ function ProgressRing({ pct, color }: { pct: number, color: string }) {
|
||||
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={color} strokeWidth="12" />
|
||||
<circle cx="56" cy="56" r={r} fill="none" stroke="currentColor" strokeWidth="12"
|
||||
<circle cx="56" cy="56" r={r} fill="none" stroke={color} strokeWidth="12"
|
||||
strokeDasharray={c} strokeDashoffset={offset} strokeLinecap="round"
|
||||
transform="rotate(-90 56 56)" />
|
||||
</svg>
|
||||
|
||||
@@ -216,13 +216,7 @@ function RouteComponent() {
|
||||
{plan.estado}
|
||||
</Badge>
|
||||
)}
|
||||
<Link
|
||||
to="/asignaturas/$planId"
|
||||
params={{ planId: plan.id }}
|
||||
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
|
||||
>
|
||||
<Icons.BookOpen className="w-4 h-4" /> Ver asignaturas
|
||||
</Link>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<EditPlanButton plan={plan} />
|
||||
<AdjustAIButton plan={plan} />
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
title={label}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-lg border px-2.5 py-1 text-xs leading-none bg-white/70 text-neutral-800"
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-lg border px-2.5 py-1 text-xs leading-none"
|
||||
style={style}
|
||||
>
|
||||
{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() {
|
||||
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button>
|
||||
<Button onClick={() => setOpenCreate(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Nuevo plan
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -184,6 +192,193 @@ function RouteComponent() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreatePlanDialog
|
||||
open={openCreate}
|
||||
onOpenChange={setOpenCreate}
|
||||
onCreated={(id) => {
|
||||
setOpenCreate(false)
|
||||
router.invalidate()
|
||||
router.navigate({ to: "/plan/$planId", params: { planId: id } })
|
||||
}}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[min(92vw,720px)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nuevo plan de estudios</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Nombre *</Label>
|
||||
<Input
|
||||
value={form.nombre}
|
||||
onChange={(e) => setForm(s => ({ ...s, nombre: e.target.value }))}
|
||||
placeholder="Ej. Licenciatura en Ciberseguridad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Nivel</Label>
|
||||
<Input
|
||||
value={form.nivel}
|
||||
onChange={(e) => setForm(s => ({ ...s, nivel: e.target.value }))}
|
||||
placeholder="Licenciatura / Maestría…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Duración</Label>
|
||||
<Input
|
||||
value={form.duracion}
|
||||
onChange={(e) => setForm(s => ({ ...s, duracion: e.target.value }))}
|
||||
placeholder="9 semestres / 3 años…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Créditos totales</Label>
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
value={form.total_creditos ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim()
|
||||
setForm(s => ({ ...s, total_creditos: v === "" ? null : Number(v) || 0 }))
|
||||
}}
|
||||
placeholder="270"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Objetivo general</Label>
|
||||
<Textarea
|
||||
value={form.objetivo_general ?? ""}
|
||||
onChange={(e) => setForm(s => ({ ...s, objetivo_general: e.target.value }))}
|
||||
placeholder="Describe el objetivo general del plan…"
|
||||
className="min-h-[90px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Procedencia */}
|
||||
{needsFacultad && (
|
||||
<div className="space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<FacultadCombobox
|
||||
value={form.facultad_id}
|
||||
onChange={(id) =>
|
||||
setForm(s => ({
|
||||
...s,
|
||||
facultad_id: id,
|
||||
carrera_id: lockCarrera ? s.carrera_id : "", // limpia carrera si no está bloqueada
|
||||
}))
|
||||
}
|
||||
disabled={lockFacultad}
|
||||
/>
|
||||
{lockFacultad && (
|
||||
<p className="text-[11px] text-neutral-500">Fijado por tu rol.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsCarrera && (
|
||||
<div className="space-y-1">
|
||||
<Label>Carrera *</Label>
|
||||
<CarreraCombobox
|
||||
facultadId={form.facultad_id}
|
||||
value={form.carrera_id}
|
||||
onChange={(id) => setForm(s => ({ ...s, carrera_id: id }))}
|
||||
disabled={lockCarrera || !form.facultad_id}
|
||||
placeholder={form.facultad_id ? "Selecciona carrera…" : "Selecciona una facultad primero"}
|
||||
/>
|
||||
{lockCarrera && (
|
||||
<p className="text-[11px] text-neutral-500">Fijada por tu rol.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||
|
||||
<DialogFooter className="flex flex-col-reverse sm:flex-row gap-2">
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
||||
<Button className="w-full sm:w-auto" onClick={createPlan} disabled={saving}>
|
||||
{saving ? "Creando…" : "Crear plan"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
||||
import {
|
||||
RefreshCcw, ShieldCheck, ShieldAlert, Pencil, Mail, CheckCircle2, XCircle,
|
||||
Cpu, Building2, ScrollText, GraduationCap, GanttChart
|
||||
Cpu, Building2, ScrollText, GraduationCap, GanttChart, Plus, Eye, EyeOff
|
||||
} from "lucide-react"
|
||||
import { SupabaseClient } from "@supabase/supabase-js"
|
||||
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
||||
import { toast } from "sonner"
|
||||
|
||||
|
||||
type AdminUser = {
|
||||
id: string
|
||||
@@ -106,6 +108,70 @@ function RouteComponent() {
|
||||
carrera_id?: string | null;
|
||||
}>({})
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createSaving, setCreateSaving] = useState(false)
|
||||
const [showPwd, setShowPwd] = useState(false)
|
||||
const [createForm, setCreateForm] = useState<{
|
||||
email: string
|
||||
password: string
|
||||
role?: Role
|
||||
claims_admin?: boolean
|
||||
nombre?: string; apellidos?: string; title?: string; clave?: string; avatar?: string
|
||||
facultad_id?: string | null
|
||||
carrera_id?: string | null
|
||||
}>({ email: "", password: "" })
|
||||
|
||||
function genPassword() {
|
||||
// 14 chars pseudo-aleatoria
|
||||
const s = Array.from(crypto.getRandomValues(new Uint32Array(4)))
|
||||
.map(n => n.toString(36)).join("")
|
||||
return s.slice(0, 14)
|
||||
}
|
||||
|
||||
async function createUserNow() {
|
||||
if (!createForm.email?.trim()) { toast.error("Correo requerido"); return }
|
||||
try {
|
||||
setCreateSaving(true)
|
||||
const admin = new SupabaseClient(
|
||||
import.meta.env.VITE_SUPABASE_URL,
|
||||
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY
|
||||
)
|
||||
|
||||
const password = createForm.password?.trim() || genPassword()
|
||||
|
||||
const { error } = await admin.auth.admin.createUser({
|
||||
email: createForm.email.trim(),
|
||||
password,
|
||||
email_confirm: false,
|
||||
user_metadata: {
|
||||
nombre: createForm.nombre ?? "",
|
||||
apellidos: createForm.apellidos ?? "",
|
||||
title: createForm.title ?? "",
|
||||
clave: createForm.clave ?? "",
|
||||
avatar: createForm.avatar ?? ""
|
||||
},
|
||||
app_metadata: {
|
||||
role: createForm.role,
|
||||
claims_admin: !!createForm.claims_admin,
|
||||
facultad_id: createForm.facultad_id ?? null,
|
||||
carrera_id: createForm.carrera_id ?? null
|
||||
}
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
toast.success("Usuario creado")
|
||||
setCreateOpen(false)
|
||||
setCreateForm({ email: "", password: "" })
|
||||
router.invalidate()
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
toast.error(e?.message || "No se pudo crear el usuario")
|
||||
} finally {
|
||||
setCreateSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!auth.claims?.claims_admin) {
|
||||
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
|
||||
}
|
||||
@@ -176,6 +242,11 @@ function RouteComponent() {
|
||||
<Button variant="outline" size="icon" title="Recargar" onClick={() => router.invalidate()}>
|
||||
<RefreshCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{/* NUEVO: abrir modal de alta */}
|
||||
<Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap">
|
||||
<Plus className="w-4 h-4 mr-1" /> Nuevo usuario
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -395,6 +466,145 @@ function RouteComponent() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Modal: Nuevo usuario */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="w-[min(92vw,720px)] sm:max-w-xl md:max-w-2xl">
|
||||
<DialogHeader><DialogTitle>Nuevo usuario</DialogTitle></DialogHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Correo</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={createForm.email}
|
||||
onChange={(e) => setCreateForm(s => ({ ...s, email: e.target.value }))}
|
||||
placeholder="usuario@lasalle.mx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Contraseña temporal</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type={showPwd ? "text" : "password"}
|
||||
value={createForm.password}
|
||||
onChange={(e) => setCreateForm(s => ({ ...s, password: e.target.value }))}
|
||||
placeholder="Se generará si la dejas vacía"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateForm(s => ({ ...s, password: genPassword() }))}>
|
||||
Generar
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setShowPwd(v => !v)} aria-label="Mostrar u ocultar">
|
||||
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-neutral-500">Pídeles cambiarla al iniciar sesión.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Nombre</Label>
|
||||
<Input value={createForm.nombre ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, nombre: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Apellidos</Label>
|
||||
<Input value={createForm.apellidos ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, apellidos: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Título</Label>
|
||||
<Input value={createForm.title ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, title: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Clave</Label>
|
||||
<Input value={createForm.clave ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, clave: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Avatar (URL)</Label>
|
||||
<Input value={createForm.avatar ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, avatar: e.target.value }))} />
|
||||
</div>
|
||||
|
||||
{/* Rol */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Rol</Label>
|
||||
<Select
|
||||
value={createForm.role ?? ""}
|
||||
onValueChange={(v) => {
|
||||
setCreateForm(s => {
|
||||
const role = v as Role
|
||||
if (role === "jefe_carrera") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
|
||||
if (role === "secretario_academico") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
|
||||
return { ...s, role, facultad_id: null, carrera_id: null }
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full"><SelectValue placeholder="Selecciona un rol" /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{ROLES.map(code => {
|
||||
const M = ROLE_META[code]; const I = M.Icon
|
||||
return (
|
||||
<SelectItem key={code} value={code} className="whitespace-normal text-sm leading-snug py-2">
|
||||
<span className="inline-flex items-center gap-2"><I className="w-4 h-4" /> {M.label}</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* SECRETARIO: Facultad */}
|
||||
{createForm.role === "secretario_academico" && (
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<FacultadCombobox
|
||||
value={createForm.facultad_id ?? ""}
|
||||
onChange={(id) => setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JEFE_CARRERA: Facultad + Carrera */}
|
||||
{createForm.role === "jefe_carrera" && (
|
||||
<div className="grid gap-4 md:col-span-2 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Facultad</Label>
|
||||
<FacultadCombobox
|
||||
value={createForm.facultad_id ?? ""}
|
||||
onChange={(id) => setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: "" }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Carrera</Label>
|
||||
<CarreraCombobox
|
||||
facultadId={createForm.facultad_id ?? ""}
|
||||
value={createForm.carrera_id ?? ""}
|
||||
onChange={(id) => setCreateForm(s => ({ ...s, carrera_id: id }))}
|
||||
disabled={!createForm.facultad_id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label>Permisos</Label>
|
||||
<Select value={String(!!createForm.claims_admin)} onValueChange={(v) => setCreateForm(s => ({ ...s, claims_admin: v === "true" }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Administrador</SelectItem>
|
||||
<SelectItem value="false">Usuario</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={createUserNow} disabled={!createForm.email || createSaving}>
|
||||
{createSaving ? "Creando…" : "Crear usuario"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Mail, Lock, Eye, EyeOff, Loader2, Shield } from "lucide-react"
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
validateSearch: (search) => ({
|
||||
redirect: (search.redirect as string) || "/planes",
|
||||
redirect: (search.redirect as string) || "/dashboard",
|
||||
}),
|
||||
beforeLoad: ({ context, search }) => {
|
||||
if (context.auth.isAuthenticated) {
|
||||
|
||||
Reference in New Issue
Block a user