Compare commits
4 Commits
fix/Filtro
...
6e2b3d72f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e2b3d72f1 | |||
| 0c5c3f935b | |||
| 8da08b6bf1 | |||
| 1fe8f2b6a8 |
@@ -30,7 +30,7 @@ export function useDeleteCarreraDialog(carreraId: string, onDeleted?: () => void
|
|||||||
|
|
||||||
const dialog = (
|
const dialog = (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent>
|
<DialogContent className="bg-white">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="font-mono" >¿Eliminar carrera?</DialogTitle>
|
<DialogTitle className="font-mono" >¿Eliminar carrera?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
|||||||
import { Field } from "./Field"
|
import { Field } from "./Field"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
||||||
import { asignaturaKeys } from "./planQueries"
|
import { asignaturaKeys } from "./planQueries"
|
||||||
|
import { useRouter } from "@tanstack/react-router"
|
||||||
|
|
||||||
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
|
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const router = useRouter()
|
||||||
const supabaseAuth = useSupabaseAuth()
|
const supabaseAuth = useSupabaseAuth()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -45,7 +47,13 @@ export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdd
|
|||||||
objetivos: toNull(f.objetivos),
|
objetivos: toNull(f.objetivos),
|
||||||
contenidos: [], bibliografia: [], criterios_evaluacion: null,
|
contenidos: [], bibliografia: [], criterios_evaluacion: null,
|
||||||
}
|
}
|
||||||
const { error } = await supabase.from("asignaturas").insert([payload])
|
const { error,data } = await supabase.from("asignaturas").insert([payload]).select().single()
|
||||||
|
console.log(data);
|
||||||
|
router.invalidate()
|
||||||
|
router.navigate({
|
||||||
|
to: "/asignatura/$asignaturaId",
|
||||||
|
params: { asignaturaId: data.id },
|
||||||
|
})
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
if (error) { alert(error.message); return }
|
if (error) { alert(error.message); return }
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
@@ -64,8 +72,17 @@ export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdd
|
|||||||
body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true, uuid: supabaseAuth.user?.id }),
|
body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true, uuid: supabaseAuth.user?.id }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(await res.text())
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
const data = await res.json()
|
||||||
|
console.log("Asignatura generada:", data)
|
||||||
|
const asignaturaId = data.asignaturaId || data.insertResult?.id
|
||||||
|
if (!asignaturaId) throw new Error("No se recibió el ID de la asignatura generada")
|
||||||
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
|
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
router.invalidate()
|
||||||
|
router.navigate({
|
||||||
|
to: "/asignatura/$asignaturaId",
|
||||||
|
params: { asignaturaId },
|
||||||
|
})
|
||||||
onAdded?.()
|
onAdded?.()
|
||||||
// qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
// qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
||||||
// qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
// qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
setDbFiles((data || []).map((file: any) => ({
|
setDbFiles((data || []).map((file: any) => ({
|
||||||
id: file.documentos_id,
|
id: file.documentos_id,
|
||||||
titulo: file.titulo_archivo,
|
titulo: file.titulo_archivo,
|
||||||
s3_file_path: file.titulo_archivo,
|
s3_file_path: `prueba-referencias/documento_${file.documentos_id}.pdf`,
|
||||||
fecha_subida: file.fecha_subida,
|
fecha_subida: file.fecha_subida,
|
||||||
tags: file.tags || [],
|
tags: file.tags || [],
|
||||||
})));
|
})));
|
||||||
@@ -118,35 +118,35 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
|
|
||||||
const isSelected = useCallback((path: string) => selectedFiles.includes(path), [selectedFiles]);
|
const isSelected = useCallback((path: string) => selectedFiles.includes(path), [selectedFiles]);
|
||||||
|
|
||||||
const toggleSelected = useCallback((path: string) => {
|
const toggleSelected = useCallback((id: string) => {
|
||||||
setSelectedFiles(prev => prev.includes(path) ? prev.filter(p => p !== path) : [...prev, path]);
|
setSelectedFiles(prev => prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const replaceSelection = useCallback((path: string) => {
|
const replaceSelection = useCallback((id: string) => {
|
||||||
setSelectedFiles([path]);
|
setSelectedFiles([id]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const rangeSelect = useCallback((start: number, end: number) => {
|
const rangeSelect = useCallback((start: number, end: number) => {
|
||||||
const [s, e] = start < end ? [start, end] : [end, start];
|
const [s, e] = start < end ? [start, end] : [end, start];
|
||||||
const paths = dbFiles.slice(s, e + 1).map(f => f.s3_file_path);
|
const ids = dbFiles.slice(s, e + 1).map(f => f.id);
|
||||||
setSelectedFiles(prev => Array.from(new Set([...prev, ...paths])));
|
setSelectedFiles(prev => Array.from(new Set([...prev, ...ids])));
|
||||||
}, [dbFiles]);
|
}, [dbFiles]);
|
||||||
|
|
||||||
const handleCardClick = useCallback((e: React.MouseEvent, index: number, file: { s3_file_path: string }) => {
|
const handleCardClick = useCallback((e: React.MouseEvent, index: number, file: { id: string }) => {
|
||||||
const path = file.s3_file_path;
|
const id = file.id;
|
||||||
|
|
||||||
if (e.shiftKey && lastSelectedIndex !== null) {
|
if (e.shiftKey && lastSelectedIndex !== null) {
|
||||||
rangeSelect(lastSelectedIndex, index);
|
rangeSelect(lastSelectedIndex, index);
|
||||||
} else if (e.metaKey || e.ctrlKey) {
|
} else if (e.metaKey || e.ctrlKey) {
|
||||||
toggleSelected(path);
|
toggleSelected(id);
|
||||||
setLastSelectedIndex(index);
|
setLastSelectedIndex(index);
|
||||||
} else {
|
} else {
|
||||||
if (isSelected(path) && selectedFiles.length === 1) {
|
if (isSelected(id) && selectedFiles.length === 1) {
|
||||||
// si ya es el único seleccionado, des-selecciona
|
// si ya es el único seleccionado, des-selecciona
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
setLastSelectedIndex(null);
|
setLastSelectedIndex(null);
|
||||||
} else {
|
} else {
|
||||||
replaceSelection(path);
|
replaceSelection(id);
|
||||||
setLastSelectedIndex(index);
|
setLastSelectedIndex(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
prompt: prompt,
|
prompt: prompt,
|
||||||
insert: true,
|
insert: true,
|
||||||
files: selectedFiles,
|
files: selectedFiles,
|
||||||
uuid: auth.user?.id,
|
created_by: auth.user?.id,
|
||||||
})
|
})
|
||||||
const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id
|
const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id
|
||||||
if (newId) {
|
if (newId) {
|
||||||
@@ -261,7 +261,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
<div role="grid" className="grid gap-4 xs:grid-cols-2 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
<div role="grid" className="grid gap-4 xs:grid-cols-2 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||||
{dbFiles.map((file, index) => {
|
{dbFiles.map((file, index) => {
|
||||||
const ext = fileExt(file.titulo);
|
const ext = fileExt(file.titulo);
|
||||||
const selected = isSelected(file.s3_file_path);
|
const selected = isSelected(file.id);
|
||||||
console.log(file);
|
console.log(file);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -169,36 +169,6 @@ function RouteComponent() {
|
|||||||
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
|
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
|
||||||
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
|
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
|
||||||
|
|
||||||
const [facultad, setFacultad] = useState("todas")
|
|
||||||
const [carrera, setCarrera] = useState("todas")
|
|
||||||
|
|
||||||
|
|
||||||
// 🟣 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
|
// NEW: Clonado individual
|
||||||
const [cloneOpen, setCloneOpen] = useState(false)
|
const [cloneOpen, setCloneOpen] = useState(false)
|
||||||
const [cloneTarget, setCloneTarget] = useState<Asignatura | null>(null)
|
const [cloneTarget, setCloneTarget] = useState<Asignatura | null>(null)
|
||||||
@@ -247,30 +217,28 @@ const carrerasList = useMemo(() => {
|
|||||||
return { sinBibliografia, sinCriterios, sinContenidos }
|
return { sinBibliografia, sinCriterios, sinContenidos }
|
||||||
}, [asignaturas])
|
}, [asignaturas])
|
||||||
|
|
||||||
|
// Filtrado
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const t = q.trim().toLowerCase()
|
const t = q.trim().toLowerCase()
|
||||||
return asignaturas.filter(a => {
|
return asignaturas.filter(a => {
|
||||||
const matchesQ =
|
const matchesQ =
|
||||||
!t ||
|
!t ||
|
||||||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
|
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.some(v => String(v).toLowerCase().includes(t))
|
.some(v => String(v).toLowerCase().includes(t))
|
||||||
|
|
||||||
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
|
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
|
||||||
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
|
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 flagOK =
|
const flagOK =
|
||||||
!flag ||
|
!flag ||
|
||||||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
|
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
|
||||||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
|
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
|
||||||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
|
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
|
||||||
|
|
||||||
return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK
|
|
||||||
})
|
|
||||||
}, [q, sem, tipo, flag, carrera, facultad, asignaturas])
|
|
||||||
|
|
||||||
|
return matchesQ && semOK && tipoOK && flagOK
|
||||||
|
})
|
||||||
|
}, [q, sem, tipo, flag, asignaturas])
|
||||||
|
|
||||||
// Agrupación
|
// Agrupación
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
@@ -289,7 +257,7 @@ const carrerasList = useMemo(() => {
|
|||||||
}, [filtered, groupBy])
|
}, [filtered, groupBy])
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag('') }
|
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setFlag('') }
|
||||||
|
|
||||||
// NEW: util para clonar 1 asignatura
|
// NEW: util para clonar 1 asignatura
|
||||||
async function cloneOne(src: Asignatura, overrides: {
|
async function cloneOne(src: Asignatura, overrides: {
|
||||||
@@ -426,7 +394,7 @@ const carrerasList = useMemo(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filtros */}
|
{/* Filtros */}
|
||||||
<div className="grid gap-4 sm:grid-cols-5">
|
<div className="grid gap-4 sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>Búsqueda</Label>
|
<Label>Búsqueda</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -448,53 +416,29 @@ const carrerasList = useMemo(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label>Facultad</Label>
|
<Label>Tipo</Label>
|
||||||
<Select
|
<Select value={tipo} onValueChange={setTipo}>
|
||||||
value={facultad ?? "todas"}
|
<SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger>
|
||||||
onValueChange={(val) => {
|
<SelectContent className="max-h-64">
|
||||||
setFacultad(val)
|
<SelectItem value="todos">Todos</SelectItem>
|
||||||
setCarrera("todas") // reset de carrera al cambiar facultad
|
{tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{facultad && facultad !== "todas" && (
|
|
||||||
<div>
|
<div>
|
||||||
<Label>Carrera</Label>
|
<Label>Agrupación</Label>
|
||||||
<Select
|
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}>
|
||||||
value={carrera ?? "todas"}
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
onValueChange={(val) => setCarrera(val)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[200px]">
|
|
||||||
<SelectValue placeholder="Filtrar por carrera" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="todas">Todas las carreras</SelectItem>
|
<SelectItem value="semestre">Por semestre</SelectItem>
|
||||||
{carrerasList.map(([id, nombre]) => (
|
<SelectItem value="ninguno">Sin agrupación</SelectItem>
|
||||||
<SelectItem key={id} value={id}>
|
|
||||||
{nombre}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Chips de salud */}
|
{/* Chips de salud */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<HealthChip
|
<HealthChip
|
||||||
|
|||||||
@@ -126,6 +126,17 @@ function RouteComponent() {
|
|||||||
const [detail, setDetail] = useState<CarreraRow | null>(null)
|
const [detail, setDetail] = useState<CarreraRow | null>(null)
|
||||||
const [editCarrera, setEditCarrera] = useState<CarreraRow | null>(null)
|
const [editCarrera, setEditCarrera] = useState<CarreraRow | null>(null)
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<CarreraRow | null>(null)
|
||||||
|
|
||||||
|
// ✅ Se declara UNA SOLA VEZ
|
||||||
|
const { setOpen: setDeleteOpen, dialog: deleteDialog } = useDeleteCarreraDialog(
|
||||||
|
deleteTarget?.id ?? "",
|
||||||
|
async () => {
|
||||||
|
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
|
||||||
|
router.invalidate()
|
||||||
|
// setDeleteTarget(null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const term = q.trim().toLowerCase()
|
const term = q.trim().toLowerCase()
|
||||||
@@ -198,10 +209,7 @@ function RouteComponent() {
|
|||||||
const border = tint(fac?.color, 0.28)
|
const border = tint(fac?.color, 0.28)
|
||||||
const chip = tint(fac?.color, 0.1)
|
const chip = tint(fac?.color, 0.1)
|
||||||
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
|
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
|
||||||
const { setOpen: setDeleteOpen, dialog: deleteDialog } = useDeleteCarreraDialog(c.id, async () => {
|
|
||||||
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
|
|
||||||
router.invalidate()
|
|
||||||
})
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={c.id}>
|
<ContextMenu key={c.id}>
|
||||||
<ContextMenuTrigger onClick={(e) => openContextMenu(e)}>
|
<ContextMenuTrigger onClick={(e) => openContextMenu(e)}>
|
||||||
@@ -233,11 +241,14 @@ function RouteComponent() {
|
|||||||
<ContextMenuItem onClick={() => setEditCarrera(c)}>
|
<ContextMenuItem onClick={() => setEditCarrera(c)}>
|
||||||
<Icons.Pencil className="w-4 h-4 mr-2" /> Editar
|
<Icons.Pencil className="w-4 h-4 mr-2" /> Editar
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem onClick={() => setDeleteOpen(true)}>
|
<ContextMenuItem onClick={() => {
|
||||||
|
setDeleteTarget(c)
|
||||||
|
setDeleteOpen(true)
|
||||||
|
}}>
|
||||||
<Icons.Trash className="w-4 h-4 mr-2" /> Eliminar
|
<Icons.Trash className="w-4 h-4 mr-2" /> Eliminar
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
{deleteDialog}
|
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -247,6 +258,8 @@ function RouteComponent() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{deleteDialog}
|
||||||
|
|
||||||
{/* Crear / Editar */}
|
{/* Crear / Editar */}
|
||||||
<CarreraFormDialog
|
<CarreraFormDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
|||||||
// ...existing code...
|
// ...existing code...
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { plan, asignaturas: asignaturasPreview } = Route.useLoaderData() as LoaderData
|
//const { plan, asignaturas: asignaturasPreview } = Route.useLoaderData() as LoaderData
|
||||||
|
const { plan } = Route.useLoaderData() as LoaderData
|
||||||
|
|
||||||
|
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(plan.id))
|
||||||
const auth = useSupabaseAuth()
|
const auth = useSupabaseAuth()
|
||||||
const asignaturasCount = asignaturasPreview.length
|
const asignaturasCount = asignaturasPreview.length
|
||||||
|
|
||||||
|
|||||||
@@ -10,44 +10,26 @@ import { Plus, RefreshCcw, Building2 } from "lucide-react"
|
|||||||
import { InfoChip } from "@/components/planes/InfoChip"
|
import { InfoChip } from "@/components/planes/InfoChip"
|
||||||
import { CreatePlanDialog } from "@/components/planes/CreatePlanDialog"
|
import { CreatePlanDialog } from "@/components/planes/CreatePlanDialog"
|
||||||
import { chipTint } from "@/components/planes/chipTint"
|
import { chipTint } from "@/components/planes/chipTint"
|
||||||
import { z } from "zod"
|
import { z } from 'zod'
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select"
|
|
||||||
|
|
||||||
export type PlanDeEstudios = {
|
export type PlanDeEstudios = {
|
||||||
id: string
|
id: string; nombre: string; nivel: string | null; duracion: string | null;
|
||||||
nombre: string
|
total_creditos: number | null; estado: string | null; fecha_creacion: string | null; carrera_id: string | null
|
||||||
nivel: string | null
|
|
||||||
duracion: string | null
|
|
||||||
total_creditos: number | null
|
|
||||||
estado: string | null
|
|
||||||
fecha_creacion: string | null
|
|
||||||
carrera_id: string | null
|
|
||||||
}
|
}
|
||||||
type PlanRow = PlanDeEstudios & {
|
type PlanRow = PlanDeEstudios & {
|
||||||
carreras: {
|
carreras: {
|
||||||
id: string
|
id: string; nombre: string;
|
||||||
nombre: string
|
facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null
|
||||||
facultades?: {
|
|
||||||
id: string
|
|
||||||
nombre: string
|
|
||||||
color?: string | null
|
|
||||||
icon?: string | null
|
|
||||||
} | null
|
|
||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const planSearchSchema = z.object({
|
const planSearchSchema = z.object({
|
||||||
plan: z.string().nullable(),
|
plan: z.string().nullable()
|
||||||
facultad: z.string().nullable().optional(),
|
|
||||||
carrera: z.string().nullable().optional(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/planes")({
|
export const Route = createFileRoute("/_authenticated/planes")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
@@ -63,191 +45,93 @@ export const Route = createFileRoute("/_authenticated/planes")({
|
|||||||
`)
|
`)
|
||||||
.order("fecha_creacion", { ascending: false })
|
.order("fecha_creacion", { ascending: false })
|
||||||
.limit(100)
|
.limit(100)
|
||||||
|
console.log({ data, error })
|
||||||
if (error) throw new Error(error.message)
|
if (error) throw new Error(error.message)
|
||||||
return (data ?? []) as PlanRow[]
|
return (data ?? []) as PlanRow[]
|
||||||
},
|
},
|
||||||
validateSearch: planSearchSchema,
|
validateSearch: planSearchSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const auth = useSupabaseAuth()
|
const auth = useSupabaseAuth()
|
||||||
const { plan, facultad, carrera } = Route.useSearch()
|
const { plan } = Route.useSearch()
|
||||||
const [openCreate, setOpenCreate] = useState(false)
|
const [openCreate, setOpenCreate] = useState(false)
|
||||||
const data = Route.useLoaderData() as PlanRow[]
|
const data = Route.useLoaderData() as PlanRow[]
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const navigate = useNavigate({ from: Route.fullPath })
|
const navigate = useNavigate({ from: Route.fullPath })
|
||||||
|
|
||||||
const showFacultad =
|
|
||||||
auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
|
|
||||||
const showCarrera =
|
|
||||||
showFacultad || auth.claims?.role === "secretario_academico"
|
|
||||||
|
|
||||||
// 🟣 Lista única de facultades
|
const showFacultad = auth.claims?.role === "lci" || auth.claims?.role === "vicerrectoria"
|
||||||
const facultadesList = useMemo(() => {
|
const showCarrera = showFacultad || auth.claims?.role === "secretario_academico"
|
||||||
const unique = new Map<string, string>()
|
|
||||||
data?.forEach((p) => {
|
|
||||||
const fac = p.carreras?.facultades
|
|
||||||
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
|
|
||||||
})
|
|
||||||
return Array.from(unique.entries())
|
|
||||||
}, [data])
|
|
||||||
|
|
||||||
// 🎓 Lista de carreras según facultad seleccionada
|
|
||||||
const carrerasList = useMemo(() => {
|
|
||||||
const unique = new Map<string, string>()
|
|
||||||
data?.forEach((p) => {
|
|
||||||
if (
|
|
||||||
p.carreras?.id &&
|
|
||||||
p.carreras?.nombre &&
|
|
||||||
(!facultad || p.carreras?.facultades?.id === facultad)
|
|
||||||
) {
|
|
||||||
unique.set(p.carreras.id, p.carreras.nombre)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return Array.from(unique.entries())
|
|
||||||
}, [data, facultad])
|
|
||||||
|
|
||||||
// 🧩 Filtrado general
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const term = plan?.trim().toLowerCase()
|
const term = plan?.trim().toLowerCase()
|
||||||
let results = data ?? []
|
if (!term || !data) return data
|
||||||
|
return data.filter((p) =>
|
||||||
if (term) {
|
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
|
||||||
results = results.filter((p) =>
|
.filter(Boolean)
|
||||||
[p.nombre, p.nivel, p.estado, p.carreras?.nombre, p.carreras?.facultades?.nombre]
|
.some((v) => String(v).toLowerCase().includes(term))
|
||||||
.filter(Boolean)
|
)
|
||||||
.some((v) => String(v).toLowerCase().includes(term))
|
}, [plan, data])
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (facultad && facultad !== "todas") {
|
|
||||||
results = results.filter((p) => p.carreras?.facultades?.id === facultad)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (carrera && carrera !== "todas") {
|
|
||||||
results = results.filter((p) => p.carreras?.id === carrera)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}, [plan, facultad, carrera, data])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<CardHeader className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
<CardTitle className="text-xl font-mono">Planes de estudio</CardTitle>
|
<CardTitle className="text-xl font-mono">Planes de estudio</CardTitle>
|
||||||
|
<div className="flex w-full items-center gap-2 md:w-auto">
|
||||||
<div className="flex w-full flex-col md:flex-row items-center gap-2 md:w-auto">
|
|
||||||
{/* 🔍 Buscador */}
|
|
||||||
<div className="relative w-full md:w-80">
|
<div className="relative w-full md:w-80">
|
||||||
<Input
|
<Input
|
||||||
value={plan ?? ""}
|
value={plan ?? ''}
|
||||||
onChange={(e) =>
|
onChange={e => navigate({ search: { plan: e.target.value } })}
|
||||||
navigate({ search: { plan: e.target.value, facultad, carrera } })
|
|
||||||
}
|
|
||||||
placeholder="Buscar por nombre, nivel, estado…"
|
placeholder="Buscar por nombre, nivel, estado…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
|
||||||
{/* 🏛️ Filtro por facultad */}
|
|
||||||
<Select
|
|
||||||
value={facultad ?? "todas"}
|
|
||||||
onValueChange={(val) =>
|
|
||||||
navigate({ search: { plan, facultad: val, carrera: "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>
|
|
||||||
|
|
||||||
{/* 🎓 Filtro por carrera (según facultad) */}
|
|
||||||
{facultad && facultad !== "todas" && (
|
|
||||||
<Select
|
|
||||||
value={carrera ?? "todas"}
|
|
||||||
onValueChange={(val) =>
|
|
||||||
navigate({ search: { plan, facultad, carrera: 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
{/* 🔁 Recargar */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => router.invalidate()}
|
|
||||||
title="Recargar"
|
|
||||||
>
|
|
||||||
<RefreshCcw className="h-4 w-4" />
|
<RefreshCcw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* ➕ Nuevo plan */}
|
|
||||||
<Button onClick={() => setOpenCreate(true)}>
|
<Button onClick={() => setOpenCreate(true)}>
|
||||||
<Plus className="mr-2 h-4 w-4" /> Nuevo plan
|
<Plus className="mr-2 h-4 w-4" /> Nuevo plan
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{/* GRID de tarjetas */}
|
{/* GRID de tarjetas con estilo suave por facultad */}
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{filtered?.map((p) => {
|
{filtered?.map((p) => {
|
||||||
const fac = p.carreras?.facultades
|
const fac = p.carreras?.facultades
|
||||||
const styles = chipTint(fac?.color)
|
const styles = chipTint(fac?.color)
|
||||||
const IconComp =
|
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Building2
|
||||||
(fac?.icon && (Icons as any)[fac.icon]) || Building2
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={p.id}
|
key={p.id}
|
||||||
to="/plan/$planId"
|
to="/plan/$planId"
|
||||||
mask={{ to: "/plan/$planId", params: { planId: p.id } }}
|
mask={{ to: '/plan/$planId', params: { planId: p.id } }}
|
||||||
className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5"
|
className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||||
params={{ planId: p.id }}
|
params={{ planId: p.id }}
|
||||||
style={styles}
|
style={styles}
|
||||||
>
|
>
|
||||||
<div className="relative p-5 h-40 flex flex-col justify-between">
|
<div className="relative p-5 h-40 flex flex-col justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span
|
<span className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2"
|
||||||
className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2"
|
style={{ borderColor: styles.borderColor as string, background: 'rgba(255,255,255,.6)' }}>
|
||||||
style={{
|
|
||||||
borderColor: styles.borderColor as string,
|
|
||||||
background: "rgba(255,255,255,.6)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconComp className="w-6 h-6" />
|
<IconComp className="w-6 h-6" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-semibold truncate">{p.nombre}</div>
|
<div className="font-semibold truncate">{p.nombre}</div>
|
||||||
<div className="text-xs text-neutral-600 truncate">
|
<div className="text-xs text-neutral-600 truncate">
|
||||||
{p.nivel ?? "—"}{" "}
|
{p.nivel ?? "—"} {p.duracion ? `· ${p.duracion}` : ""}
|
||||||
{p.duracion ? `· ${p.duracion}` : ""}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Dentro del map de tarjetas, sustituye SOLO el footer inferior */}
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
{/* grupo izquierdo: chips (wrap si no caben) */}
|
||||||
<div className="min-w-0 flex-1 flex flex-wrap items-center gap-2">
|
<div className="min-w-0 flex-1 flex flex-wrap items-center gap-2">
|
||||||
{showCarrera && p.carreras?.nombre && (
|
{showCarrera && p.carreras?.nombre && (
|
||||||
<InfoChip
|
<InfoChip
|
||||||
@@ -264,21 +148,18 @@ function RouteComponent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* derecha: estado */}
|
||||||
{p.estado && (
|
{p.estado && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-white/60"
|
className="bg-white/60"
|
||||||
style={{
|
style={{ borderColor: (chipTint(fac?.color).borderColor as string) }}
|
||||||
borderColor:
|
|
||||||
chipTint(fac?.color).borderColor as string,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{p.estado.length > 10
|
{p.estado && p.estado.length > 10 ? `${p.estado.slice(0, 10)}…` : p.estado}
|
||||||
? `${p.estado.slice(0, 10)}…`
|
|
||||||
: p.estado}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
@@ -286,14 +167,16 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!filtered?.length && (
|
{!filtered?.length && (
|
||||||
<div className="text-center text-sm text-muted-foreground py-10">
|
<div className="text-center text-sm text-muted-foreground py-10">Sin resultados</div>
|
||||||
Sin resultados
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<CreatePlanDialog open={openCreate} onOpenChange={setOpenCreate} />
|
<CreatePlanDialog
|
||||||
|
open={openCreate}
|
||||||
|
onOpenChange={setOpenCreate}
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user