2 Commits

Author SHA1 Message Date
169599874e Se quitan respuestas amigables 2025-11-28 08:32:25 -06:00
9b3880a02f Se corrige edfunction 2025-11-28 07:48:02 -06:00
16 changed files with 454 additions and 776 deletions

4
.env.local2 Normal file
View File

@@ -0,0 +1,4 @@
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,4 +7,3 @@ count.txt
.env* .env*
.nitro .nitro
.tanstack .tanstack
.cta.json

View File

@@ -111,7 +111,7 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
const { data: { session } } = await supabase.auth.getSession(); const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token; const token = session?.access_token;
const resp = await supabase.functions.invoke("conversation-format", { const resp = await supabase.functions.invoke("modal-conversation", {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
body: { action: "start", role: "system", content: context?.cont_conversation ?? "" } body: { action: "start", role: "system", content: context?.cont_conversation ?? "" }
}); });
@@ -147,7 +147,7 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
const { data: { session } } = await supabase.auth.getSession(); const { data: { session } } = await supabase.auth.getSession();
const token = session?.access_token; const token = session?.access_token;
await supabase.functions.invoke("conversation-format", { await supabase.functions.invoke("modal-conversation", {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
body: { action: "end", conversationId: convIdToUse } body: { action: "end", conversationId: convIdToUse }
}); });
@@ -223,7 +223,7 @@ export default function AIChatModal({ open, onClose, context, onAccept, plan_for
}; };
const { data: invokeData, error } = await supabase.functions.invoke( const { data: invokeData, error } = await supabase.functions.invoke(
"conversation-format", "modal-conversation",
{ {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
body: payload body: payload

View File

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

View File

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

View File

@@ -33,6 +33,7 @@ export function DownloadPlanPDF({ plan }: { plan: PlanLike }) {
const sectionGap = 10 // Espacio entre recuadros de sección const sectionGap = 10 // Espacio entre recuadros de sección
const bodyFontSize = 10.5 const bodyFontSize = 10.5
const headingFontSize = 12 const headingFontSize = 12
const subHeadingFontSize = 10
const bulletGlifo = "\u21D2" // Flecha doble (⇒) para el glifo const bulletGlifo = "\u21D2" // Flecha doble (⇒) para el glifo
const bulletIndent = 6 // Sangría para el texto de la lista 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 Expandable text
===================================================== */ ===================================================== */
function ExpandableText({ text }: { text?: string | string[] | null; mono?: boolean }) { function ExpandableText({ text, mono = false }: { text?: string | string[] | null; mono?: boolean }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
if (!text || (Array.isArray(text) && text.length === 0)) { if (!text || (Array.isArray(text) && text.length === 0)) {
return <span className="text-neutral-400"></span> return <span className="text-neutral-400"></span>
@@ -127,6 +127,16 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null) const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
const [draft, setDraft] = useState("") 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 --- // --- mutation con actualización optimista ---
const updateField = useMutation({ const updateField = useMutation({
@@ -303,12 +313,12 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<HistorialCambiosModal <HistorialCambiosModal
open={openHistorial} open={openHistorial}
onClose={() => setOpenHistorial(false)} onClose={() => setOpenHistorial(false)}
planId={planId} planId={planId}
onRestore={async (key, value) => { onRestore={async (key, value) => {
updateField.mutate({ key: key as keyof PlanTextFields, value }) updateField.mutate({ key, value })
}} }}
/> />
@@ -320,7 +330,10 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
section: null,//,iaContext?.title, section: null,//,iaContext?.title,
fieldKey: null,//iaContext?.key, fieldKey: null,//iaContext?.key,
originalText: iaContext?.content, originalText: iaContext?.content,
cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId}`, cont_conversation: `Eres un experto en craer planes de estudios basate en el id del plan ${planId} que se encuentra en la tabla plan_estudios con el mcp para realizar los cambios que se te soliciten Responde únicamente con la información solicitada.
No uses frases como “claro”, “por supuesto”, “aquí tienes”, “con gusto”, “hola”, “perfecto”.
No uses introducciones, despedidas ni texto de relleno.
Entrega solo el contenido útil.`,
}} }}
onAccept={(newText: string) => { onAccept={(newText: string) => {
if (iaContext) { if (iaContext) {

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
// routes/_authenticated/archivos.tsx // routes/_authenticated/archivos.tsx
import { createFileRoute, useRouter } from "@tanstack/react-router" import { createFileRoute, useRouter } from "@tanstack/react-router"
import { useEffect, useMemo, useState } from "react" import { use, useMemo, useState } from "react"
import { supabase, useSupabaseAuth } from "@/auth/supabase" import { supabase, useSupabaseAuth } from "@/auth/supabase"
import * as Icons from "lucide-react" import * as Icons from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -9,204 +9,92 @@ import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { import {
Dialog, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
Select, import { DetailDialog } from "@/components/archivos/DetailDialog"
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
type EdgeModule = "files" | "vectorStores" | "vectorStoreFiles" import type { RefRow } from "@/types/RefRow"
import { uuid } from "zod"
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")({ export const Route = createFileRoute("/_authenticated/archivos")({
component: RouteComponent, component: RouteComponent,
loader: async () => { loader: async () => {
const stores = await callFilesAndVectorStoresApi<VectorStore[]>({ const { data, error } = await supabase
module: "vectorStores", .from("documentos")
action: "list", .select("*")
params: { .order("fecha_subida", { ascending: false })
limit: 10, .limit(200)
}, if (error) throw error
}) return (data ?? []) as RefRow[]
return stores ?? []
}, },
}) })
/* ====== UI helpers ====== */ function chipTint(ok?: boolean | null) {
return ok
function StatusBadge({ status }: { status: string }) { ? "bg-emerald-50 text-emerald-700 border-emerald-200"
const label = : "bg-amber-50 text-amber-800 border-amber-200"
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() { function RouteComponent() {
const router = useRouter() const router = useRouter()
const vectorStores = Route.useLoaderData() as VectorStore[] const rows = Route.useLoaderData() as RefRow[]
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [statusFilter, setStatusFilter] = useState<"all" | "completed" | "in_progress">("all") const [estado, setEstado] = useState<"todos" | "proc" | "pend">("todos")
const [selected, setSelected] = useState<VectorStore | null>(null) const [scope, setScope] = useState<"todos" | "internos" | "externos">("todos")
const [dialogOpen, setDialogOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false) const [viewing, setViewing] = useState<RefRow | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null) const [uploadOpen, setUploadOpen] = useState(false)
const filtered = useMemo(() => { const filtered = useMemo(() => {
const term = q.trim().toLowerCase() const t = q.trim().toLowerCase()
return vectorStores.filter((vs) => { return rows.filter((r) => {
if (statusFilter !== "all" && vs.status !== statusFilter) return false if (estado === "proc" && !r.procesado) return false
if (!term) return true if (estado === "pend" && r.procesado) return false
return ( if (scope === "internos" && !r.interno) return false
(vs.name ?? "").toLowerCase().includes(term) || if (scope === "externos" && r.interno) return false
(vs.description ?? "").toLowerCase().includes(term)
) 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
}) })
}, [vectorStores, q, statusFilter]) }, [rows, q, estado, scope])
function openDetails(vs: VectorStore) { async function remove(id: string) {
setSelected(vs) if (!confirm("¿Eliminar archivo de referencia?")) return
setDialogOpen(true) const { error } = await supabase
} .from("documentos")
.delete()
.eq("documentos_id", id)
if (error) return alert(error.message)
async function handleDelete(id: string) {
if (!confirm("¿Eliminar este repositorio y sus archivos asociados en OpenAI?")) return
setDeletingId(id)
try { try {
await callFilesAndVectorStoresApi({ const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/eliminar/documento`, {
module: "vectorStores", method: "DELETE",
action: "delete", headers: { "Content-Type": "application/json" },
params: { vector_store_id: id }, body: JSON.stringify({ documentos_id: id }),
}) })
await supabase if (!res.ok) {
.from("vector_store_files_meta") throw new Error("Se falló al eliminar el documento")
.delete() }
.eq("vector_store_id", id) } catch (err) {
console.error("Error al eliminar el documento:", err)
router.invalidate()
} catch (err: any) {
alert(err?.message ?? "Error al eliminar el repositorio")
} finally {
setDeletingId(null)
} }
router.invalidate()
} }
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<Card> <Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="font-mono">Repositorios de archivos</CardTitle> <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="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80"> <div className="relative w-full sm:w-80">
@@ -214,502 +102,240 @@ function RouteComponent() {
<Input <Input
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
placeholder="Buscar por nombre o descripción…" placeholder="Buscar por título, etiqueta, fuente…"
className="pl-8" className="pl-8"
/> />
</div> </div>
<Select <Select value={estado} onValueChange={(v: any) => setEstado(v)}>
value={statusFilter}
onValueChange={(v) =>
setStatusFilter(v as "all" | "completed" | "in_progress")
}
>
<SelectTrigger className="sm:w-[160px]"> <SelectTrigger className="sm:w-[160px]">
<SelectValue placeholder="Estado" /> <SelectValue placeholder="Estado" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Todos</SelectItem> <SelectItem value="todos">Todos</SelectItem>
<SelectItem value="completed">Completados</SelectItem> <SelectItem value="proc">Procesados</SelectItem>
<SelectItem value="in_progress">En proceso</SelectItem> <SelectItem value="pend">Pendientes</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button onClick={() => setCreateOpen(true)}> <Select value={scope} onValueChange={(v: any) => setScope(v)}>
<Icons.FolderPlus className="w-4 h-4 mr-2" /> <SelectTrigger className="sm:w-[160px]">
Nuevo repositorio <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> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{filtered.length ? ( <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> {filtered.map((r) => (
{filtered.map((vs) => ( <article
<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"
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">
<div className="flex items-center justify-between gap-2"> <h3 className="font-semibold truncate">{r.titulo_archivo ?? "(Sin título)"}</h3>
<h3 className="font-semibold truncate"> <span className={`text-[10px] px-2 py-0.5 rounded-full border ${chipTint(r.procesado)}`}>
{vs.name || "(Sin nombre)"} {r.procesado ? "Procesado" : "Pendiente"}
</h3> </span>
<StatusBadge status={vs.status} />
</div>
<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>
)}
{vs.last_active_at && (
<span className="inline-flex items-center gap-1">
<Icons.Clock3 className="w-3 h-3" />
{new Date(vs.last_active_at * 1000).toLocaleDateString()}
</span>
)}
</div>
</header>
{vs.description && (
<p className="text-sm text-neutral-700 line-clamp-3">
{vs.description}
</p>
)}
<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={() => handleDelete(vs.id)}
disabled={deletingId === vs.id}
>
<Icons.Trash2 className="w-4 h-4 mr-1" />
{deletingId === vs.id ? "Eliminando…" : "Eliminar"}
</Button>
</div> </div>
</article> <div className="mt-1 text-xs text-neutral-600 flex flex-wrap gap-2">
))} {r.tipo_contenido && <Badge variant="outline">{r.tipo_contenido}</Badge>}
</div> {r.interno != null && (
) : ( <Badge variant="outline">{r.interno ? "Interno" : "Externo"}</Badge>
<div className="text-center text-sm text-neutral-500 py-10"> )}
No hay repositorios todavía. Crea uno nuevo para empezar 🚀 {r.fecha_subida && (
</div> <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> </CardContent>
</Card> </Card>
<CreateVectorStoreDialog {/* Detalle */}
open={createOpen} <DetailDialog row={viewing} onClose={() => setViewing(null)} />
onOpenChange={setCreateOpen}
onCreated={() => router.invalidate()}
/>
<VectorStoreDialog {/* Subida */}
store={selected} <UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onDone={() => router.invalidate()} />
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open)
if (!open) setSelected(null)
}}
onUpdated={() => router.invalidate()}
/>
</div> </div>
) )
} }
/* ====== Crear repositorio ====== */ /* ========= 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)
function CreateVectorStoreDialog({ async function toBase64(f: File): Promise<string> {
open, const buf = await f.arrayBuffer()
onOpenChange, const bytes = new Uint8Array(buf)
onCreated, let binary = ""
}: { for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
open: boolean return btoa(binary)
onOpenChange: (open: boolean) => void }
onCreated: () => void
}) {
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [creating, setCreating] = useState(false)
async function handleCreate() { async function upload() {
if (!name.trim()) { if (!file) { alert("Selecciona un archivo"); return }
alert("Escribe un nombre para el repositorio") if (!instrucciones.trim()) { alert("Escribe las instrucciones"); return }
return
} setSubiendo(true)
setCreating(true)
try { try {
await callFilesAndVectorStoresApi<VectorStore>({ const fileBase64 = await toBase64(file)
module: "vectorStores", // Enviamos al motor (inserta en la tabla si insert=true)
action: "create", const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/upload/documento`, {
params: { name: name.trim(), description: description.trim() || undefined }, 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) onOpenChange(false)
setName("") onDone()
setDescription("") } catch (e: any) {
onCreated() alert(e?.message ?? "Error al subir el documento")
} catch (err: any) {
alert(err?.message ?? "Error al crear el repositorio")
} finally { } finally {
setCreating(false) setSubiendo(false)
} }
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="font-mono">Nuevo repositorio</DialogTitle> <DialogTitle className="font-mono" >Nuevo archivo de referencia</DialogTitle>
<DialogDescription> <DialogDescription>
Crea un Vector Store para agrupar archivos relacionados. 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> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-3"> <div className="grid gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label>Nombre</Label> <Label>Archivo</Label>
<Input <Input type="file" accept=".pdf,.doc,.docx,.txt,.md" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
value={name} {file && (
onChange={(e) => setName(e.target.value)} <div className="text-xs text-neutral-600">{file.name} · {(file.size / 1024).toFixed(1)} KB</div>
placeholder="Planeación curricular, Entrevistas…" )}
/>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Descripción (opcional)</Label> <Label>Instrucciones</Label>
<Textarea <Textarea
value={description} value={instrucciones}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setInstrucciones(e.target.value)}
placeholder="Breve descripción del contenido de este repositorio." placeholder="Ej.: Extrae temario, resultados de aprendizaje y bibliografía; limpia ruido y normaliza formato."
className="min-h-[80px]" className="min-h-[120px]"
/> />
</div> </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> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
Cancelar <Button onClick={upload} disabled={subiendo || !file || !instrucciones.trim()}>
</Button> {subiendo ? "Subiendo…" : "Subir"}
<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> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -2,7 +2,7 @@ import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query' import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
import { supabase } from '@/auth/supabase' import { supabase } from '@/auth/supabase'
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select' import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
@@ -168,25 +168,25 @@ function RouteComponent() {
const [q, setQ] = useState(search.q ?? '') const [q, setQ] = useState(search.q ?? '')
const [sem, setSem] = useState<string>('todos') const [sem, setSem] = useState<string>('todos')
const [tipo, setTipo] = useState<string>('todos') const [tipo, setTipo] = useState<string>('todos')
const [groupBy] = useState<'semestre' | 'ninguno'>('semestre') const [groupBy, setGroupBy] = useState<'semestre' | 'ninguno'>('semestre')
const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '') const [flag, setFlag] = useState<'' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos'>(search.f ?? '')
const [facultad, setFacultad] = useState("todas") const [facultad, setFacultad] = useState("todas")
const [carrera, setCarrera] = useState("todas") const [carrera, setCarrera] = useState("todas")
/* useEffect(() => { /* useEffect(() => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
router.navigate({ router.navigate({
to: '/asignaturas', to: '/asignaturas',
search: { ...search, q }, search: { ...search, q },
replace: true, replace: true,
}) })
}, 400) }, 400)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [q]) */ }, [q]) */
function handleChange(e: React.ChangeEvent<HTMLInputElement>) { function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value const value = e.target.value
setQ(value) setQ(value)
router.navigate({ router.navigate({
@@ -199,30 +199,30 @@ function RouteComponent() {
}) })
} }
// 🟣 Lista única de facultades // 🟣 Lista única de facultades
const facultadesList = useMemo(() => { const facultadesList = useMemo(() => {
const unique = new Map<string, string>() const unique = new Map<string, string>()
planes?.forEach((p) => { planes?.forEach((p) => {
const fac = p.carrera?.facultad const fac = p.carrera?.facultad
if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre) if (fac?.id && fac?.nombre) unique.set(fac.id, fac.nombre)
}) })
return Array.from(unique.entries()) return Array.from(unique.entries())
}, [planes]) }, [planes])
// 🎓 Lista de carreras según la facultad seleccionada // 🎓 Lista de carreras según la facultad seleccionada
const carrerasList = useMemo(() => { const carrerasList = useMemo(() => {
const unique = new Map<string, string>() const unique = new Map<string, string>()
planes?.forEach((p) => { planes?.forEach((p) => {
if ( if (
p.carrera?.id && p.carrera?.id &&
p.carrera?.nombre && p.carrera?.nombre &&
(!facultad || facultad === "todas" || p.carrera?.facultad?.id === facultad) (!facultad || facultad === "todas" || p.carrera?.facultad?.id === facultad)
) { ) {
unique.set(p.carrera.id, p.carrera.nombre) unique.set(p.carrera.id, p.carrera.nombre)
} }
}) })
return Array.from(unique.entries()) return Array.from(unique.entries())
}, [planes, facultad]) }, [planes, facultad])
// NEW: Clonado individual // NEW: Clonado individual
@@ -256,6 +256,12 @@ function RouteComponent() {
return Array.from(s).sort((a, b) => (a === '—' ? 1 : 0) - (b === '—' ? 1 : 0) || Number(a) - Number(b)) return Array.from(s).sort((a, b) => (a === '—' ? 1 : 0) - (b === '—' ? 1 : 0) || Number(a) - Number(b))
}, [asignaturas]) }, [asignaturas])
const tipos = useMemo(() => {
const s = new Set<string>()
asignaturas.forEach(a => s.add(a.tipo ?? '—'))
return Array.from(s).sort()
}, [asignaturas])
// Salud // Salud
const salud = useMemo(() => { const salud = useMemo(() => {
let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0 let sinBibliografia = 0, sinCriterios = 0, sinContenidos = 0
@@ -268,29 +274,29 @@ function RouteComponent() {
}, [asignaturas]) }, [asignaturas])
const filtered = useMemo(() => { const filtered = useMemo(() => {
const t = q.trim().toLowerCase() const t = q.trim().toLowerCase()
return asignaturas.filter(a => { return asignaturas.filter(a => {
const matchesQ = const matchesQ =
!t || !t ||
[a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre] [a.nombre, a.clave, a.tipo, a.objetivos, a.plan?.nombre, a.plan?.carrera?.nombre, a.plan?.carrera?.facultad?.nombre]
.filter(Boolean) .filter(Boolean)
.some(v => String(v).toLowerCase().includes(t)) .some(v => String(v).toLowerCase().includes(t))
const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem const semOK = sem === 'todos' || String(a.semestre ?? '—') === sem
const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo const tipoOK = tipo === 'todos' || (a.tipo ?? '—') === tipo
const carreraOK = carrera === 'todas' || a.plan?.carrera?.id === carrera const carreraOK = carrera === 'todas' || a.plan?.carrera?.id === carrera
const facultadOK = facultad === 'todas' || a.plan?.carrera?.facultad?.id === facultad const facultadOK = facultad === 'todas' || a.plan?.carrera?.facultad?.id === facultad
const planOK = !search.planId || a.plan?.id === search.planId const planOK = !search.planId || a.plan?.id === search.planId
const flagOK = const flagOK =
!flag || !flag ||
(flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) || (flag === 'sinBibliografia' && (!a.bibliografia || a.bibliografia.length === 0)) ||
(flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) || (flag === 'sinCriterios' && (!a.criterios_evaluacion || !a.criterios_evaluacion.trim())) ||
(flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0)) (flag === 'sinContenidos' && (!a.contenidos || Object.keys(a.contenidos ?? {}).length === 0))
return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK && planOK return matchesQ && semOK && tipoOK && flagOK && carreraOK && facultadOK && planOK
}) })
}, [q, sem, tipo, flag, carrera, facultad, asignaturas]) }, [q, sem, tipo, flag, carrera, facultad, asignaturas])
// Agrupación // Agrupación
@@ -310,19 +316,18 @@ function RouteComponent() {
}, [filtered, groupBy]) }, [filtered, groupBy])
// Helpers // Helpers
const clearFilters = () => { const clearFilters = () => { setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag('') ; setFacultad('todas')
setQ(''); setSem('todos'); setTipo('todos'); setCarrera('todas'); setFlag(''); setFacultad('todas')
// Actualiza la URL limpiando todos los query params // Actualiza la URL limpiando todos los query params
router.navigate({ router.navigate({
to: '/asignaturas', to: '/asignaturas',
search: { search: {
q: '', q: '',
planId: '', planId: '',
carreraId: '', carreraId: '',
facultadId: '', facultadId: '',
f: '' f: ''
}, },
}) })
} }
// NEW: util para clonar 1 asignatura // NEW: util para clonar 1 asignatura
@@ -545,12 +550,7 @@ function RouteComponent() {
value={search.planId ?? "todos"} value={search.planId ?? "todos"}
onValueChange={(val) => { onValueChange={(val) => {
router.navigate({ router.navigate({
to: '/asignaturas', search: { ...search, planId: val === "todos" ? "" : val },
search: {
...search,
planId: val === 'todos' ? '' : val,
},
replace: true,
}) })
}} }}
> >
@@ -828,14 +828,15 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
const horasT = a.horas_teoricas ?? 0 const horasT = a.horas_teoricas ?? 0
const horasP = a.horas_practicas ?? 0 const horasP = a.horas_practicas ?? 0
const meta = tipoMeta(a.tipo) const meta = tipoMeta(a.tipo)
const FacIcon = (Icons as any)[a.plan?.carrera?.facultad?.icon ?? 'Building2'] || Icons.Building2
console.log(a); console.log(a);
return ( return (
<li className="group relative overflow-hidden rounded-2xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm hover:shadow-md transition-all" <li className="group relative overflow-hidden rounded-2xl border bg-white/75 dark:bg-neutral-900/60 shadow-sm hover:shadow-md transition-all"
style={{ style={{
borderColor: a.plan?.carrera?.facultad?.color ?? '#ccc', borderColor: a.plan?.carrera?.facultad?.color ?? '#ccc',
backgroundColor: `${a.plan?.carrera?.facultad?.color}15`, // 15 = transparencia HEX backgroundColor: `${a.plan?.carrera?.facultad?.color}15`, // 15 = transparencia HEX
}} }}
> >
<div className="p-3"> <div className="p-3">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">

View File

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

View File

@@ -17,6 +17,13 @@ import { toast } from "sonner"
/* -------------------- Tipos -------------------- */
const SCOPED_ROLES = ["director_facultad", "secretario_academico", "jefe_carrera"] as const
/* -------------------- Query Keys & Fetcher -------------------- */ /* -------------------- Query Keys & Fetcher -------------------- */
const usersKeys = { const usersKeys = {
@@ -142,6 +149,35 @@ function RouteComponent() {
carrera_id?: string | null carrera_id?: string | null
}>({ email: "", password: "" }) }>({ 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 }) { function RolePill({ role }: { role: Role }) {
const meta = ROLE_META[role] const meta = ROLE_META[role]
@@ -161,6 +197,61 @@ function RouteComponent() {
router.invalidate() 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({ const createUser = useMutation({
mutationFn: async (payload: typeof createForm) => { mutationFn: async (payload: typeof createForm) => {
// Validaciones previas // Validaciones previas
@@ -318,7 +409,7 @@ function RouteComponent() {
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => {}} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex"> <Button variant="outline" size="sm" onClick={() => toggleBan.mutate(u)} 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"} <Icons.BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
</Button> </Button>
<Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}> <Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
@@ -334,7 +425,7 @@ function RouteComponent() {
</div> </div>
</div> </div>
<div className="sm:hidden self-start shrink-0 flex gap-1"> <div className="sm:hidden self-start shrink-0 flex gap-1">
<Button variant="outline" size="icon" onClick={() => {}} aria-label="Ban/Unban"><Icons.BanIcon className="w-4 h-4" /></Button> <Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} 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> <Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Icons.Pencil className="w-4 h-4" /></Button>
</div> </div>
</div> </div>

View File

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

View File

@@ -1,12 +0,0 @@
{
"$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"
}
}
}