feat: enhance CreatePlanDialog with file selection and preview functionality; add pagination and search capabilities
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useRouter } from "@tanstack/react-router"
|
import { useRouter } from "@tanstack/react-router"
|
||||||
import { useSupabaseAuth } from "@/auth/supabase"
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
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 { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { postAPI } from "@/lib/api"
|
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<T>(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<string, string> = {
|
||||||
|
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 }) {
|
export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useSupabaseAuth()
|
const auth = useSupabaseAuth()
|
||||||
@@ -24,9 +65,96 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
"Genera un plan de estudios claro y realista: "
|
"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<string[]>([])
|
||||||
|
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(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<RefRow | null>(null);
|
||||||
|
|
||||||
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
|
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
|
||||||
const lockCarrera = 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() {
|
async function crearConIA() {
|
||||||
setErr(null)
|
setErr(null)
|
||||||
if (!carreraId) { setErr("Selecciona una carrera."); return }
|
if (!carreraId) { setErr("Selecciona una carrera."); return }
|
||||||
@@ -36,8 +164,9 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
carreraId,
|
carreraId,
|
||||||
prompt,
|
prompt,
|
||||||
insert: true,
|
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) {
|
if (newId) {
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
router.invalidate()
|
router.invalidate()
|
||||||
@@ -53,12 +182,16 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ————————————————————————————————————————————————————————————————
|
||||||
|
// Render
|
||||||
|
// ————————————————————————————————————————————————————————————————
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-[min(92vw,760px)]">
|
<DialogContent className="min-w-[65vw] max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="font-mono">Nuevo plan de estudios (IA)</DialogTitle>
|
<DialogTitle className="font-mono">Nuevo plan de estudios (IA)</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="md:col-span-2 space-y-1">
|
<div className="md:col-span-2 space-y-1">
|
||||||
<Label>Prompt</Label>
|
<Label>Prompt</Label>
|
||||||
@@ -69,10 +202,12 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
placeholder="Describe cómo debe ser el plan…"
|
placeholder="Describe cómo debe ser el plan…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Nivel (opcional)</Label>
|
<Label>Nivel (opcional)</Label>
|
||||||
<Input value={nivel} onChange={(e) => setNivel(e.target.value)} />
|
<Input value={nivel} onChange={(e) => setNivel(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Facultad</Label>
|
<Label>Facultad</Label>
|
||||||
<FacultadCombobox
|
<FacultadCombobox
|
||||||
@@ -82,6 +217,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
placeholder="Elige una facultad…"
|
placeholder="Elige una facultad…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2 space-y-1">
|
<div className="md:col-span-2 space-y-1">
|
||||||
<Label>Carrera *</Label>
|
<Label>Carrera *</Label>
|
||||||
<CarreraCombobox
|
<CarreraCombobox
|
||||||
@@ -92,8 +228,172 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
placeholder={facultadId ? "Elige una carrera…" : "Selecciona una facultad primero"}
|
placeholder={facultadId ? "Elige una carrera…" : "Selecciona una facultad primero"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 space-y-1">
|
||||||
|
<Label>Buscar archivos</Label>
|
||||||
|
<Input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Buscar por título..."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar de selección */}
|
||||||
|
<div className="md:col-span-2 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="text-sm text-neutral-600">
|
||||||
|
{selectedFiles.length > 0 ? (
|
||||||
|
<span>
|
||||||
|
{selectedFiles.length} seleccionado{selectedFiles.length > 1 ? 's' : ''}
|
||||||
|
<button className="ml-3 underline hover:no-underline" onClick={clearSelection}>Limpiar</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>Tip: ⇧ para seleccionar rango, ⌘/Ctrl para múltiples.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid de archivos con selección tipo file manager */}
|
||||||
|
<div className="md:col-span-2 space-y-1">
|
||||||
|
<Label>Archivos de referencia (opcional)</Label>
|
||||||
|
<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) => {
|
||||||
|
const ext = fileExt(file.titulo);
|
||||||
|
const selected = isSelected(file.s3_file_path);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={file.id}
|
||||||
|
role="gridcell"
|
||||||
|
aria-selected={selected}
|
||||||
|
onClick={(e) => handleCardClick(e, index, file)}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setPreviewRow({
|
||||||
|
fine_tuning_referencias_id: file.id,
|
||||||
|
created_by: "unknown",
|
||||||
|
s3_file_path: file.s3_file_path,
|
||||||
|
titulo_archivo: file.titulo,
|
||||||
|
descripcion: "Previsualización del archivo",
|
||||||
|
tipo_contenido: "PDF",
|
||||||
|
interno: true,
|
||||||
|
procesado: true,
|
||||||
|
fuente_autoridad: "",
|
||||||
|
fecha_subida: file.fecha_subida ?? null,
|
||||||
|
tags: file.tags ?? null,
|
||||||
|
instrucciones: "",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCardClick(e as any, index, file);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
"group relative rounded-2xl border bg-white p-4 text-left shadow-sm transition",
|
||||||
|
"hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||||
|
selected ? "border-blue-500 ring-2 ring-blue-500 shadow-md" : "border-neutral-200 hover:border-neutral-300",
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{/* Outline animado tipo file manager */}
|
||||||
|
<span className={[
|
||||||
|
"pointer-events-none absolute inset-0 rounded-2xl ring-2 ring-blue-500",
|
||||||
|
"opacity-0 group-aria-selected:opacity-100 group-focus:opacity-100 transition-opacity",
|
||||||
|
].join(' ')} />
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border bg-neutral-50">
|
||||||
|
<span className="text-lg" aria-hidden>{extIcon(ext)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-semibold text-sm md:text-base truncate" title={file.titulo}>{file.titulo}</h3>
|
||||||
|
{file.fecha_subida ? (
|
||||||
|
<p className="text-xs text-neutral-600">{new Date(file.fecha_subida).toLocaleDateString()}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-neutral-500">Fecha desconocida</p>
|
||||||
|
)}
|
||||||
|
{file.tags && file.tags.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{file.tags.map((tag, i) => (
|
||||||
|
<span key={i} className="text-[10px] px-2 py-0.5 bg-neutral-100 text-neutral-700 rounded-full">#{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Acciones on-hover */}
|
||||||
|
<div className="absolute right-3 top-3 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setPreviewRow({
|
||||||
|
fine_tuning_referencias_id: file.id,
|
||||||
|
created_by: "unknown",
|
||||||
|
s3_file_path: file.s3_file_path,
|
||||||
|
titulo_archivo: file.titulo,
|
||||||
|
descripcion: "Previsualización del archivo",
|
||||||
|
tipo_contenido: "PDF",
|
||||||
|
interno: true,
|
||||||
|
procesado: true,
|
||||||
|
fuente_autoridad: "",
|
||||||
|
fecha_subida: file.fecha_subida ?? null,
|
||||||
|
tags: file.tags ?? null,
|
||||||
|
instrucciones: "",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>Previsualizar</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer compacto */}
|
||||||
|
<div className="mt-4 flex items-center justify-between text-xs text-neutral-600">
|
||||||
|
<span className="truncate">{ext.toUpperCase()}</span>
|
||||||
|
{selected ? <span className="font-medium">Seleccionado</span> : <span className="opacity-60">Click para seleccionar</span>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{dbFiles.length === 0 && (
|
||||||
|
<p className="text-sm text-neutral-500">No se encontraron archivos.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paginación mejorada */}
|
||||||
|
{dbFiles.length > itemsPerPage && (
|
||||||
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="text-sm text-neutral-700">Página {currentPage} de {totalPages}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
||||||
|
>Anterior</Button>
|
||||||
|
<Input
|
||||||
|
className="h-8 w-16 text-center"
|
||||||
|
value={currentPage}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = parseInt(e.target.value || '1', 10);
|
||||||
|
if (!isNaN(v)) setCurrentPage(Math.min(Math.max(v, 1), totalPages));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
|
||||||
|
>Siguiente</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{err && <div className="text-sm text-red-600">{err}</div>}
|
{err && <div className="text-sm text-red-600">{err}</div>}
|
||||||
|
|
||||||
<DialogFooter className="flex flex-col-reverse sm:flex-row gap-2">
|
<DialogFooter className="flex flex-col-reverse sm:flex-row gap-2">
|
||||||
<Button variant="outline" className="w-full sm:w-auto" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
<Button variant="outline" className="w-full sm:w-auto" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
||||||
<Button className="w-full sm:w-auto" onClick={crearConIA} disabled={saving}>
|
<Button className="w-full sm:w-auto" onClick={crearConIA} disabled={saving}>
|
||||||
@@ -101,6 +401,13 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
{previewRow && (
|
||||||
|
<DetailDialog
|
||||||
|
row={previewRow}
|
||||||
|
onClose={() => setPreviewRow(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import * as Icons from "lucide-react"
|
import * as Icons from "lucide-react"
|
||||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"
|
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 { AuroraButton } from "@/components/effect/aurora-button"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { DeletePlanButton } from "@/components/planes/DeletePlan"
|
import { DeletePlanButton } from "@/components/planes/DeletePlan"
|
||||||
|
import { AddAsignaturaButton } from "@/components/planes/AddAsignaturaButton"
|
||||||
|
|
||||||
type LoaderData = { planId: string }
|
type LoaderData = { planId: string }
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
pendingComponent: PageSkeleton,
|
pendingComponent: PageSkeleton,
|
||||||
|
beforeLoad: ({ params }) => {
|
||||||
|
if (!params.planId) {
|
||||||
|
throw redirect({ to: "/planes", search: { plan: "" } })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
|
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
|
||||||
const { planId } = params
|
const { planId } = params
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -414,124 +421,3 @@ function SmallStat({ icon: Icon, label, value }: { icon: React.ComponentType<Rea
|
|||||||
</div>
|
</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="1–10" /></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="1–10" /></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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user