345 lines
13 KiB
TypeScript
345 lines
13 KiB
TypeScript
// routes/_authenticated/archivos.tsx
|
|
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
|
import { use, useMemo, useState } from "react"
|
|
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
|
import * as Icons from "lucide-react"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Label } from "@/components/ui/label"
|
|
import {
|
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
|
import { DetailDialog } from "@/components/archivos/DetailDialog"
|
|
|
|
import type { RefRow } from "@/types/RefRow"
|
|
import { uuid } from "zod"
|
|
|
|
export const Route = createFileRoute("/_authenticated/archivos")({
|
|
component: RouteComponent,
|
|
loader: async () => {
|
|
const { data, error } = await supabase
|
|
.from("documentos")
|
|
.select("*")
|
|
.order("fecha_subida", { ascending: false })
|
|
.limit(200)
|
|
if (error) throw error
|
|
return (data ?? []) as RefRow[]
|
|
},
|
|
})
|
|
|
|
function chipTint(ok?: boolean | null) {
|
|
return ok
|
|
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
|
: "bg-amber-50 text-amber-800 border-amber-200"
|
|
}
|
|
|
|
function RouteComponent() {
|
|
const router = useRouter()
|
|
const rows = Route.useLoaderData() as RefRow[]
|
|
|
|
const [q, setQ] = useState("")
|
|
const [estado, setEstado] = useState<"todos" | "proc" | "pend">("todos")
|
|
const [scope, setScope] = useState<"todos" | "internos" | "externos">("todos")
|
|
|
|
const [viewing, setViewing] = useState<RefRow | null>(null)
|
|
const [uploadOpen, setUploadOpen] = useState(false)
|
|
|
|
const filtered = useMemo(() => {
|
|
const t = q.trim().toLowerCase()
|
|
return rows.filter((r) => {
|
|
if (estado === "proc" && !r.procesado) return false
|
|
if (estado === "pend" && r.procesado) return false
|
|
if (scope === "internos" && !r.interno) return false
|
|
if (scope === "externos" && r.interno) return false
|
|
|
|
if (!t) return true
|
|
const hay =
|
|
[r.titulo_archivo, r.descripcion, r.fuente_autoridad, r.tipo_contenido, ...(r.tags ?? [])]
|
|
.filter(Boolean)
|
|
.some((v) => String(v).toLowerCase().includes(t))
|
|
return hay
|
|
})
|
|
}, [rows, q, estado, scope])
|
|
|
|
async function remove(id: string) {
|
|
if (!confirm("¿Eliminar archivo de referencia?")) return
|
|
const { error } = await supabase
|
|
.from("documentos")
|
|
.delete()
|
|
.eq("documentos_id", id)
|
|
if (error) return alert(error.message)
|
|
|
|
try {
|
|
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/eliminar/documento`, {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ documentos_id: id }),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
throw new Error("Se falló al eliminar el documento")
|
|
}
|
|
} catch (err) {
|
|
console.error("Error al eliminar el documento:", err)
|
|
}
|
|
|
|
router.invalidate()
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 space-y-4">
|
|
<Card>
|
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<CardTitle className="font-mono">Archivos de referencia</CardTitle>
|
|
|
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
|
<div className="relative w-full sm:w-80">
|
|
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
|
<Input
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
placeholder="Buscar por título, etiqueta, fuente…"
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
|
|
<Select value={estado} onValueChange={(v: any) => setEstado(v)}>
|
|
<SelectTrigger className="sm:w-[160px]">
|
|
<SelectValue placeholder="Estado" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="todos">Todos</SelectItem>
|
|
<SelectItem value="proc">Procesados</SelectItem>
|
|
<SelectItem value="pend">Pendientes</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select value={scope} onValueChange={(v: any) => setScope(v)}>
|
|
<SelectTrigger className="sm:w-[160px]">
|
|
<SelectValue placeholder="Ámbito" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="todos">Todos</SelectItem>
|
|
<SelectItem value="internos">Internos</SelectItem>
|
|
<SelectItem value="externos">Externos</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Button onClick={() => setUploadOpen(true)}>
|
|
<Icons.Upload className="w-4 h-4 mr-2" /> Nuevo
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent>
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
{filtered.map((r) => (
|
|
<article
|
|
key={r.documentos_id}
|
|
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
|
|
>
|
|
<header className="min-w-0">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h3 className="font-semibold truncate">{r.titulo_archivo ?? "(Sin título)"}</h3>
|
|
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${chipTint(r.procesado)}`}>
|
|
{r.procesado ? "Procesado" : "Pendiente"}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 text-xs text-neutral-600 flex flex-wrap gap-2">
|
|
{r.tipo_contenido && <Badge variant="outline">{r.tipo_contenido}</Badge>}
|
|
{r.interno != null && (
|
|
<Badge variant="outline">{r.interno ? "Interno" : "Externo"}</Badge>
|
|
)}
|
|
{r.fecha_subida && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Icons.CalendarClock className="w-3 h-3" />
|
|
{new Date(r.fecha_subida).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{r.descripcion && (
|
|
<p className="text-sm text-neutral-700 line-clamp-3">{r.descripcion}</p>
|
|
)}
|
|
|
|
{/* Tags
|
|
{r.tags && r.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{r.tags.map((t, i) => (
|
|
<span key={i} className="text-[10px] px-2 py-0.5 rounded-full border bg-white/60">
|
|
#{t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)} */}
|
|
|
|
<div className="mt-auto flex items-center justify-between gap-2">
|
|
<Button variant="ghost" size="sm" onClick={() => setViewing(r)}>
|
|
<Icons.Eye className="w-4 h-4 mr-1" /> Ver
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => remove(r.documentos_id)}>
|
|
<Icons.Trash2 className="w-4 h-4 mr-1" /> Eliminar
|
|
</Button>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
|
|
{!filtered.length && (
|
|
<div className="text-center text-sm text-neutral-500 py-10">No hay archivos</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Detalle */}
|
|
<DetailDialog row={viewing} onClose={() => setViewing(null)} />
|
|
|
|
{/* Subida */}
|
|
<UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onDone={() => router.invalidate()} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ========= Subida ========= */
|
|
function UploadDialog({
|
|
open, onOpenChange, onDone,
|
|
}: { open: boolean; onOpenChange: (o: boolean) => void; onDone: () => void }) {
|
|
const supabaseAuth = useSupabaseAuth()
|
|
const [file, setFile] = useState<File | null>(null)
|
|
const [instrucciones, setInstrucciones] = useState("")
|
|
const [tags, setTags] = useState("")
|
|
const [interno, setInterno] = useState(true)
|
|
const [fuente, setFuente] = useState("")
|
|
const [subiendo, setSubiendo] = useState(false)
|
|
|
|
async function toBase64(f: File): Promise<string> {
|
|
const buf = await f.arrayBuffer()
|
|
const bytes = new Uint8Array(buf)
|
|
let binary = ""
|
|
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
|
|
return btoa(binary)
|
|
}
|
|
|
|
async function upload() {
|
|
if (!file) { alert("Selecciona un archivo"); return }
|
|
if (!instrucciones.trim()) { alert("Escribe las instrucciones"); return }
|
|
|
|
setSubiendo(true)
|
|
try {
|
|
const fileBase64 = await toBase64(file)
|
|
// Enviamos al motor (inserta en la tabla si insert=true)
|
|
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/upload/documento`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
prompt: instrucciones,
|
|
fileBase64,
|
|
insert: true,
|
|
uuid: supabaseAuth.user?.id ?? null,
|
|
}),
|
|
})
|
|
if (!res.ok) {
|
|
const txt = await res.text()
|
|
throw new Error(txt || "Error al subir")
|
|
}
|
|
// Ajustes extra (tags, interno, fuente) si el motor no los llenó
|
|
// Intentamos leer el id que regrese el servicio; si no, solo invalidamos.
|
|
let createdId: string | null = null
|
|
try {
|
|
const payload = await res.json()
|
|
createdId =
|
|
payload?.documentos_id ||
|
|
payload?.id ||
|
|
payload?.data?.documentos_id ||
|
|
null
|
|
} catch { /* noop */ }
|
|
|
|
if (createdId && (tags.trim() || fuente.trim() || typeof interno === "boolean")) {
|
|
await supabase
|
|
.from("documentos")
|
|
.update({
|
|
tags: tags.trim() ? tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
|
|
fuente_autoridad: fuente.trim() || undefined,
|
|
interno,
|
|
})
|
|
.eq("documentos_id", createdId)
|
|
}
|
|
|
|
onOpenChange(false)
|
|
onDone()
|
|
} catch (e: any) {
|
|
alert(e?.message ?? "Error al subir el documento")
|
|
} finally {
|
|
setSubiendo(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="font-mono" >Nuevo archivo de referencia</DialogTitle>
|
|
<DialogDescription>
|
|
Sube un documento y escribe instrucciones para su procesamiento. Se guardará en la base y se marcará como
|
|
<em> procesado </em> cuando termine el flujo.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-3">
|
|
<div className="space-y-1">
|
|
<Label>Archivo</Label>
|
|
<Input type="file" accept=".pdf,.doc,.docx,.txt,.md" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
|
{file && (
|
|
<div className="text-xs text-neutral-600">{file.name} · {(file.size / 1024).toFixed(1)} KB</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label>Instrucciones</Label>
|
|
<Textarea
|
|
value={instrucciones}
|
|
onChange={(e) => setInstrucciones(e.target.value)}
|
|
placeholder="Ej.: Extrae temario, resultados de aprendizaje y bibliografía; limpia ruido y normaliza formato."
|
|
className="min-h-[120px]"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid sm:grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label>Tags (separados por coma)</Label>
|
|
<Input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="normatividad, plan, lineamientos" />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label>Fuente de autoridad</Label>
|
|
<Input value={fuente} onChange={(e) => setFuente(e.target.value)} placeholder="SEP, ANUIES…" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label>Ámbito</Label>
|
|
<Select value={String(interno)} onValueChange={(v) => setInterno(v === "true")}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="true">Interno</SelectItem>
|
|
<SelectItem value="false">Externo</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
|
<Button onClick={upload} disabled={subiendo || !file || !instrucciones.trim()}>
|
|
{subiendo ? "Subiendo…" : "Subir"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|