From b8f446ba48bf38846baaaf04956068916d3bce08 Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Thu, 4 Sep 2025 15:57:39 -0600 Subject: [PATCH] feat: enhance CreatePlanDialog with file selection and preview functionality; add pagination and search capabilities --- src/components/planes/CreatePlanDialog.tsx | 315 ++++++++++++++++++++- src/routes/_authenticated/plan/$planId.tsx | 130 +-------- 2 files changed, 319 insertions(+), 126 deletions(-) diff --git a/src/components/planes/CreatePlanDialog.tsx b/src/components/planes/CreatePlanDialog.tsx index aeeb478..09d5c22 100644 --- a/src/components/planes/CreatePlanDialog.tsx +++ b/src/components/planes/CreatePlanDialog.tsx @@ -1,6 +1,6 @@ import { useRouter } from "@tanstack/react-router" import { useSupabaseAuth } from "@/auth/supabase" -import { useState } from "react" +import { useState, useEffect, useMemo, useCallback } from "react" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" @@ -8,7 +8,48 @@ import { Input } from "@/components/ui/input" import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox" import { Button } from "@/components/ui/button" import { postAPI } from "@/lib/api" +import { supabase } from "@/auth/supabase" +import { DetailDialog } from "@/components/archivos/DetailDialog" +import type { RefRow } from "@/types/RefRow"; +// ———————————————————————————————————————————————————————————————— +// Utils +// ———————————————————————————————————————————————————————————————— +function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + useEffect(() => { + const handler = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(handler); + }, [value, delay]); + return debouncedValue; +} + +function fileExt(title?: string) { + const m = (title || "").match(/\.([a-z0-9]{2,5})$/i); + return m ? m[1].toLowerCase() : "file"; +} + +function extIcon(ext: string) { + // Usa íconos de lucide si ya los tienes en tu stack; si no, fallback a emojis + const map: Record = { + pdf: "📄", + doc: "📝", + docx: "📝", + xls: "📊", + xlsx: "📊", + csv: "📑", + ppt: "🖼️", + pptx: "🖼️", + txt: "📃", + md: "🗒️", + json: "{ }", + }; + return map[ext] || "📁"; +} + +// ———————————————————————————————————————————————————————————————— +// Componente principal +// ———————————————————————————————————————————————————————————————— export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) { const router = useRouter() const auth = useSupabaseAuth() @@ -24,9 +65,96 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen "Genera un plan de estudios claro y realista: " ) + const [dbFiles, setDbFiles] = useState<{ + id: string; + titulo: string; + s3_file_path: string; + fecha_subida?: string; + tags?: string[]; + }[]>([]) + const [selectedFiles, setSelectedFiles] = useState([]) + const [lastSelectedIndex, setLastSelectedIndex] = useState(null) + const [searchTerm, setSearchTerm] = useState("") + const [currentPage, setCurrentPage] = useState(1) + const itemsPerPage = 10 + const debouncedSearchTerm = useDebounce(searchTerm, 300) + const totalPages = Math.ceil(dbFiles.length / itemsPerPage); + + const [previewRow, setPreviewRow] = useState(null); + const lockFacultad = role === "secretario_academico" || role === "jefe_carrera" const lockCarrera = role === "jefe_carrera" + useEffect(() => { + async function fetchDbFiles() { + try { + const { data, error } = await supabase + .from("fine_tuning_referencias") + .select("fine_tuning_referencias_id, titulo_archivo, s3_file_path, fecha_subida, tags") + .ilike("titulo_archivo", `%${debouncedSearchTerm}%`) + .range((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage - 1); + + if (error) { + console.error("Error fetching files from database:", error); + return; + } + + setDbFiles((data || []).map((file: any) => ({ + id: file.fine_tuning_referencias_id, + titulo: file.titulo_archivo, + s3_file_path: file.s3_file_path, + fecha_subida: file.fecha_subida, + tags: file.tags || [], + }))); + } catch (err) { + console.error("Unexpected error fetching files:", err); + } + } + + if (open) fetchDbFiles(); + }, [open, debouncedSearchTerm, currentPage]); + + const isSelected = useCallback((path: string) => selectedFiles.includes(path), [selectedFiles]); + + const toggleSelected = useCallback((path: string) => { + setSelectedFiles(prev => prev.includes(path) ? prev.filter(p => p !== path) : [...prev, path]); + }, []); + + const replaceSelection = useCallback((path: string) => { + setSelectedFiles([path]); + }, []); + + const rangeSelect = useCallback((start: number, end: number) => { + const [s, e] = start < end ? [start, end] : [end, start]; + const paths = dbFiles.slice(s, e + 1).map(f => f.s3_file_path); + setSelectedFiles(prev => Array.from(new Set([...prev, ...paths]))); + }, [dbFiles]); + + const handleCardClick = useCallback((e: React.MouseEvent, index: number, file: { s3_file_path: string }) => { + const path = file.s3_file_path; + + if (e.shiftKey && lastSelectedIndex !== null) { + rangeSelect(lastSelectedIndex, index); + } else if (e.metaKey || e.ctrlKey) { + toggleSelected(path); + setLastSelectedIndex(index); + } else { + if (isSelected(path) && selectedFiles.length === 1) { + // si ya es el único seleccionado, des-selecciona + setSelectedFiles([]); + setLastSelectedIndex(null); + } else { + replaceSelection(path); + setLastSelectedIndex(index); + } + } + }, [isSelected, lastSelectedIndex, rangeSelect, replaceSelection, selectedFiles.length, toggleSelected]); + + const clearSelection = () => { + setSelectedFiles([]); + setLastSelectedIndex(null); + }; + async function crearConIA() { setErr(null) if (!carreraId) { setErr("Selecciona una carrera."); return } @@ -36,8 +164,9 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen carreraId, prompt, insert: true, + files: selectedFiles, }) - const newId = res?.id || res?.plan?.id || res?.data?.id + const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id if (newId) { onOpenChange(false) router.invalidate() @@ -53,12 +182,16 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen } } + // ———————————————————————————————————————————————————————————————— + // Render + // ———————————————————————————————————————————————————————————————— return ( - + - Nuevo plan de estudios (IA) + Nuevo plan de estudios (IA) +
@@ -69,10 +202,12 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen placeholder="Describe cómo debe ser el plan…" />
+
setNivel(e.target.value)} />
+
+
+ +
+ + setSearchTerm(e.target.value)} + placeholder="Buscar por título..." + /> +
+ + {/* Toolbar de selección */} +
+
+ {selectedFiles.length > 0 ? ( + + {selectedFiles.length} seleccionado{selectedFiles.length > 1 ? 's' : ''} + + + ) : ( + Tip: ⇧ para seleccionar rango, ⌘/Ctrl para múltiples. + )} +
+
+ + {/* Grid de archivos con selección tipo file manager */} +
+ +
+ {dbFiles.map((file, index) => { + const ext = fileExt(file.titulo); + const selected = isSelected(file.s3_file_path); + return ( + +
+ + {/* Footer compacto */} +
+ {ext.toUpperCase()} + {selected ? Seleccionado : Click para seleccionar} +
+ + ) + })} + + {dbFiles.length === 0 && ( +

No se encontraron archivos.

+ )} +
+ + {/* Paginación mejorada */} + {dbFiles.length > itemsPerPage && ( +
+
Página {currentPage} de {totalPages}
+
+ + { + const v = parseInt(e.target.value || '1', 10); + if (!isNaN(v)) setCurrentPage(Math.min(Math.max(v, 1), totalPages)); + }} + /> + +
+
+ )} +
+ {err &&
{err}
} +
+ + {previewRow && ( + setPreviewRow(null)} + /> + )}
) } diff --git a/src/routes/_authenticated/plan/$planId.tsx b/src/routes/_authenticated/plan/$planId.tsx index f80a255..af8ed4b 100644 --- a/src/routes/_authenticated/plan/$planId.tsx +++ b/src/routes/_authenticated/plan/$planId.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, Link } from "@tanstack/react-router" +import { createFileRoute, Link, redirect } from "@tanstack/react-router" import { useEffect, useMemo, useRef, useState } from "react" import * as Icons from "lucide-react" import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query" @@ -19,12 +19,19 @@ import { Textarea } from "@/components/ui/textarea" import { AuroraButton } from "@/components/effect/aurora-button" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { DeletePlanButton } from "@/components/planes/DeletePlan" +import { AddAsignaturaButton } from "@/components/planes/AddAsignaturaButton" type LoaderData = { planId: string } export const Route = createFileRoute("/_authenticated/plan/$planId")({ component: RouteComponent, pendingComponent: PageSkeleton, + beforeLoad: ({ params }) => { + if (!params.planId) { + throw redirect({ to: "/planes", search: { plan: "" } }) + } + }, + loader: async ({ params, context: { queryClient } }): Promise => { const { planId } = params await Promise.all([ @@ -414,124 +421,3 @@ function SmallStat({ icon: Icon, label, value }: { icon: React.ComponentType ) } - -/* ===== Crear Asignatura ===== */ -function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) { - const qc = useQueryClient() - const [open, setOpen] = useState(false) - const [saving, setSaving] = useState(false) - const [mode, setMode] = useState<"manual" | "ia">("manual") - - const [f, setF] = useState({ nombre: "", clave: "", tipo: "", semestre: "", creditos: "", horas_teoricas: "", horas_practicas: "", objetivos: "" }) - const [iaPrompt, setIaPrompt] = useState("") - const [iaSemestre, setIaSemestre] = useState("") - - const toNull = (s: string) => s.trim() ? s : null - const toNum = (s: string) => s.trim() ? Number(s) || null : null - - const canManual = f.nombre.trim().length > 0 - const canIA = iaPrompt.trim().length > 0 - const canSubmit = mode === "manual" ? canManual : canIA - - async function createManual() { - if (!canManual) return - setSaving(true) - const payload = { - plan_id: planId, - nombre: f.nombre.trim(), - clave: toNull(f.clave), - tipo: toNull(f.tipo), - semestre: toNum(f.semestre), - creditos: toNum(f.creditos), - horas_teoricas: toNum(f.horas_teoricas), - horas_practicas: toNum(f.horas_practicas), - objetivos: toNull(f.objetivos), - contenidos: {}, bibliografia: [], criterios_evaluacion: null, - } - const { error } = await supabase.from("asignaturas").insert([payload]) - setSaving(false) - if (error) { alert(error.message); return } - setOpen(false) - onAdded?.() - // Warm up cache quickly - qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) }) - qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) }) - } - - async function createWithAI() { - if (!canIA) return - setSaving(true) - try { - const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/generar/asignatura`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true }), - }) - if (!res.ok) throw new Error(await res.text()) - confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } }) - setOpen(false) - onAdded?.() - qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) }) - qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) }) - } catch (e: any) { - alert(e?.message ?? "Error al generar la asignatura") - } finally { setSaving(false) } - } - - const submit = () => (mode === "manual" ? createManual() : createWithAI()) - - return ( - <> - - - - - - Nueva asignatura - Elige cómo crearla: manual o generada por IA. - - - setMode(v as "manual" | "ia")} className="w-full"> - - - Manual - - - Generado por IA - - - - -
- setF(s => ({ ...s, nombre: e.target.value }))} /> - setF(s => ({ ...s, clave: e.target.value }))} /> - setF(s => ({ ...s, tipo: e.target.value }))} placeholder="Obligatoria / Optativa / Taller…" /> - setF(s => ({ ...s, semestre: e.target.value }))} placeholder="1–10" /> - setF(s => ({ ...s, creditos: e.target.value }))} /> - setF(s => ({ ...s, horas_teoricas: e.target.value }))} /> - setF(s => ({ ...s, horas_practicas: e.target.value }))} /> -