This repository has been archived on 2026-01-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Acad-IA/src/components/planes/CreatePlanDialog.tsx
2025-11-27 19:41:44 -06:00

547 lines
19 KiB
TypeScript

import { useRouter } from "@tanstack/react-router";
import { useSupabaseAuth } from "@/auth/supabase";
import { useState, useEffect, 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";
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<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;
}) {
const router = useRouter();
const auth = useSupabaseAuth();
const role = auth.claims?.role;
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [facultadId, setFacultadId] = useState(auth.claims?.facultad_id ?? "");
const [carreraId, setCarreraId] = useState(auth.claims?.carrera_id ?? "");
const [nivel, setNivel] = useState("");
const [prompt, setPrompt] = useState(
"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 lockCarrera = role === "jefe_carrera";
useEffect(() => {
async function fetchDbFiles() {
try {
const { data, error } = await supabase
.from("documentos")
.select("documentos_id, titulo_archivo, 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.documentos_id,
titulo: file.titulo_archivo,
s3_file_path: `prueba-referencias/documento_${file.documentos_id}.pdf`,
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((id: string) => {
setSelectedFiles((prev) =>
prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id]
);
}, []);
const replaceSelection = useCallback((id: string) => {
setSelectedFiles([id]);
}, []);
const rangeSelect = useCallback(
(start: number, end: number) => {
const [s, e] = start < end ? [start, end] : [end, start];
const ids = dbFiles.slice(s, e + 1).map((f) => f.id);
setSelectedFiles((prev) => Array.from(new Set([...prev, ...ids])));
},
[dbFiles]
);
const handleCardClick = useCallback(
(e: React.MouseEvent, index: number, file: { id: string }) => {
const id = file.id;
if (e.shiftKey && lastSelectedIndex !== null) {
rangeSelect(lastSelectedIndex, index);
} else if (e.metaKey || e.ctrlKey) {
toggleSelected(id);
setLastSelectedIndex(index);
} else {
if (isSelected(id) && selectedFiles.length === 1) {
// si ya es el único seleccionado, des-selecciona
setSelectedFiles([]);
setLastSelectedIndex(null);
} else {
replaceSelection(id);
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;
}
setSaving(true);
try {
const {
data: { session },
} = await supabase.auth.getSession();
const token = session?.access_token;
const { data, error } = await supabase.functions.invoke(
"crear-plan-estudios",
{
headers: { Authorization: `Bearer ${token}` },
body: {
carreraId,
prompt_usuario: prompt,
insert: true,
archivos_a_usar: [],
},
}
);
if (error) throw error;
const res = JSON.parse(data as string);
const newId =
(res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id;
if (newId) {
onOpenChange(false);
router.invalidate();
router.navigate({ to: "/plan/$planId", params: { planId: newId } });
} else {
onOpenChange(false);
router.invalidate();
}
} catch (e: any) {
setErr(
typeof e?.message === "string" ? e.message : "Error al generar el plan."
);
} finally {
setSaving(false);
}
}
// ————————————————————————————————————————————————————————————————
// Render
// ————————————————————————————————————————————————————————————————
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="min-w-[65vw] max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="font-mono">
Nuevo plan de estudios (IA)
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2 space-y-1">
<Label>Prompt</Label>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[120px]"
placeholder="Describe cómo debe ser el plan…"
/>
</div>
<div className="space-y-1">
<Label>Nivel (opcional)</Label>
<Input value={nivel} onChange={(e) => setNivel(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Facultad</Label>
<FacultadCombobox
value={facultadId}
onChange={(id) => {
setFacultadId(id);
setCarreraId("");
}}
disabled={lockFacultad}
placeholder="Elige una facultad…"
/>
</div>
<div className="md:col-span-2 space-y-1">
<Label>Carrera *</Label>
<CarreraCombobox
facultadId={facultadId}
value={carreraId}
onChange={setCarreraId}
disabled={!facultadId || lockCarrera}
placeholder={
facultadId
? "Elige una carrera…"
: "Selecciona una facultad primero"
}
/>
</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>
{/* 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.id);
console.log(file);
return (
<div
key={file.id}
role="gridcell"
aria-selected={selected}
onClick={(e) => handleCardClick(e, index, file)}
onDoubleClick={(e) => {
e.stopPropagation();
setPreviewRow({
documentos_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({
documentos_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>
</div>
);
})}
{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>}
<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
className="w-full sm:w-auto"
onClick={crearConIA}
disabled={saving}
>
{saving ? "Generando…" : "Generar y crear"}
</Button>
</DialogFooter>
</DialogContent>
{previewRow && (
<DetailDialog row={previewRow} onClose={() => setPreviewRow(null)} />
)}
</Dialog>
);
}