diff --git a/bun.lock b/bun.lock index a79c5bb..bca3ce2 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@supabase/supabase-js": "^2.55.0", "@tailwindcss/vite": "^4.1.12", @@ -255,6 +256,8 @@ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], diff --git a/package.json b/package.json index d193b8f..2005e95 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@supabase/supabase-js": "^2.55.0", "@tailwindcss/vite": "^4.1.12", diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..b0363e3 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as SwitchPrimitive from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Switch } diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..2983361 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,34 @@ +// api.ts +const API_BASE = + (import.meta.env.VITE_API_BASE?.replace(/\/$/, "")) || + "http://localhost:3001"; // 👈 tu Bun.serve real + +export async function postAPI(path: string, body: any): Promise { + const url = `${API_BASE}${path}`; + const r = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + // Si necesitas cookies, agrega: credentials: "include", + body: JSON.stringify(body), + }); + + const ct = r.headers.get("content-type") || ""; + const isHTML = ct.includes("text/html"); + const text = await r.text(); + + if (!r.ok) { + throw new Error( + isHTML + ? "El servidor respondió HTML (probable 404 del dashboard/puerto 3000). Revisa API_BASE y el puerto." + : text + ); + } + + // Si el server devolvió JSON correctamente: + if (ct.includes("application/json")) return JSON.parse(text) as T; + + // Último recurso: intenta parsear + try { return JSON.parse(text) as T } catch { + throw new Error("Respuesta no-JSON desde la API."); + } +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index d7b1ce2..a53828a 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as AuthenticatedUsuariosRouteImport } from './routes/_authenticat import { Route as AuthenticatedPlanesRouteImport } from './routes/_authenticated/planes' import { Route as AuthenticatedFacultadesRouteImport } from './routes/_authenticated/facultades' import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard' +import { Route as AuthenticatedCarrerasRouteImport } from './routes/_authenticated/carreras' import { Route as AuthenticatedAsignaturasRouteImport } from './routes/_authenticated/asignaturas' import { Route as AuthenticatedArchivosRouteImport } from './routes/_authenticated/archivos' import { Route as AuthenticatedPlanPlanIdRouteImport } from './routes/_authenticated/plan/$planId' @@ -57,6 +58,11 @@ const AuthenticatedDashboardRoute = AuthenticatedDashboardRouteImport.update({ path: '/dashboard', getParentRoute: () => AuthenticatedRoute, } as any) +const AuthenticatedCarrerasRoute = AuthenticatedCarrerasRouteImport.update({ + id: '/carreras', + path: '/carreras', + getParentRoute: () => AuthenticatedRoute, +} as any) const AuthenticatedAsignaturasRoute = AuthenticatedAsignaturasRouteImport.update({ id: '/asignaturas', @@ -97,6 +103,7 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/archivos': typeof AuthenticatedArchivosRoute '/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren + '/carreras': typeof AuthenticatedCarrerasRoute '/dashboard': typeof AuthenticatedDashboardRoute '/facultades': typeof AuthenticatedFacultadesRoute '/planes': typeof AuthenticatedPlanesRoute @@ -111,6 +118,7 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/archivos': typeof AuthenticatedArchivosRoute '/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren + '/carreras': typeof AuthenticatedCarrerasRoute '/dashboard': typeof AuthenticatedDashboardRoute '/facultades': typeof AuthenticatedFacultadesRoute '/planes': typeof AuthenticatedPlanesRoute @@ -127,6 +135,7 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/_authenticated/archivos': typeof AuthenticatedArchivosRoute '/_authenticated/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren + '/_authenticated/carreras': typeof AuthenticatedCarrerasRoute '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute '/_authenticated/facultades': typeof AuthenticatedFacultadesRoute '/_authenticated/planes': typeof AuthenticatedPlanesRoute @@ -143,6 +152,7 @@ export interface FileRouteTypes { | '/login' | '/archivos' | '/asignaturas' + | '/carreras' | '/dashboard' | '/facultades' | '/planes' @@ -157,6 +167,7 @@ export interface FileRouteTypes { | '/login' | '/archivos' | '/asignaturas' + | '/carreras' | '/dashboard' | '/facultades' | '/planes' @@ -172,6 +183,7 @@ export interface FileRouteTypes { | '/login' | '/_authenticated/archivos' | '/_authenticated/asignaturas' + | '/_authenticated/carreras' | '/_authenticated/dashboard' | '/_authenticated/facultades' | '/_authenticated/planes' @@ -239,6 +251,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedDashboardRouteImport parentRoute: typeof AuthenticatedRoute } + '/_authenticated/carreras': { + id: '/_authenticated/carreras' + path: '/carreras' + fullPath: '/carreras' + preLoaderRoute: typeof AuthenticatedCarrerasRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/asignaturas': { id: '/_authenticated/asignaturas' path: '/asignaturas' @@ -301,6 +320,7 @@ const AuthenticatedAsignaturasRouteWithChildren = interface AuthenticatedRouteChildren { AuthenticatedArchivosRoute: typeof AuthenticatedArchivosRoute AuthenticatedAsignaturasRoute: typeof AuthenticatedAsignaturasRouteWithChildren + AuthenticatedCarrerasRoute: typeof AuthenticatedCarrerasRoute AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute AuthenticatedFacultadesRoute: typeof AuthenticatedFacultadesRoute AuthenticatedPlanesRoute: typeof AuthenticatedPlanesRoute @@ -313,6 +333,7 @@ interface AuthenticatedRouteChildren { const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedArchivosRoute: AuthenticatedArchivosRoute, AuthenticatedAsignaturasRoute: AuthenticatedAsignaturasRouteWithChildren, + AuthenticatedCarrerasRoute: AuthenticatedCarrerasRoute, AuthenticatedDashboardRoute: AuthenticatedDashboardRoute, AuthenticatedFacultadesRoute: AuthenticatedFacultadesRoute, AuthenticatedPlanesRoute: AuthenticatedPlanesRoute, diff --git a/src/routes/_authenticated.tsx b/src/routes/_authenticated.tsx index 2af6eeb..5bdf818 100644 --- a/src/routes/_authenticated.tsx +++ b/src/routes/_authenticated.tsx @@ -26,6 +26,8 @@ import { KeySquare, IdCard, Users2Icon, + FileAxis3D, + FolderCheck, } from "lucide-react" import { useSupabaseAuth } from "@/auth/supabase" @@ -43,6 +45,7 @@ const nav = [ { to: "/asignaturas", label: "Asignaturas", icon: FileText }, { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { to: "/usuarios", label: "Usuarios", icon: Users2Icon }, + { to: "/archivos", label: "Archivos de referencia", icon: FileAxis3D }, ] as const function getInitials(name?: string) { @@ -60,12 +63,13 @@ function useUserDisplay() { const fullName = [titulo, nombre, apellidos].filter(Boolean).join(" ") const shortName = [titulo, nombre, apellidos.split(" ")[0] ?? ""].filter(Boolean).join(" ") const role = claims?.role ?? "" + return { fullName, shortName, clave, email: user?.email, - avatar: claims?.avatar ?? null, + avatar: claims?.avatar ?? null, initials: getInitials([nombre, apellidos].filter(Boolean).join(" ")), role, isAdmin: Boolean(claims?.claims_admin), @@ -90,7 +94,7 @@ function Layout() { - {}} /> + { }} /> @@ -145,6 +149,9 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) { const { claims } = useSupabaseAuth() const isAdmin = Boolean(claims?.claims_admin) + const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '') + + return (
@@ -171,6 +178,18 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) { ))} + {canSeeCarreras && ( + + + Carreras + + )} + {isAdmin && ( { + const { data, error } = await supabase + .from("fine_tuning_referencias") + .select("*") + .order("fecha_subida", { ascending: false }) + .limit(200) + if (error) throw error + return (data ?? []) as RefRow[] + }, }) -function RouteComponent() { - return
Hello "/_authenticated/archivos"!
+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(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("fine_tuning_referencias") + .delete() + .eq("fine_tuning_referencias_id", id) + if (error) return alert(error.message) + router.invalidate() + } + + return ( +
+ + + Archivos de referencia + +
+
+ + setQ(e.target.value)} + placeholder="Buscar por título, etiqueta, fuente…" + className="pl-8" + /> +
+ + + + + + +
+
+ + +
+ {filtered.map((r) => ( +
+
+
+

{r.titulo_archivo ?? "(Sin título)"}

+ + {r.procesado ? "Procesado" : "Pendiente"} + +
+
+ {r.tipo_contenido && {r.tipo_contenido}} + {r.interno != null && ( + {r.interno ? "Interno" : "Externo"} + )} + {r.fecha_subida && ( + + + {new Date(r.fecha_subida).toLocaleDateString()} + + )} +
+
+ + {r.descripcion && ( +

{r.descripcion}

+ )} + + {r.tags && r.tags.length > 0 && ( +
+ {r.tags.map((t, i) => ( + + #{t} + + ))} +
+ )} + +
+ + +
+
+ ))} +
+ + {!filtered.length && ( +
No hay archivos
+ )} +
+
+ + {/* Detalle */} + setViewing(null)} /> + + {/* Subida */} + router.invalidate()} /> +
+ ) +} + +/* ========= Detalle ========= */ +function DetailDialog({ row, onClose }: { row: RefRow | null; onClose: () => void }) { + return ( + !o && onClose()}> + + + {row?.titulo_archivo ?? "(Sin título)"} + + {row?.descripcion || "Sin descripción"} + + + + {row && ( +
+
+ {row.tipo_contenido ?? "—"} + {row.interno ? "Interno" : "Externo"} + {row.procesado ? "Procesado" : "Pendiente"} + {row.fuente_autoridad && {row.fuente_autoridad}} + {row.fecha_subida && ( + + + {new Date(row.fecha_subida).toLocaleString()} + + )} +
+ + {row.tags && row.tags.length > 0 && ( +
+ Tags: {row.tags.join(", ")} +
+ )} + +
+ +
+ {row.instrucciones || "—"} +
+
+ +
+ +
+                {JSON.stringify(row.contenido_archivo ?? {}, null, 2)}
+              
+
+
+ )} + + + + +
+
+ ) +} + +/* ========= Subida ========= */ +function UploadDialog({ + open, onOpenChange, onDone, +}: { open: boolean; onOpenChange: (o: boolean) => void; onDone: () => void }) { + const [file, setFile] = useState(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 { + 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("http://localhost:3001/api/upload/documento", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt: instrucciones, + fileBase64, + insert: true, + }), + }) + 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?.fine_tuning_referencias_id || + payload?.id || + payload?.data?.fine_tuning_referencias_id || + null + } catch { /* noop */ } + + if (createdId && (tags.trim() || fuente.trim() || typeof interno === "boolean")) { + await supabase + .from("fine_tuning_referencias") + .update({ + tags: tags.trim() ? tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined, + fuente_autoridad: fuente.trim() || undefined, + interno, + }) + .eq("fine_tuning_referencias_id", createdId) + } + + onOpenChange(false) + onDone() + } catch (e: any) { + alert(e?.message ?? "Error al subir el documento") + } finally { + setSubiendo(false) + } + } + + return ( + + + + Nuevo archivo de referencia + + Sube un documento y escribe instrucciones para su procesamiento. Se guardará en la base y se marcará como + procesado cuando termine el flujo. + + + +
+
+ + setFile(e.target.files?.[0] ?? null)} /> + {file && ( +
{file.name} · {(file.size / 1024).toFixed(1)} KB
+ )} +
+ +
+ +