import { createFileRoute, Link, useRouter } from '@tanstack/react-router' import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query' 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' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Label } from '@/components/ui/label' import { toast } from 'sonner' import { InfoChip } from '@/components/planes/InfoChip' /* ================== Tipos ================== */ type FacMini = { id: string; nombre: string; color?: string | null; icon?: string | null } type CarMini = { id: string; nombre: string; facultad: FacMini | null } type PlanMini = { id: string; nombre: string; carrera: CarMini | null } type Asignatura = { id: string nombre: string clave: string | null tipo: string | null semestre: number | null creditos: number | null horas_teoricas: number | null horas_practicas: number | null objetivos: string | null contenidos: Record> | null bibliografia: string[] | null criterios_evaluacion: string | null fecha_creacion: string | null plan: PlanMini | null // NEW: plan_id base (lo traemos en el SELECT) plan_id?: string | null } type SearchState = { q: string planId: string carreraId: string facultadId: string f: 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | '' } /* ================== Query Keys & Options ================== */ const asignaturasKeys = { root: ['asignaturas'] as const, list: (search: SearchState) => [...asignaturasKeys.root, { search }] as const, } const planesKeys = { root: ['planes'] as const, all: () => [...planesKeys.root, 'all'] as const, } async function fetchPlanIdsByScope(search: Pick): Promise { const { planId, carreraId, facultadId } = search if (planId) return [planId] if (carreraId) { const { data, error } = await supabase.from('plan_estudios').select('id').eq('carrera_id', carreraId) if (error) throw error return (data ?? []).map(p => p.id) } 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) return [] const { data: planesFac, error: plaErr } = await supabase .from('plan_estudios') .select('id') .in('carrera_id', cIds) if (plaErr) throw plaErr return (planesFac ?? []).map(p => p.id) } return null } async function fetchAsignaturas(search: SearchState): Promise { const planIds = await fetchPlanIdsByScope(search) if (planIds && planIds.length === 0) return [] console.log(AsignaturaCard); 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 } = await query if (error) throw error return (data ?? []) as unknown as Asignatura[] } async function fetchPlanes(): Promise { const { data, error } = await supabase .from('plan_estudios') .select(` id, nombre, carrera:carreras( id, nombre, facultad:facultades(id, nombre, color, icon) ) `) .order('nombre', { ascending: true }) if (error) throw error return (data ?? []) as unknown as PlanMini[] } const asignaturasOptions = (search: SearchState) => queryOptions({ queryKey: asignaturasKeys.list(search), queryFn: () => fetchAsignaturas(search), staleTime: 60_000, }) const planesOptions = () => queryOptions({ queryKey: planesKeys.all(), queryFn: fetchPlanes, staleTime: 5 * 60_000, }) /* ================== Ruta ================== */ export const Route = createFileRoute('/_authenticated/asignaturas')({ component: RouteComponent, pendingComponent: PageSkeleton, validateSearch: (search: Record) => { return { q: (search.q as string) ?? '', planId: (search.planId as string) ?? '', carreraId: (search.carreraId as string) ?? '', facultadId: (search.facultadId as string) ?? '', f: (search.f as 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | '') ?? '', } }, loader: async ({ context: { queryClient }, location }) => { const search = (location?.search ?? {}) as SearchState // Pre-hydrate ambas queries con QueryClient (sin llamadas "sueltas" aquí) await Promise.all([ queryClient.ensureQueryData(asignaturasOptions(search)), queryClient.ensureQueryData(planesOptions()), ]) return null }, }) /* ================== Página ================== */ function RouteComponent() { const router = useRouter() const qc = useQueryClient() const search = Route.useSearch() as SearchState // Datos por TanStack Query (suspense-friendly) const { data: asignaturas } = useSuspenseQuery(asignaturasOptions(search)) const { data: planes } = useSuspenseQuery(planesOptions()) // Filtros const [q, setQ] = useState(search.q ?? '') const [sem, setSem] = useState('todos') const [tipo, setTipo] = useState('todos') const [groupBy] = useState<'semestre' | 'ninguno'>('semestre') const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '') const [facultad, setFacultad] = useState("todas") const [carrera, setCarrera] = useState("todas") /* useEffect(() => { const timeout = setTimeout(() => { router.navigate({ to: '/asignaturas', search: { ...search, q }, replace: true, }) }, 400) return () => clearTimeout(timeout) }, [q]) */ function handleChange(e: React.ChangeEvent) { const value = e.target.value setQ(value) router.navigate({ to: '/asignaturas', search: { ...search, q: value, }, replace: true, // evita recargar o empujar al historial }) } // 🟣 Lista única de facultades const facultadesList = useMemo(() => { const unique = new Map() planes?.forEach((p) => { const fac = p.carrera?.facultad if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre) }) return Array.from(unique.entries()) }, [planes]) // 🎓 Lista de carreras según la facultad seleccionada const carrerasList = useMemo(() => { const unique = new Map() planes?.forEach((p) => { if ( p.carrera?.id && p.carrera?.nombre && (!facultad || facultad === "todas" || p.carrera?.facultad?.id === facultad) ) { unique.set(p.carrera.id, p.carrera.nombre) } }) return Array.from(unique.entries()) }, [planes, facultad]) // NEW: Clonado individual const [cloneOpen, setCloneOpen] = useState(false) const [cloneTarget, setCloneTarget] = useState(null) const [cloneForm, setCloneForm] = useState<{ nombre?: string clave?: string | null tipo?: string | null semestre?: number | null creditos?: number | null horas_teoricas?: number | null horas_practicas?: number | null plan_destino_id?: string | '' }>({}) // NEW: Carrito const [cart, setCart] = useState([]) const [bulkOpen, setBulkOpen] = useState(false) const [bulk, setBulk] = useState<{ plan_destino_id?: string | '' semestre?: string creditos?: string tipo?: string }>({}) // Valores selects const semestres = useMemo(() => { const s = new Set() asignaturas.forEach(a => s.add(String(a.semestre ?? '—'))) return Array.from(s).sort((a, b) => (a === '—' ? 1 : 0) - (b === '—' ? 1 : 0) || Number(a) - Number(b)) }, [asignaturas]) // Salud 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 || Object.keys(a.contenidos ?? {}).length === 0) sinContenidos++ } return { sinBibliografia, sinCriterios, sinContenidos } }, [asignaturas]) 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 carreraOK = carrera === 'todas' || a.plan?.carrera?.id === carrera const facultadOK = facultad === 'todas' || a.plan?.carrera?.facultad?.id === facultad const planOK = !search.planId || a.plan?.id === search.planId 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 && carreraOK && facultadOK && planOK }) }, [q, sem, tipo, flag, carrera, facultad, asignaturas]) // Agrupación const groups = useMemo(() => { if (groupBy === 'ninguno') return [['Todas', filtered] as [string, Asignatura[]]] const m = new Map() for (const a of filtered) { const k = a.semestre ?? '—' if (!m.has(k)) m.set(k, []) m.get(k)!.push(a) } return Array.from(m.entries()).sort(([a], [b]) => { if (a === '—') return 1 if (b === '—') return -1 return Number(a) - Number(b) }) }, [filtered, groupBy]) // Helpers const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag(''); setFacultad('todas') // Actualiza la URL limpiando todos los query params router.navigate({ to: '/asignaturas', search: { q: '', planId: '', carreraId: '', facultadId: '', f: '' }, }) } // NEW: util para clonar 1 asignatura async function cloneOne(src: Asignatura, overrides: { plan_destino_id: string nombre?: string clave?: string | null tipo?: string | null semestre?: number | null creditos?: number | null horas_teoricas?: number | null horas_practicas?: number | null }) { if (!overrides.plan_destino_id) throw new Error('Selecciona un plan destino') const payload = { plan_id: overrides.plan_destino_id, nombre: overrides.nombre ?? src.nombre, clave: overrides.clave ?? src.clave, tipo: overrides.tipo ?? src.tipo, semestre: overrides.semestre ?? src.semestre, creditos: overrides.creditos ?? src.creditos, horas_teoricas: overrides.horas_teoricas ?? src.horas_teoricas, horas_practicas: overrides.horas_practicas ?? src.horas_practicas, objetivos: src.objetivos, contenidos: src.contenidos, bibliografia: src.bibliografia, criterios_evaluacion: src.criterios_evaluacion, estado: 'activo', } const { error } = await supabase.from('asignaturas').insert(payload) if (error) throw error } // NEW: abrir modal clon individual function openClone(a: Asignatura) { setCloneTarget(a) setCloneForm({ nombre: a.nombre, clave: a.clave, tipo: a.tipo ?? '', semestre: a.semestre ?? undefined, creditos: a.creditos ?? undefined, horas_teoricas: a.horas_teoricas ?? undefined, horas_practicas: a.horas_practicas ?? undefined, plan_destino_id: a.plan?.id ?? '', }) setCloneOpen(true) } // NEW: acciones carrito function addToCart(a: Asignatura) { setCart(prev => prev.find(x => x.id === a.id) ? prev : [...prev, a]) toast.success('Asignatura añadida al carrito de asignaturas') } function removeFromCart(id: string) { setCart(prev => prev.filter(x => x.id !== id)) } function clearCart() { setCart([]) } // NEW: clonado en lote async function cloneBulk() { if (!bulk.plan_destino_id) { toast.error('Selecciona un plan de destino'); return } if (!cart.length) { toast.error('Carrito vacío'); return } try { const common: Partial = { tipo: bulk.tipo && bulk.tipo !== '—' ? bulk.tipo : undefined, semestre: bulk.semestre ? Number(bulk.semestre) : undefined, creditos: bulk.creditos ? Number(bulk.creditos) : undefined, } for (const a of cart) { await cloneOne(a, { plan_destino_id: bulk.plan_destino_id!, tipo: common.tipo ?? undefined, semestre: common.semestre ?? undefined, creditos: common.creditos ?? undefined, }) } toast.success(`Clonadas ${cart.length} asignaturas`) setBulkOpen(false) clearCart() // Invalida ambas queries y la ruta await Promise.all([ qc.invalidateQueries({ queryKey: asignaturasKeys.root }), qc.invalidateQueries({ queryKey: planesKeys.root }), ]) router.invalidate() } catch (e: any) { console.error(e) toast.error(e?.message || 'No se pudieron clonar') } } return (
{/* HEADER */}

Asignaturas

Nueva asignatura {/* NEW: botón carrito */}
{/* Filtros */}
{/* 🔍 Búsqueda */}
{/* 📘 Semestre */}
{/* 🏛️ Facultad */}
{/* 🎓 Carrera */}
{/* 📜 Plan */}
{/* Chips de salud */}
setFlag(flag === 'sinBibliografia' ? '' : 'sinBibliografia')} icon={} label="Sin bibliografía" value={salud.sinBibliografia} /> setFlag(flag === 'sinCriterios' ? '' : 'sinCriterios')} icon={} label="Sin criterios de evaluación" value={salud.sinCriterios} /> setFlag(flag === 'sinContenidos' ? '' : 'sinContenidos')} icon={} label="Sin contenidos" value={salud.sinContenidos} /> {/*(q || sem !== 'todos' || tipo !== 'todos' || flag || carrera=='todos')*/ true && ( )}
{/* LISTA */}
{!groups.length &&
Sin asignaturas
} {groups.map(([key, items]) => (
{groupBy !== 'ninguno' && (
Semestre {key}
)}
    {items.map(a => openClone(a)} onAddToCart={() => addToCart(a)} />)}
))}
{/* NEW: Modal clonado individual */} Clonar asignatura {cloneTarget && (
Origen: {cloneTarget.nombre} {cloneTarget.plan?.nombre ? `· ${cloneTarget.plan?.nombre}` : ''}
setCloneForm(s => ({ ...s, nombre: e.target.value }))} />
setCloneForm(s => ({ ...s, clave: e.target.value }))} />
setCloneForm(s => ({ ...s, tipo: e.target.value }))} />
setCloneForm(s => ({ ...s, creditos: e.target.value ? Number(e.target.value) : null }))} />
setCloneForm(s => ({ ...s, semestre: e.target.value ? Number(e.target.value) : null }))} />
setCloneForm(s => ({ ...s, horas_teoricas: e.target.value ? Number(e.target.value) : null }))} />
setCloneForm(s => ({ ...s, horas_practicas: e.target.value ? Number(e.target.value) : null }))} />
)}
{/* NEW: Modal carrito */} Carrito de asignaturas ({cart.length}) {cart.length === 0 ? (
No has añadido asignaturas. Usa el menú “…” de cada tarjeta.
) : (
Asignatura
Créditos
Semestre
Tipo
    {cart.map(a => (
  • {a.nombre}
    {a.plan?.nombre} · {a.plan?.carrera?.nombre}
    {a.creditos ?? '—'}
    {a.semestre ?? '—'}
    {a.tipo ?? '—'}
  • ))}
setBulk(s => ({ ...s, creditos: e.target.value }))} />
setBulk(s => ({ ...s, semestre: e.target.value }))} />
setBulk(s => ({ ...s, tipo: e.target.value }))} />
)}
) } /* ================== Card ================== */ function tipoMeta(tipo?: string | null) { const t = (tipo ?? '').toLowerCase() if (t.includes('oblig')) return { label: 'Obligatoria', Icon: Icons.BadgeCheck, cls: 'bg-emerald-50 text-emerald-700 border-emerald-200' } if (t.includes('opt')) return { label: 'Optativa', Icon: Icons.Wand2, cls: 'bg-amber-50 text-amber-800 border-amber-200' } if (t.includes('taller')) return { label: 'Taller', Icon: Icons.Hammer, cls: 'bg-indigo-50 text-indigo-700 border-indigo-200' } if (t.includes('lab')) return { label: 'Laboratorio', Icon: Icons.FlaskConical, cls: 'bg-sky-50 text-sky-700 border-sky-200' } return { label: tipo ?? 'Genérica', Icon: Icons.BookOpen, cls: 'bg-neutral-100 text-neutral-700 border-neutral-200' } } function Chip({ children, className = '' }: { children: React.ReactNode; className?: string }) { return {children} } function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: () => void; onAddToCart: () => void }) { const horasT = a.horas_teoricas ?? 0 const horasP = a.horas_practicas ?? 0 const meta = tipoMeta(a.tipo) console.log(a); return (
  • {a.nombre}

    Abrir Ver plan {/* NEW */} Clonar… Añadir al carrito de asignaturas
    {a.clave && {a.clave}} {meta.label} {a.creditos != null && {a.creditos} créditos} {(horasT + horasP) > 0 && H T/P: {horasT}/{horasP}} Semestre {a.semestre ?? '—'}
    {a.plan && (
    Plan:{a.plan.nombre} {a.plan.carrera && ( } label={a.plan.carrera.nombre} /> )} {a.plan.carrera?.facultad && ( } label={a.plan.carrera.facultad.nombre} tint={a.plan.carrera.facultad.color} /> )}
    )}

    {a.objetivos ?? '—'}

    Ver
  • ) } /* ================== UI helpers ================== */ function HealthChip({ active, onClick, icon, label, value, }: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string; value: number }) { return ( ) } /* ================== Skeleton ================== */ function Pulse({ className = '' }: { className?: string }) { return
    } function PageSkeleton() { return (
    {Array.from({ length: 9 }).map((_, i) => )}
    ) }