958 lines
38 KiB
TypeScript
958 lines
38 KiB
TypeScript
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 { useEffect, 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<string, Record<string, string>> | 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<SearchState, 'planId' | 'carreraId' | 'facultadId'>): Promise<string[] | null> {
|
|
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<Asignatura[]> {
|
|
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<PlanMini[]> {
|
|
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<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 ({ 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<string>('todos')
|
|
const [tipo, setTipo] = useState<string>('todos')
|
|
const [groupBy, setGroupBy] = 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<HTMLInputElement>) {
|
|
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<string, string>()
|
|
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<string, string>()
|
|
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<Asignatura | null>(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<Asignatura[]>([])
|
|
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<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
|
|
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<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'); 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<Asignatura> = {
|
|
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 (
|
|
<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 font-mono">
|
|
<Icons.BookOpen className="w-5 h-5" />
|
|
Asignaturas
|
|
</h1>
|
|
<div className="flex items-center gap-2">
|
|
<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={{ plan: '' }}
|
|
>
|
|
<Icons.Plus className="w-4 h-4" /> Nueva asignatura
|
|
</Link>
|
|
{/* NEW: botón carrito */}
|
|
<Button
|
|
variant={cart.length ? 'default' : 'outline'}
|
|
onClick={() => setBulkOpen(true)}
|
|
className="relative"
|
|
>
|
|
<Icons.ShoppingCart className="w-4 h-4 mr-2" />
|
|
Carrito de asignaturas
|
|
{cart.length > 0 && (
|
|
<span className="ml-2 inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-white/90 text-[11px] text-neutral-900 px-1">
|
|
{cart.length}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
<Button variant="outline" size="icon" onClick={() => { qc.invalidateQueries({ queryKey: asignaturasKeys.root }); router.invalidate() }} title="Recargar">
|
|
<Icons.RefreshCcw className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filtros */}
|
|
<div className="grid gap-4 sm:grid-cols-5 items-end">
|
|
{/* 🔍 Búsqueda */}
|
|
<div>
|
|
<Label>Búsqueda</Label>
|
|
<Input
|
|
value={q}
|
|
onChange={handleChange}
|
|
placeholder="Nombre, clave, plan, carrera, facultad…"
|
|
/>
|
|
</div>
|
|
|
|
{/* 📘 Semestre */}
|
|
<div>
|
|
<Label>Semestre</Label>
|
|
<Select value={sem} onValueChange={setSem}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Todos" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="todos">Todos</SelectItem>
|
|
{semestres.map((s) => (
|
|
<SelectItem key={s} value={s}>
|
|
Semestre {s}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 🏛️ Facultad */}
|
|
<div>
|
|
<Label>Facultad</Label>
|
|
<Select
|
|
value={facultad ?? "todas"}
|
|
onValueChange={(val) => {
|
|
setFacultad(val)
|
|
setCarrera("todas")
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-[200px]">
|
|
<SelectValue placeholder="Filtrar por facultad" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="todas">Todas las facultades</SelectItem>
|
|
{facultadesList.map(([id, nombre]) => (
|
|
<SelectItem key={id} value={id}>
|
|
{nombre}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 🎓 Carrera */}
|
|
<div className={!facultad || facultad === "todas" ? "invisible" : ""}>
|
|
<Label>Carrera</Label>
|
|
<Select
|
|
value={carrera ?? "todas"}
|
|
onValueChange={(val) => setCarrera(val)}
|
|
>
|
|
<SelectTrigger className="w-[200px]">
|
|
<SelectValue placeholder="Filtrar por carrera" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="todas">Todas las carreras</SelectItem>
|
|
{carrerasList.map(([id, nombre]) => (
|
|
<SelectItem key={id} value={id}>
|
|
{nombre}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 📜 Plan */}
|
|
<div className={!carrera || carrera === "todas" ? "invisible" : ""}>
|
|
<Label>Plan</Label>
|
|
<Select
|
|
value={search.planId ?? "todos"}
|
|
onValueChange={(val) => {
|
|
router.navigate({
|
|
search: { ...search, planId: val === "todos" ? "" : val },
|
|
})
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-[200px]">
|
|
<SelectValue placeholder="Filtrar por plan" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="todos">Todos los planes</SelectItem>
|
|
{planes
|
|
.filter((p) => p.carrera?.id === carrera)
|
|
.map((p) => (
|
|
<SelectItem key={p.id} value={p.id}>
|
|
{p.nombre}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Chips de salud */}
|
|
<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 || carrera=='todos')*/ true && (
|
|
<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} onClone={() => openClone(a)} onAddToCart={() => addToCart(a)} />)}
|
|
</ul>
|
|
</section>
|
|
))}
|
|
</div>
|
|
|
|
{/* NEW: Modal clonado individual */}
|
|
<Dialog open={cloneOpen} onOpenChange={setCloneOpen}>
|
|
<DialogContent className="w-[min(92vw,720px)] sm:max-w-xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="font-mono" >Clonar asignatura</DialogTitle>
|
|
</DialogHeader>
|
|
{cloneTarget && (
|
|
<div className="grid gap-3">
|
|
<div className="text-xs text-neutral-600">
|
|
Origen: <strong>{cloneTarget.nombre}</strong> {cloneTarget.plan?.nombre ? `· ${cloneTarget.plan?.nombre}` : ''}
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div>
|
|
<Label>Nombre</Label>
|
|
<Input value={cloneForm.nombre ?? ''} onChange={e => setCloneForm(s => ({ ...s, nombre: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<Label>Clave</Label>
|
|
<Input value={cloneForm.clave ?? ''} onChange={e => setCloneForm(s => ({ ...s, clave: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<Label>Tipo</Label>
|
|
<Input value={cloneForm.tipo ?? ''} onChange={e => setCloneForm(s => ({ ...s, tipo: e.target.value }))} />
|
|
</div>
|
|
<div>
|
|
<Label>Créditos</Label>
|
|
<Input type="number" value={cloneForm.creditos ?? ''} onChange={e => setCloneForm(s => ({ ...s, creditos: e.target.value ? Number(e.target.value) : null }))} />
|
|
</div>
|
|
<div>
|
|
<Label>Semestre</Label>
|
|
<Input type="number" value={cloneForm.semestre ?? ''} onChange={e => setCloneForm(s => ({ ...s, semestre: e.target.value ? Number(e.target.value) : null }))} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label>Horas T</Label>
|
|
<Input type="number" value={cloneForm.horas_teoricas ?? ''} onChange={e => setCloneForm(s => ({ ...s, horas_teoricas: e.target.value ? Number(e.target.value) : null }))} />
|
|
</div>
|
|
<div>
|
|
<Label>Horas P</Label>
|
|
<Input type="number" value={cloneForm.horas_practicas ?? ''} onChange={e => setCloneForm(s => ({ ...s, horas_practicas: e.target.value ? Number(e.target.value) : null }))} />
|
|
</div>
|
|
</div>
|
|
<div className="sm:col-span-2">
|
|
<Label>Plan destino</Label>
|
|
<Select value={cloneForm.plan_destino_id ?? ''} onValueChange={(v) => setCloneForm(s => ({ ...s, plan_destino_id: v }))}>
|
|
<SelectTrigger><SelectValue placeholder="Selecciona plan de estudios" /></SelectTrigger>
|
|
<SelectContent className="max-h-72">
|
|
{planes.map(p => (
|
|
<SelectItem key={p.id} value={p.id}>
|
|
{p.nombre} {p.carrera ? `· ${p.carrera.nombre}` : ''} {p.carrera?.facultad ? `· ${p.carrera.facultad.nombre}` : ''}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setCloneOpen(false)}>Cancelar</Button>
|
|
<Button
|
|
onClick={async () => {
|
|
if (!cloneTarget) return
|
|
try {
|
|
await cloneOne(cloneTarget, {
|
|
plan_destino_id: (cloneForm.plan_destino_id as string) || '',
|
|
nombre: cloneForm.nombre,
|
|
clave: cloneForm.clave ?? null,
|
|
tipo: cloneForm.tipo ?? null,
|
|
semestre: (cloneForm.semestre as number) ?? null,
|
|
creditos: (cloneForm.creditos as number) ?? null,
|
|
horas_teoricas: (cloneForm.horas_teoricas as number) ?? null,
|
|
horas_practicas: (cloneForm.horas_practicas as number) ?? null,
|
|
})
|
|
toast.success('Asignatura clonada')
|
|
setCloneOpen(false)
|
|
await qc.invalidateQueries({ queryKey: asignaturasKeys.root })
|
|
router.invalidate()
|
|
} catch (e: any) {
|
|
console.error(e)
|
|
toast.error(e?.message || 'No se pudo clonar')
|
|
}
|
|
}}
|
|
>
|
|
Clonar
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* NEW: Modal carrito */}
|
|
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
|
<DialogContent className="w-[min(92vw,840px)]">
|
|
<DialogHeader>
|
|
<DialogTitle className="font-mono" >Carrito de asignaturas ({cart.length})</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{cart.length === 0 ? (
|
|
<div className="text-sm text-neutral-500">No has añadido asignaturas. Usa el menú “…” de cada tarjeta.</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div className="rounded-xl border">
|
|
<div className="grid grid-cols-12 px-3 py-2 text-[11px] font-medium text-neutral-600">
|
|
<div className="col-span-5">Asignatura</div>
|
|
<div className="col-span-2">Créditos</div>
|
|
<div className="col-span-2">Semestre</div>
|
|
<div className="col-span-2">Tipo</div>
|
|
<div className="col-span-1 text-right">—</div>
|
|
</div>
|
|
<ul className="divide-y">
|
|
{cart.map(a => (
|
|
<li key={a.id} className="grid grid-cols-12 items-center px-3 py-2 text-sm">
|
|
<div className="col-span-5">
|
|
<div className="font-medium">{a.nombre}</div>
|
|
<div className="text-[11px] text-neutral-500">{a.plan?.nombre} · {a.plan?.carrera?.nombre}</div>
|
|
</div>
|
|
<div className="col-span-2">{a.creditos ?? '—'}</div>
|
|
<div className="col-span-2">{a.semestre ?? '—'}</div>
|
|
<div className="col-span-2">{a.tipo ?? '—'}</div>
|
|
<div className="col-span-1 text-right">
|
|
<Button variant="ghost" size="icon" onClick={() => removeFromCart(a.id)}><Icons.X className="w-4 h-4" /></Button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div>
|
|
<Label>Plan de estudios destino</Label>
|
|
<Select value={bulk.plan_destino_id ?? ''} onValueChange={(v) => setBulk(s => ({ ...s, plan_destino_id: v }))}>
|
|
<SelectTrigger><SelectValue placeholder="Selecciona plan" /></SelectTrigger>
|
|
<SelectContent className="max-h-72">
|
|
{planes.map(p => (
|
|
<SelectItem key={p.id} value={p.id}>
|
|
{p.nombre} {p.carrera ? `· ${p.carrera.nombre}` : ''} {p.carrera?.facultad ? `· ${p.carrera.facultad.nombre}` : ''}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div>
|
|
<Label>Créditos (común)</Label>
|
|
<Input
|
|
type="number"
|
|
placeholder="—"
|
|
value={bulk.creditos ?? ''}
|
|
onChange={e => setBulk(s => ({ ...s, creditos: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Semestre (común)</Label>
|
|
<Input
|
|
type="number"
|
|
placeholder="—"
|
|
value={bulk.semestre ?? ''}
|
|
onChange={e => setBulk(s => ({ ...s, semestre: e.target.value }))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Tipo (común)</Label>
|
|
<Input
|
|
placeholder="Obligatoria / Optativa…"
|
|
value={bulk.tipo ?? ''}
|
|
onChange={e => setBulk(s => ({ ...s, tipo: e.target.value }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Button variant="ghost" onClick={clearCart}><Icons.Trash2 className="w-4 h-4 mr-1" /> Vaciar carrito de Asignaturas</Button>
|
|
<div className="space-x-2">
|
|
<Button variant="outline" onClick={() => setBulkOpen(false)}>Cerrar</Button>
|
|
<Button onClick={cloneBulk}><Icons.CopyPlus className="w-4 h-4 mr-1" /> Clonar en lote</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</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, 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)
|
|
const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2
|
|
console.log(a);
|
|
|
|
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"
|
|
style={{
|
|
borderColor: a.plan?.carrera?.facultad?.color ?? '#ccc',
|
|
backgroundColor: `${a.plan?.carrera?.facultad?.color}15`, // 15 = transparencia HEX
|
|
}}
|
|
>
|
|
<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-mono leading-tight truncate" title={a.nombre}>{a.nombre}</h4>
|
|
|
|
<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-[200px]">
|
|
<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>
|
|
{/* NEW */}
|
|
<DropdownMenuItem className="gap-2" onClick={onClone}>
|
|
<Icons.Copy className="w-4 h-4" /> Clonar…
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem className="gap-2" onClick={onAddToCart}>
|
|
<Icons.ShoppingCart className="w-4 h-4" /> Añadir al carrito de asignaturas
|
|
</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>
|
|
|
|
{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" /> <strong>Plan:</strong>{a.plan.nombre}
|
|
</span>
|
|
{a.plan.carrera && (
|
|
<InfoChip
|
|
icon={<Icons.GraduationCap className="h-3 w-3" />}
|
|
label={a.plan.carrera.nombre}
|
|
/>
|
|
)}
|
|
{a.plan.carrera?.facultad && (
|
|
<InfoChip
|
|
icon={<Icons.Building2 className="h-3 w-3" />}
|
|
label={a.plan.carrera.facultad.nombre}
|
|
tint={a.plan.carrera.facultad.color}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<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>
|
|
)
|
|
}
|