feat: enhance CreatePlanDialog with file selection and preview functionality; add pagination and search capabilities

This commit is contained in:
2025-09-04 15:57:39 -06:00
parent 2367baa538
commit b8f446ba48
2 changed files with 319 additions and 126 deletions

View File

@@ -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<LoaderData> => {
const { planId } = params
await Promise.all([
@@ -414,124 +421,3 @@ function SmallStat({ icon: Icon, label, value }: { icon: React.ComponentType<Rea
</div>
)
}
/* ===== 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 (
<>
<Button onClick={() => setOpen(true)}>
<Icons.Plus className="w-4 h-4 mr-2" /> Nueva asignatura
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-[min(92vw,760px)]">
<DialogHeader>
<DialogTitle className="font-mono" >Nueva asignatura</DialogTitle>
<DialogDescription>Elige cómo crearla: manual o generada por IA.</DialogDescription>
</DialogHeader>
<Tabs value={mode} onValueChange={v => setMode(v as "manual" | "ia")} className="w-full">
<TabsList className="grid w-full grid-cols-2 rounded-xl border bg-neutral-50 p-1" aria-label="Modo de creación">
<TabsTrigger value="manual" className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm">
<Icons.PencilLine className="h-4 w-4 mr-2" /> Manual
</TabsTrigger>
<TabsTrigger value="ia" className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm">
<Icons.Sparkles className="h-4 w-4 mr-2" /> Generado por IA
</TabsTrigger>
</TabsList>
<TabsContent value="manual" className="mt-4">
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Nombre"><Input value={f.nombre} onChange={e => setF(s => ({ ...s, nombre: e.target.value }))} /></Field>
<Field label="Clave"><Input value={f.clave} onChange={e => setF(s => ({ ...s, clave: e.target.value }))} /></Field>
<Field label="Tipo"><Input value={f.tipo} onChange={e => setF(s => ({ ...s, tipo: e.target.value }))} placeholder="Obligatoria / Optativa / Taller…" /></Field>
<Field label="Semestre"><Input value={f.semestre} onChange={e => setF(s => ({ ...s, semestre: e.target.value }))} placeholder="110" /></Field>
<Field label="Créditos"><Input value={f.creditos} onChange={e => setF(s => ({ ...s, creditos: e.target.value }))} /></Field>
<Field label="Horas teóricas"><Input value={f.horas_teoricas} onChange={e => setF(s => ({ ...s, horas_teoricas: e.target.value }))} /></Field>
<Field label="Horas prácticas"><Input value={f.horas_practicas} onChange={e => setF(s => ({ ...s, horas_practicas: e.target.value }))} /></Field>
<div className="sm:col-span-2"><Field label="Objetivo (opcional)"><Textarea value={f.objetivos} onChange={e => setF(s => ({ ...s, objetivos: e.target.value }))} className="min-h-[90px]" /></Field></div>
</div>
</TabsContent>
<TabsContent value="ia" className="mt-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2"><Field label="Indica el enfoque / requisitos"><Textarea value={iaPrompt} onChange={e => setIaPrompt(e.target.value)} className="min-h-[120px]" placeholder="Ej.: Diseña una materia de Programación Web con proyectos, evaluación por rúbricas y bibliografía actual…" /></Field></div>
<Field label="Periodo (opcional)"><Input value={iaSemestre} onChange={e => setIaSemestre(e.target.value)} placeholder="110" /></Field>
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
<AuroraButton onClick={submit} disabled={saving || !canSubmit}>
{saving ? (mode === "manual" ? "Guardando…" : "Generando…") : (mode === "manual" ? "Crear" : "Generar e insertar")}
</AuroraButton>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}