8 Commits

15 changed files with 773 additions and 448 deletions

View File

@@ -1,4 +0,0 @@
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImV4ZGtzc3Vyem1qbm5oZ3RpYW1hIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEzNzg2MzIsImV4cCI6MjA1Njk1NDYzMn0.g1mBmsw-i6F6e-tPv5gWkHZacyPM2Y9X0fiKVYmVYKE
#VITE_BACK_ORIGIN=http://localhost:3001
VITE_BACK_ORIGIN=http://localhost:3001

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ count.txt
.env*
.nitro
.tanstack
.cta.json

View File

@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { supabase } from "@/auth/supabase"
import ReactMarkdown from "react-markdown"
import { useSupabaseAuth } from "@/auth/supabase"
export function HistorialCambiosModal({

View File

@@ -6,15 +6,12 @@ import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { AuroraButton } from "@/components/effect/aurora-button"
import confetti from "canvas-confetti"
import { useQueryClient } from "@tanstack/react-query"
import { supabase, useSupabaseAuth } from "@/auth/supabase"
import { Field } from "./Field"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
import { asignaturaKeys } from "./planQueries"
import { useRouter } from "@tanstack/react-router"
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
const qc = useQueryClient()
const router = useRouter()
const supabaseAuth = useSupabaseAuth()
const [open, setOpen] = useState(false)

View File

@@ -33,7 +33,6 @@ export function DownloadPlanPDF({ plan }: { plan: PlanLike }) {
const sectionGap = 10 // Espacio entre recuadros de sección
const bodyFontSize = 10.5
const headingFontSize = 12
const subHeadingFontSize = 10
const bulletGlifo = "\u21D2" // Flecha doble (⇒) para el glifo
const bulletIndent = 6 // Sangría para el texto de la lista

View File

@@ -72,7 +72,7 @@ const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb
/* =====================================================
Expandable text
===================================================== */
function ExpandableText({ text, mono = false }: { text?: string | string[] | null; mono?: boolean }) {
function ExpandableText({ text }: { text?: string | string[] | null; mono?: boolean }) {
const [open, setOpen] = useState(false)
if (!text || (Array.isArray(text) && text.length === 0)) {
return <span className="text-neutral-400"></span>
@@ -127,16 +127,6 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
const [draft, setDraft] = useState("")
const plan_format={
"objetivo_general": "...",
"sistema_evaluacion": "...",
"perfil_ingreso": "...",
"perfil_egreso": "...",
"competencias_genericas": "...",
"competencias_especificas": "...",
"indicadores_desempeno": "...",
"pertinencia": "..."
}
// --- mutation con actualización optimista ---
const updateField = useMutation({
@@ -318,7 +308,7 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
onClose={() => setOpenHistorial(false)}
planId={planId}
onRestore={async (key, value) => {
updateField.mutate({ key, value })
updateField.mutate({ key: key as keyof PlanTextFields, value })
}}
/>

View File

@@ -1,4 +1,4 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { supabase } from "@/auth/supabase";
/**
@@ -11,8 +11,6 @@ export function useSupabaseUpdateWithHistory<T extends Record<string, any>>(
tableName: string,
idKey: keyof T = "id" as keyof T
) {
const qc = useQueryClient();
// Generar diferencias tipo JSON Patch
function generateDiff(oldData: T, newData: Partial<T>) {
const changes: any[] = [];

View File

@@ -0,0 +1,39 @@
import { supabase } from "@/auth/supabase"
type EdgeModule = "files" | "vectorStores" | "vectorStoreFiles"
type EdgeArgs = {
module: EdgeModule
action: string
params?: Record<string, any>
}
export async function callFilesAndVectorStoresApi<T = unknown>(
args: EdgeArgs,
): Promise<T> {
const { data, error } = await supabase.functions.invoke<any>(
"files-and-vector-stores-api",
{
body: args,
},
)
if (error) {
console.error(error)
throw error
}
const payload = data ?? {}
if (payload.error) {
const msg =
typeof payload.error === "string"
? payload.error
: payload.error.message ?? "Error en la función Edge"
throw new Error(msg)
}
// Soporta tanto `{ data: [...] }` como `[...]`
const result = payload.data !== undefined ? payload.data : payload
return result as T
}

View File

@@ -149,8 +149,6 @@ function Layout() {
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const { claims } = useSupabaseAuth()
const isAdmin = claims?.role === 'lci' || claims?.role === 'vicerrectoria'
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')

View File

@@ -1,6 +1,6 @@
// routes/_authenticated/archivos.tsx
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { use, useMemo, useState } from "react"
import { useEffect, 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"
@@ -9,92 +9,204 @@ 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,
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { RefRow } from "@/types/RefRow"
import { uuid } from "zod"
type EdgeModule = "files" | "vectorStores" | "vectorStoreFiles"
interface VectorStore {
id: string
object: "vector_store"
created_at: number
name: string | null
description?: string | null
usage_bytes: number
file_counts: {
in_progress: number
completed: number
failed: number
cancelled: number
total: number
}
status: string
last_active_at?: number | null
metadata?: Record<string, any> | null
}
interface VectorStoreFile {
id: string
object: string
created_at: number
vector_store_id: string
status: string
usage_bytes: number
last_error?: { code: string; message: string } | null
}
interface VectorStoreFileMeta {
id: string
user_id: string | null
vector_store_id: string
openai_file_id: string
label: string | null
tags: string[] | null
created_at: string
}
type EdgeArgs = {
module: EdgeModule
action: string
params?: Record<string, any>
}
async function callFilesAndVectorStoresApi<T = unknown>(
args: EdgeArgs,
): Promise<T> {
const { data, error } = await supabase.functions.invoke<any>(
"files-and-vector-stores-api",
{
body: args,
},
)
if (error) {
console.error(error)
throw error
}
const payload = data ?? {}
if (payload.error) {
const msg =
typeof payload.error === "string"
? payload.error
: payload.error.message ?? "Error en la función Edge"
throw new Error(msg)
}
const result = payload.data !== undefined ? payload.data : payload
return result as T
}
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[]
const stores = await callFilesAndVectorStoresApi<VectorStore[]>({
module: "vectorStores",
action: "list",
params: {
limit: 10,
},
})
return stores ?? []
},
})
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"
/* ====== UI helpers ====== */
function StatusBadge({ status }: { status: string }) {
const label =
status === "completed"
? "Completado"
: status === "in_progress"
? "Procesando"
: status
const base = "text-[10px] px-2 py-0.5 rounded-full border"
if (status === "completed") {
return (
<span
className={`${base} bg-emerald-50 text-emerald-700 border-emerald-200`}
>
{label}
</span>
)
}
if (status === "in_progress") {
return (
<span
className={`${base} bg-amber-50 text-amber-800 border-amber-200`}
>
{label}
</span>
)
}
return (
<span className={`${base} bg-neutral-50 text-neutral-700 border-neutral-200`}>
{label}
</span>
)
}
/* ====== Página principal: lista repositorios (Vector Stores) ====== */
function RouteComponent() {
const router = useRouter()
const rows = Route.useLoaderData() as RefRow[]
const vectorStores = Route.useLoaderData() as VectorStore[]
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 [statusFilter, setStatusFilter] = useState<"all" | "completed" | "in_progress">("all")
const [selected, setSelected] = useState<VectorStore | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
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
const term = q.trim().toLowerCase()
return vectorStores.filter((vs) => {
if (statusFilter !== "all" && vs.status !== statusFilter) return false
if (!term) return true
return (
(vs.name ?? "").toLowerCase().includes(term) ||
(vs.description ?? "").toLowerCase().includes(term)
)
})
}, [rows, q, estado, scope])
}, [vectorStores, q, statusFilter])
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)
function openDetails(vs: VectorStore) {
setSelected(vs)
setDialogOpen(true)
}
async function handleDelete(id: string) {
if (!confirm("¿Eliminar este repositorio y sus archivos asociados en OpenAI?")) return
setDeletingId(id)
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 }),
await callFilesAndVectorStoresApi({
module: "vectorStores",
action: "delete",
params: { vector_store_id: id },
})
if (!res.ok) {
throw new Error("Se falló al eliminar el documento")
}
} catch (err) {
console.error("Error al eliminar el documento:", err)
}
await supabase
.from("vector_store_files_meta")
.delete()
.eq("vector_store_id", id)
router.invalidate()
} catch (err: any) {
alert(err?.message ?? "Error al eliminar el repositorio")
} finally {
setDeletingId(null)
}
}
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>
<CardTitle className="font-mono">Repositorios de archivos</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">
@@ -102,240 +214,502 @@ function RouteComponent() {
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Buscar por título, etiqueta, fuente…"
placeholder="Buscar por nombre o descripción…"
className="pl-8"
/>
</div>
<Select value={estado} onValueChange={(v: any) => setEstado(v)}>
<Select
value={statusFilter}
onValueChange={(v) =>
setStatusFilter(v as "all" | "completed" | "in_progress")
}
>
<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>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="completed">Completados</SelectItem>
<SelectItem value="in_progress">En proceso</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 onClick={() => setCreateOpen(true)}>
<Icons.FolderPlus className="w-4 h-4 mr-2" />
Nuevo repositorio
</Button>
</div>
</CardHeader>
<CardContent>
{filtered.length ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map((r) => (
{filtered.map((vs) => (
<article
key={r.documentos_id}
key={vs.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">
<header className="min-w-0 space-y-1">
<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>
<h3 className="font-semibold truncate">
{vs.name || "(Sin nombre)"}
</h3>
<StatusBadge status={vs.status} />
</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>
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-600">
<Badge variant="outline">
Archivos: {vs.file_counts?.completed ?? 0}
</Badge>
{typeof vs.usage_bytes === "number" && (
<span>
{(vs.usage_bytes / 1024 / 1024).toFixed(2)} MB
</span>
)}
{r.fecha_subida && (
{vs.last_active_at && (
<span className="inline-flex items-center gap-1">
<Icons.CalendarClock className="w-3 h-3" />
{new Date(r.fecha_subida).toLocaleDateString()}
<Icons.Clock3 className="w-3 h-3" />
{new Date(vs.last_active_at * 1000).toLocaleDateString()}
</span>
)}
</div>
</header>
{r.descripcion && (
<p className="text-sm text-neutral-700 line-clamp-3">{r.descripcion}</p>
{vs.description && (
<p className="text-sm text-neutral-700 line-clamp-3">
{vs.description}
</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
<div className="mt-auto flex items-center justify-between gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={() => openDetails(vs)}>
<Icons.Eye className="w-4 h-4 mr-1" /> Abrir
</Button>
<Button variant="ghost" size="sm" onClick={() => remove(r.documentos_id)}>
<Icons.Trash2 className="w-4 h-4 mr-1" /> Eliminar
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(vs.id)}
disabled={deletingId === vs.id}
>
<Icons.Trash2 className="w-4 h-4 mr-1" />
{deletingId === vs.id ? "Eliminando…" : "Eliminar"}
</Button>
</div>
</article>
))}
</div>
{!filtered.length && (
<div className="text-center text-sm text-neutral-500 py-10">No hay archivos</div>
) : (
<div className="text-center text-sm text-neutral-500 py-10">
No hay repositorios todavía. Crea uno nuevo para empezar 🚀
</div>
)}
</CardContent>
</Card>
{/* Detalle */}
<DetailDialog row={viewing} onClose={() => setViewing(null)} />
<CreateVectorStoreDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={() => router.invalidate()}
/>
{/* Subida */}
<UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onDone={() => router.invalidate()} />
<VectorStoreDialog
store={selected}
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) setSelected(null)
}}
onUpdated={() => 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)
/* ====== Crear repositorio ====== */
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)
function CreateVectorStoreDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: () => void
}) {
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [creating, setCreating] = useState(false)
async function handleCreate() {
if (!name.trim()) {
alert("Escribe un nombre para el repositorio")
return
}
async function upload() {
if (!file) { alert("Selecciona un archivo"); return }
if (!instrucciones.trim()) { alert("Escribe las instrucciones"); return }
setSubiendo(true)
setCreating(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,
}),
await callFilesAndVectorStoresApi<VectorStore>({
module: "vectorStores",
action: "create",
params: { name: name.trim(), description: description.trim() || undefined },
})
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")
setName("")
setDescription("")
onCreated()
} catch (err: any) {
alert(err?.message ?? "Error al crear el repositorio")
} finally {
setSubiendo(false)
setCreating(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="font-mono" >Nuevo archivo de referencia</DialogTitle>
<DialogTitle className="font-mono">Nuevo repositorio</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.
Crea un Vector Store para agrupar archivos relacionados.
</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]"
<Label>Nombre</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Planeación curricular, Entrevistas…"
/>
</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>
<Label>Descripción (opcional)</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Breve descripción del contenido de este repositorio."
className="min-h-[80px]"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
<Button onClick={upload} disabled={subiendo || !file || !instrucciones.trim()}>
{subiendo ? "Subiendo…" : "Subir"}
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
{creating ? "Creando…" : "Crear repositorio"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/* ====== Detalle de un repositorio: archivos + subida ====== */
type FileRow = {
file: VectorStoreFile
meta: VectorStoreFileMeta | null
}
function VectorStoreDialog({
store,
open,
onOpenChange,
onUpdated,
}: {
store: VectorStore | null
open: boolean
onOpenChange: (open: boolean) => void
onUpdated: () => void
}) {
const supabaseAuth = useSupabaseAuth()
const [files, setFiles] = useState<FileRow[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [file, setFile] = useState<File | null>(null)
const [label, setLabel] = useState("")
useEffect(() => {
if (!open || !store) return
void refreshFiles()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, store?.id])
async function refreshFiles() {
if (!store) return
setLoading(true)
setError(null)
try {
const vectorFiles = await callFilesAndVectorStoresApi<VectorStoreFile[]>({
module: "vectorStoreFiles",
action: "list",
params: { vector_store_id: store.id },
})
const { data: metaRows, error: metaError } = await supabase
.from("vector_store_files_meta")
.select("*")
.eq("vector_store_id", store.id)
.order("created_at", { ascending: false })
if (metaError) throw metaError
const meta = (metaRows ?? []) as VectorStoreFileMeta[]
const merged: FileRow[] = (vectorFiles ?? []).map((vf) => ({
file: vf,
meta: meta.find((m) => m.openai_file_id === vf.id) ?? null,
}))
setFiles(merged)
} catch (err: any) {
console.error(err)
setError(err?.message ?? "No se pudieron cargar los archivos")
} finally {
setLoading(false)
}
}
async function handleUpload() {
if (!store || !file) {
alert("Selecciona un archivo")
return
}
setUploading(true)
try {
// 1) Subir archivo a OpenAI vía Edge con FormData (igual que en tu script)
const formData = new FormData()
formData.append("module", "files")
formData.append("action", "upload")
formData.append("file", file)
formData.append("purpose", "assistants") // o lo que uses en tu flujo
const { data, error } = await supabase.functions.invoke<any>(
"files-and-vector-stores-api",
{
body: formData,
},
)
if (error) {
console.error(error)
throw error
}
const uploaded = data
// La respuesta es el objeto "file" de OpenAI:
// { object: "file", id: "file-xxx", ... }
const openaiFileId: string | undefined = uploaded?.id
if (!openaiFileId) {
console.error("Respuesta Edge inesperada:", uploaded)
throw new Error("La Edge Function no devolvió el id del archivo")
}
// 2) Mapear archivo al Vector Store (JSON normal)
await callFilesAndVectorStoresApi<any>({
module: "vectorStoreFiles",
action: "create",
params: {
vector_store_id: store.id,
body: {
file_id: openaiFileId,
},
},
})
// 3) Guardar metadata en Supabase
const { error: insertError } = await supabase
.from("vector_store_files_meta")
.insert({
user_id: supabaseAuth.user?.id ?? null,
vector_store_id: store.id,
openai_file_id: openaiFileId,
label: label.trim() || file.name,
})
if (insertError) throw insertError
setFile(null)
setLabel("")
await refreshFiles()
onUpdated()
} catch (err: any) {
console.error(err)
alert(err?.message ?? "Error al subir el archivo")
} finally {
setUploading(false)
}
}
async function handleDeleteFile(fileId: string) {
if (!store) return
if (!confirm("¿Eliminar este archivo del repositorio y de OpenAI?")) return
setRefreshing(true)
try {
await callFilesAndVectorStoresApi<any>({
module: "vectorStoreFiles",
action: "delete",
params: {
vector_store_id: store.id,
file_id: fileId,
},
})
// Opcional: eliminar también el archivo global de OpenAI
await callFilesAndVectorStoresApi<any>({
module: "files",
action: "delete",
params: { id: fileId },
})
await supabase
.from("vector_store_files_meta")
.delete()
.eq("openai_file_id", fileId)
await refreshFiles()
onUpdated()
} catch (err: any) {
console.error(err)
alert(err?.message ?? "Error al eliminar el archivo")
} finally {
setRefreshing(false)
}
}
if (!store) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icons.Folder className="h-4 w-4" />
{store.name || "(Sin nombre)"}
</DialogTitle>
<DialogDescription>
Gestiona los archivos asociados a este repositorio (Vector Store).
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 text-xs text-neutral-600">
<StatusBadge status={store.status} />
<Badge variant="outline">
Archivos completados: {store.file_counts?.completed ?? 0}
</Badge>
<Badge variant="outline">
Total archivos: {store.file_counts?.total ?? 0}
</Badge>
{typeof store.usage_bytes === "number" && (
<span>{(store.usage_bytes / 1024 / 1024).toFixed(2)} MB</span>
)}
</div>
{/* Subida de archivo */}
<div className="space-y-2 rounded-lg border bg-muted/50 p-4">
<Label className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
Agregar archivo al repositorio
</Label>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-end">
<div className="space-y-1">
<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>Título / etiqueta</Label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Ej.: Plan 2025, Entrevista 3…"
/>
<Button
className="mt-2 w-full sm:w-auto"
onClick={handleUpload}
disabled={uploading || !file}
>
{uploading ? "Subiendo…" : "Subir al repositorio"}
</Button>
</div>
</div>
</div>
{/* Lista de archivos */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Archivos en este repositorio</Label>
<Button
variant="ghost"
size="sm"
onClick={() => refreshFiles()}
disabled={loading || refreshing}
>
<Icons.RefreshCw className="h-4 w-4 mr-1" />
Actualizar
</Button>
</div>
{loading ? (
<div className="text-xs text-neutral-500 py-4">
Cargando archivos
</div>
) : error ? (
<div className="text-xs text-red-500 py-4">{error}</div>
) : files.length === 0 ? (
<div className="text-xs text-neutral-500 py-4">
Todavía no hay archivos en este repositorio
</div>
) : (
<ul className="space-y-2 max-h-64 overflow-y-auto pr-1">
{files.map(({ file, meta }) => (
<li
key={file.id}
className="flex items-center justify-between gap-3 rounded-md border bg-background px-3 py-2"
>
<div className="min-w-0">
<p className="font-medium truncate">
{meta?.label || file.id}
</p>
<p className="text-xs text-neutral-500 truncate">
{new Date(file.created_at * 1000).toLocaleString()} ·{" "}
{(file.usage_bytes / 1024).toFixed(1)} KB
</p>
</div>
<div className="flex items-center gap-2">
<StatusBadge status={file.status} />
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteFile(file.id)}
>
<Icons.Trash2 className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cerrar
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -2,7 +2,7 @@ import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
import { supabase } from '@/auth/supabase'
import * as Icons from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
@@ -168,13 +168,13 @@ function RouteComponent() {
const [q, setQ] = useState(search.q ?? '')
const [sem, setSem] = useState<string>('todos')
const [tipo, setTipo] = useState<string>('todos')
const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
const [groupBy] = useState<'semestre' | 'ninguno'>('semestre')
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
const [facultad, setFacultad] = useState("todas")
const [carrera, setCarrera] = useState("todas")
const [facultad, setFacultad] = useState("todas")
const [carrera, setCarrera] = useState("todas")
/* useEffect(() => {
/* useEffect(() => {
const timeout = setTimeout(() => {
router.navigate({
to: '/asignaturas',
@@ -183,10 +183,10 @@ const [carrera, setCarrera] = useState("todas")
})
}, 400)
return () => clearTimeout(timeout)
}, [q]) */
}, [q]) */
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value
setQ(value)
router.navigate({
@@ -199,18 +199,18 @@ function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
})
}
// 🟣 Lista única de facultades
const facultadesList = useMemo(() => {
// 🟣 Lista única de facultades
const facultadesList = useMemo(() => {
const unique = new Map<string, string>()
planes?.forEach((p) => {
const fac = p.carrera?.facultad
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
})
return Array.from(unique.entries())
}, [planes])
}, [planes])
// 🎓 Lista de carreras según la facultad seleccionada
const carrerasList = useMemo(() => {
// 🎓 Lista de carreras según la facultad seleccionada
const carrerasList = useMemo(() => {
const unique = new Map<string, string>()
planes?.forEach((p) => {
if (
@@ -222,7 +222,7 @@ const carrerasList = useMemo(() => {
}
})
return Array.from(unique.entries())
}, [planes, facultad])
}, [planes, facultad])
// NEW: Clonado individual
@@ -256,12 +256,6 @@ const carrerasList = useMemo(() => {
return Array.from(s).sort((a, b) => (a === '—' ? 1 : 0) - (b === '—' ? 1 : 0) || Number(a) - Number(b))
}, [asignaturas])
const tipos = useMemo(() => {
const s = new Set<string>()
asignaturas.forEach(a => s.add(a.tipo ?? '—'))
return Array.from(s).sort()
}, [asignaturas])
// Salud
const salud = useMemo(() => {
let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0
@@ -296,7 +290,7 @@ const carrerasList = useMemo(() => {
return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK && planOK
})
}, [q, sem, tipo, flag, carrera, facultad, asignaturas])
}, [q, sem, tipo, flag, carrera, facultad, asignaturas])
// Agrupación
@@ -316,7 +310,8 @@ const carrerasList = useMemo(() => {
}, [filtered, groupBy])
// Helpers
const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag('') ; setFacultad('todas')
const clearFilters = () => {
setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag(''); setFacultad('todas')
// Actualiza la URL limpiando todos los query params
router.navigate({
to: '/asignaturas',
@@ -550,7 +545,12 @@ const carrerasList = useMemo(() => {
value={search.planId ?? "todos"}
onValueChange={(val) => {
router.navigate({
search: { ...search, planId: val === "todos" ? "" : val },
to: '/asignaturas',
search: {
...search,
planId: val === 'todos' ? '' : val,
},
replace: true,
})
}}
>
@@ -828,7 +828,6 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
const horasT = a.horas_teoricas ?? 0
const horasP = a.horas_practicas ?? 0
const meta = tipoMeta(a.tipo)
const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2
console.log(a);
return (

View File

@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { AcademicSections, planKeys } from "@/components/planes/academic-sections"
import { GradientMesh } from "../../../components/planes/GradientMesh"
import { asignaturaExtraOptions, asignaturaKeys, asignaturasCountOptions, asignaturasPreviewOptions, planByIdOptions, type AsignaturaLite, type PlanFull } from "@/components/planes/planQueries"
import { asignaturaExtraOptions, asignaturaKeys, asignaturasPreviewOptions, planByIdOptions, type AsignaturaLite, type PlanFull } from "@/components/planes/planQueries"
import { softAccentStyle } from "@/components/planes/planHelpers"
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
import { DialogFooter, DialogHeader } from "@/components/ui/dialog"

View File

@@ -17,13 +17,6 @@ import { toast } from "sonner"
/* -------------------- Tipos -------------------- */
const SCOPED_ROLES = ["director_facultad", "secretario_academico", "jefe_carrera"] as const
/* -------------------- Query Keys & Fetcher -------------------- */
const usersKeys = {
@@ -149,35 +142,6 @@ function RouteComponent() {
carrera_id?: string | null
}>({ email: "", password: "" })
function genPassword() {
/*
Supabase requiere que las contraseñas tengan las siguientes características:
- Mínimo de 6 caracteres
- Debe contener al menos una letra minúscula
- Debe contener al menos una letra mayúscula
- Debe contener al menos un número
- Debe contener al menos un carácter especial
Para garantizar la seguridad, generaremos contraseñas de 12 caracteres en vez del mínimo de 6
*/
// 1. Generar una permutación de los números de 1 al 12 con el método Fisher-Yates
const positions = Array.from({ length: 12 }, (_, i) => i);
for (let i = positions.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[positions[i], positions[j]] = [positions[j], positions[i]];
}
// 2. Las correspondencias son las siguientes:
// - El primer número indica la posición de la letra minúscula
// - El segundo número indica la posición de la letra mayúscula
// - El tercer número indica la posición del número
// - El cuarto número indica la posición del carácter especial
// - En las demás posiciones puede haber cualquier caracter alfanumérico
const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("")
return s.slice(0, 14)
}
function RolePill({ role }: { role: Role }) {
const meta = ROLE_META[role]
@@ -197,61 +161,6 @@ function RouteComponent() {
router.invalidate()
}
const upsertNombramiento = useMutation({
mutationFn: async (opts: {
user_id: string
puesto: "director_facultad" | "secretario_academico" | "jefe_carrera"
facultad_id?: string | null
carrera_id?: string | null
}) => {
// cierra vigentes
if (opts.puesto === "jefe_carrera") {
if (!opts.carrera_id) throw new Error("Selecciona carrera")
await supabase
.from("nombramientos")
.update({ hasta: new Date().toISOString().slice(0, 10) })
.eq("puesto", "jefe_carrera")
.eq("carrera_id", opts.carrera_id)
.is("hasta", null)
} else {
if (!opts.facultad_id) throw new Error("Selecciona facultad")
await supabase
.from("nombramientos")
.update({ hasta: new Date().toISOString().slice(0, 10) })
.eq("puesto", opts.puesto)
.eq("facultad_id", opts.facultad_id)
.is("hasta", null)
}
const { error } = await supabase.from("nombramientos").insert({
user_id: opts.user_id,
puesto: opts.puesto,
facultad_id: opts.facultad_id ?? null,
carrera_id: opts.carrera_id ?? null,
desde: new Date().toISOString().slice(0, 10),
hasta: null,
})
if (error) throw error
},
onError: (e: any) => toast.error(e?.message || "Error al registrar nombramiento"),
})
const toggleBan = useMutation({
mutationFn: async (u: UserClaims) => {
throw new Error("Funcionalidad de baneo no implementada aún.")
const banned = false // cuando se tenga acceso a ese campo
// const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
const payload = banned ? { banned_until: null } : { banned_until: new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString() }
// const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
// if (error) throw new Error(error.message)
return !banned
},
onSuccess: async (isBanned) => {
toast.success(isBanned ? "Usuario baneado" : "Usuario desbaneado")
await invalidateAll()
},
onError: (e: any) => toast.error(e?.message || "Error al cambiar estado de baneo"),
})
const createUser = useMutation({
mutationFn: async (payload: typeof createForm) => {
// Validaciones previas
@@ -409,7 +318,7 @@ function RouteComponent() {
</div>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => toggleBan.mutate(u)} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
<Button variant="outline" size="sm" onClick={() => {}} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
<Icons.BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
</Button>
<Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
@@ -425,7 +334,7 @@ function RouteComponent() {
</div>
</div>
<div className="sm:hidden self-start shrink-0 flex gap-1">
<Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><Icons.BanIcon className="w-4 h-4" /></Button>
<Button variant="outline" size="icon" onClick={() => {}} aria-label="Ban/Unban"><Icons.BanIcon className="w-4 h-4" /></Button>
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Icons.Pencil className="w-4 h-4" /></Button>
</div>
</div>

14
staticwebapp.config.json Normal file
View File

@@ -0,0 +1,14 @@
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": [
"/assets/*",
"/*.css",
"/*.js",
"/*.ico",
"/*.png",
"/*.jpg",
"/*.svg"
]
}
}

12
swa-cli.config.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "https://aka.ms/azure/static-web-apps-cli/schema",
"configurations": {
"acad-ia": {
"appLocation": ".",
"outputLocation": "dist",
"appBuildCommand": "npm run build",
"run": "npm run dev",
"appDevserverUrl": "http://localhost:5173"
}
}
}