12 Commits

Author SHA1 Message Date
14b188d3ca Merge branch 'master' of https://github.lci.ulsa.mx/AlexRG/Acad-IA 2025-10-23 13:02:40 -06:00
3fccdc0478 commit wip 2025-10-22 15:54:42 -06:00
d491100c73 Se añadió el botón de eliminar asignatura, y se borra adecuadamente 2025-10-21 17:05:45 -06:00
ce2cd6b397 Eliminada dependencia de llave de servicio para el manejo de usuarios y eliminado el hard-code de los roles
supabase.tsx
Se añadieron los roles al contexto de autenticación. Se modificó la interfaz de UserClaims que consiste en la información que se obtiene de los usuarios. Se obtienen los roles desde la base de datos.

_authenticated.tsx
Ya todos pueden ver el enlace a la página de facultades.

login.tsx
Se movió el enlace de '¿Olvidaste tu contraseña?' a después del input de la contraseña, para mejorar la usabilidad.

usuarios.tsx
- La obtención de los usuarios ahora se hace a con el cliente de llave anónima de supabase y se obtiene de tablas en el esquema public a través de una función de PostgreSQL.
- La información de los roles se obtiene del contexto de autenticación para mostrarla en la página.
- El RolePill se movió a dentro del componente para poder usar la información del contexto.
- Se añadieron validaciones para poder crear un usuario.
- Se muestra la información para editar los usuarios y se actualiza en la BDD con una función de PostgreSQL.
2025-10-20 17:09:14 -06:00
f2b3010ac9 Ahora se obtienen claims de las tablas en el esquema public, en vez de la información de sesion del usuario, que se obtiene de la tabla auth.users
En supabase.tsx se sustituyó la manera de obtener los claims del usuario, utilizando ahora un rpc de una función en supabase.
2025-10-10 17:23:37 -06:00
c49c0bbc0a Bugfix de botones anidados, facultad y carrera faltantes de la card de plan de estudios, campo opcional marcado como tal
CreatePlanDialog: el botón con el que selecciona al archivo de referencia se cambió a div para evitar posibles problemas.

planes: se limitó el número de caracteres del estado que se pueden mostrar para darle espacio al div de la facultad y la carrera.

usuarios: se añadió un texto small para indicar que el campo de título es opcional. Se puede hacer lo mismo con los demás en un futuro.
2025-10-06 16:34:19 -06:00
101758da24 Se quitó botón de editar prompt, se arregló el bug de no encontrar el plan de estudios por el uuid al estar idle la página, y se arregló el bug de visualización de archivos en el modal de crear plan de estudios
Academic-sections: ya se renderea condicionalmente el botón de editar prompt.

AddAsignaturaButton: se quitaron llamadas redundantes de invalidateQueries.

CreatePlanDialog: ya no se selecciona la columna de s3_file_path porque ya no existe.

$planId: el bug de no encontrar el plan de estudios por el uuid al estar idle la página probablemente era causado por llamar de manera redundante a planByIdOptions(), asignaturasCountOptions() y asignaturasPreviewOptions() en el componente. Ahora desde el loader se obtiene toda la información del plan de estudios y sus asignaturas.
2025-10-06 12:50:38 -06:00
e03d5f5e36 Parte del bugfix/feature para guardar los prompts en la BDD 2025-10-03 16:07:02 -06:00
b3ca317e5e Feature: al borrar un archivo, ya se borra de minio también
Se añadió la peticion DELETE para borrar un archivo de minio
2025-10-03 12:58:29 -06:00
e12d0ad8b1 Parte de la actualización de obtención de documentos a partir del documentos_id
Ahora se usa documentos_id para obtener los documentos de referencia de minio, por lo que se cambiaron los nombres de las variables utilizadas
2025-10-03 12:18:25 -06:00
4be34e8d6a bug de no cargar visualización de archivos de referencia con filenames con caracteres no permitidos 2025-10-02 11:35:02 -06:00
da4cf5a5e0 se envía uuid de usuario
Se envia el uuid del usuario para para ponerle dueño a las asignaturas y planes de estudio creadas, y a los documentos de referencia subidos
2025-10-01 19:20:24 -06:00
17 changed files with 521 additions and 296 deletions

22
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,22 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
},
{
"type": "msedge",
"request": "launch",
"name": "Launch Edge against localhost",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
}
]
}

View File

@@ -10,26 +10,29 @@ export interface SupabaseAuthState {
isAuthenticated: boolean
user: User | null
claims: UserClaims | null
roles: RolCatalogo[] | null
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
isLoading: boolean
}
type Role =
| 'lci'
| 'vicerrectoria'
| 'director_facultad' // 👈 NEW
| 'secretario_academico'
| 'jefe_carrera'
| 'planeacion'
export interface RolCatalogo {
id: string
nombre: string
icono: string
nombre_clase: string
label: string
}
type UserClaims = {
claims_admin: boolean
clave: string
export type Role = string;
export type UserClaims = {
id: string | null
clave?: string
nombre: string
apellidos: string
title: string
avatar: string | null
title?: string
avatar?: string | null
carrera_id?: string | null
facultad_id?: string | null
facultad_color?: string | null // 🎨 NEW
@@ -41,28 +44,35 @@ const SupabaseAuthContext = createContext<SupabaseAuthState | undefined>(undefin
export function SupabaseAuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [claims, setClaims] = useState<UserClaims | null>(null)
const [roles, setRoles] = useState<RolCatalogo[] | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// Función para manejar la sesión
const handleSession = async (session: Session | null) => {
const u = session?.user ?? null
setUser(u)
setIsAuthenticated(!!u)
setClaims(await buildClaims(session))
setIsLoading(false)
}
// Carga inicial
supabase.auth.getSession().then(async ({ data: { session } }) => {
const u = session?.user ?? null
setUser(u)
setIsAuthenticated(!!u)
setClaims(await buildClaims(session))
setIsLoading(false)
supabase.auth.getSession().then(({ data: { session } }) => {
handleSession(session)
})
// Carga roles catálogo
fetchRoles().then(fetchedRoles => {
setRoles(fetchedRoles);
});
// Suscripción a cambios de sesión
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => {
const u = session?.user ?? null
setUser(u)
setIsAuthenticated(!!u)
setClaims(await buildClaims(session))
setIsLoading(false)
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
handleSession(session)
})
return () => subscription.unsubscribe()
}, [])
@@ -80,7 +90,7 @@ export function SupabaseAuthProvider({ children }: { children: React.ReactNode }
return (
<SupabaseAuthContext.Provider
value={{ isAuthenticated, user, claims, login, logout, isLoading }}
value={{ isAuthenticated, user, claims, roles, login, logout, isLoading }}
>
{children}
</SupabaseAuthContext.Provider>
@@ -99,49 +109,54 @@ export function useSupabaseAuth() {
* Helpers
* ===================== */
// Unifica extracción de metadatos y resuelve facultad_color si hay facultad_id
// Obtiene los claims del usuario desde la base de datos a partir de una función en la BDD
async function buildClaims(session: Session | null): Promise<UserClaims | null> {
const u = session?.user
if (!u) return null
const app = (u.app_metadata ?? {}) as Partial<UserClaims> & { role?: Role }
const meta = (u.user_metadata ?? {}) as Partial<UserClaims>
// Mezcla segura: app_metadata > user_metadata (para campos de claims)
const base: Partial<UserClaims> = {
claims_admin: !!(app.claims_admin ?? (meta as any).claims_admin),
role: (app.role as Role | undefined) ?? ('lci' as Role),
facultad_id: app.facultad_id ?? meta.facultad_id ?? null,
carrera_id: app.carrera_id ?? meta.carrera_id ?? null,
clave: (meta.clave as string) ?? '',
nombre: (meta.nombre as string) ?? '',
apellidos: (meta.apellidos as string) ?? '',
title: (meta.title as string) ?? '',
avatar: (meta.avatar as string) ?? null,
// Validar sesión
if (!session || !session.user) {
console.warn('No session or user found');
return null;
}
const u = session.user;
let facultad_color: string | null = null
if (base.facultad_id) {
// Lee color desde public.facultades
const { data, error } = await supabase
.from('facultades')
.select('color')
.eq('id', base.facultad_id)
.maybeSingle()
if (!error && data) facultad_color = (data as any)?.color ?? null
}
try{
const result = await supabase.rpc('obtener_claims_usuario', {
p_user_id: u.id,
});
const data: UserClaims[] | null = result.data;
const error = result.error;
return {
claims_admin: !!base.claims_admin,
role: (base.role ?? 'lci') as Role,
clave: base.clave ?? '',
nombre: base.nombre ?? '',
apellidos: base.apellidos ?? '',
title: base.title ?? '',
avatar: base.avatar ?? null,
facultad_id: (base.facultad_id as string | null) ?? null,
carrera_id: (base.carrera_id as string | null) ?? null,
facultad_color, // 🎨
if (error) {
console.error('Error al obtener la información:', error);
throw new Error('Error al obtener la información del usuario');
}
console.log(data);
if (!data || data.length === 0) {
console.warn('No se encontró información para el usuario');
return null;
}
return {
...data[0],
id: null
};
} catch (e) {
console.error('Error inesperado:', e);
return null;
}
}
async function fetchRoles(): Promise<RolCatalogo[]> {
const { data, error } = await supabase
.from("roles_catalogo")
.select("id, nombre, icono, nombre_clase, label");
if (error) {
console.error("Error al obtener los roles:", error.message);
return [];
}
return data || [];
}

View File

@@ -7,7 +7,7 @@ import * as Icons from "lucide-react"
import type { RefRow } from "@/types/RefRow"
// POST -> recibe blob PDF y (opcional) Content-Disposition
async function fetchPdfBlob(url: string, body: { s3_file_path: string }) {
async function fetchPdfBlob(url: string, body: { documentos_id: string }) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -35,6 +35,7 @@ export function DetailDialog({
onClose: () => void
pdfUrl?: string
}) {
console.log("DetailDialog render", { row })
const [viewerUrl, setViewerUrl] = useState<string | null>(null)
const [currentBlob, setCurrentBlob] = useState<Blob | null>(null)
const [filename, setFilename] = useState<string>("archivo.pdf")
@@ -48,13 +49,15 @@ export function DetailDialog({
const ctrl = new AbortController()
async function load() {
if (!row?.s3_file_path) {
console.log(row)
if (!row?.documentos_id) {
setViewerUrl(null)
setCurrentBlob(null)
console.warn("No hay documentos_id en el row")
return
}
try {
const { blob, disposition } = await fetchPdfBlob(`${import.meta.env.VITE_BACK_ORIGIN}/api/get/documento`, { s3_file_path: row.s3_file_path })
const { blob, disposition } = await fetchPdfBlob(`${import.meta.env.VITE_BACK_ORIGIN}/api/get/documento`, { documentos_id: row.documentos_id })
if (ctrl.signal.aborted) return
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
setFilename(name)
@@ -94,8 +97,8 @@ export function DetailDialog({
}
// Si no, vuelve a pedirlo (p. ej., si el user abre y descarga sin render previo)
if (!row?.s3_file_path) throw new Error("No hay contenido para descargar.")
const { blob, disposition } = await fetchPdfBlob(`${import.meta.env.VITE_BACK_ORIGIN}/api/get/documento`, { s3_file_path: row.s3_file_path })
if (!row?.documentos_id) throw new Error("No hay contenido para descargar.")
const { blob, disposition } = await fetchPdfBlob(`${import.meta.env.VITE_BACK_ORIGIN}/api/get/documento`, { documentos_id: row.documentos_id })
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
const link = document.createElement("a")
const href = URL.createObjectURL(blob)
@@ -111,7 +114,7 @@ export function DetailDialog({
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl">
<DialogContent className="max-w-fit">
<DialogHeader>
<DialogTitle className="font-mono">{row?.titulo_archivo ?? "(Sin título)"}</DialogTitle>
<DialogDescription>{row?.descripcion || "Sin descripción"}</DialogDescription>
@@ -131,13 +134,13 @@ export function DetailDialog({
</span>
)}
</div>
{/* Tags
{row.tags?.length ? (
<div className="text-xs text-neutral-600">
<span className="font-medium">Tags: </span>
{row.tags.join(", ")}
</div>
) : null}
) : null} */}
<div>
<Label className="text-xs text-neutral-600">Instrucciones</Label>

View File

@@ -7,13 +7,14 @@ 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 } from "@/auth/supabase"
import { supabase, useSupabaseAuth } from "@/auth/supabase"
import { Field } from "./Field"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
import { asignaturaKeys } from "./planQueries"
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
const qc = useQueryClient()
const supabaseAuth = useSupabaseAuth()
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [mode, setMode] = useState<"manual" | "ia">("manual")
@@ -42,32 +43,32 @@ export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdd
horas_teoricas: toNum(f.horas_teoricas),
horas_practicas: toNum(f.horas_practicas),
objetivos: toNull(f.objetivos),
contenidos: {}, bibliografia: [], criterios_evaluacion: null,
contenidos: [], bibliografia: [], criterios_evaluacion: null,
}
const { error } = await supabase.from("asignaturas").insert([payload])
setSaving(false)
if (error) { alert(error.message); return }
setOpen(false)
onAdded?.()
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
}
async function createWithAI() {
if (!canIA) return
setSaving(true)
// inserte la asignatura generada directamente
// obtengas el uuid que se insertó
try {
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/generar/asignatura`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true }),
body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true, uuid: supabaseAuth.user?.id }),
})
if (!res.ok) throw new Error(await res.text())
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
setOpen(false)
onAdded?.()
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
// qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
// qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
} catch (e: any) {
alert(e?.message ?? "Error al generar la asignatura")
} finally { setSaving(false) }

View File

@@ -89,8 +89,8 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
async function fetchDbFiles() {
try {
const { data, error } = await supabase
.from("fine_tuning_referencias")
.select("fine_tuning_referencias_id, titulo_archivo, s3_file_path, fecha_subida, tags")
.from("documentos")
.select("documentos_id, titulo_archivo, fecha_subida, tags")
.ilike("titulo_archivo", `%${debouncedSearchTerm}%`)
.range((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage - 1);
@@ -100,7 +100,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
}
setDbFiles((data || []).map((file: any) => ({
id: file.fine_tuning_referencias_id,
id: file.documentos_id,
titulo: file.titulo_archivo,
s3_file_path: file.s3_file_path,
fecha_subida: file.fecha_subida,
@@ -162,9 +162,10 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
try {
const res = await postAPI("/api/generar/plan", {
carreraId,
prompt,
prompt: prompt,
insert: true,
files: selectedFiles,
uuid: auth.user?.id,
})
const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id
if (newId) {
@@ -260,8 +261,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
const ext = fileExt(file.titulo);
const selected = isSelected(file.s3_file_path);
return (
<button
type="button"
<div
key={file.id}
role="gridcell"
aria-selected={selected}
@@ -269,7 +269,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
onDoubleClick={(e) => {
e.stopPropagation();
setPreviewRow({
fine_tuning_referencias_id: file.id,
documentos_id: file.id,
created_by: "unknown",
s3_file_path: file.s3_file_path,
titulo_archivo: file.titulo,
@@ -312,6 +312,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
) : (
<p className="text-xs text-neutral-500">Fecha desconocida</p>
)}
{file.tags && file.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{file.tags.map((tag, i) => (
@@ -330,7 +331,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
onClick={(e) => {
e.stopPropagation();
setPreviewRow({
fine_tuning_referencias_id: file.id,
documentos_id: file.id,
created_by: "unknown",
s3_file_path: file.s3_file_path,
titulo_archivo: file.titulo,
@@ -352,7 +353,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
<span className="truncate">{ext.toUpperCase()}</span>
{selected ? <span className="font-medium">Seleccionado</span> : <span className="opacity-60">Click para seleccionar</span>}
</div>
</button>
</div>
)
})}

View File

@@ -28,10 +28,10 @@ export function DeletePlanButton({ planId, onDeleted }: { planId: string; onDele
return confirm ? (
<div className="flex gap-2">
<Button variant="outline" onClick={() => setConfirm(false)} disabled={loading}>Cancelar</Button>
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
{loading ? "Eliminando…" : "Confirmar eliminación"}
</Button>
<Button variant="outline" onClick={() => setConfirm(false)} disabled={loading}>Cancelar</Button>
</div>
) : (
<Button variant="outline" onClick={() => setConfirm(true)}>

View File

@@ -0,0 +1,33 @@
import { Download } from "lucide-react";
import { Button } from "../ui/button";
export function DescargarPdfButton({planId, opcion}: {planId: string, opcion: "plan" | "asignaturas"}) {
return (
<Button variant="outline" className="flex items-center gap-2 " onClick={() => descargarPdf(planId, opcion)}>
Descargar {opcion === "plan" ? "Plan" : "Asignaturas"} PDF
<Download className="w-4 h-4" />
</Button>
)
}
function descargarPdf(planId: string, opcion: "plan" | "asignaturas") {
// Lógica para generar y descargar el PDF del plan de estudios
try {
// Usa la variable de entorno para construir la URL completa
const pdfUrl = opcion === "plan"
? `${import.meta.env.VITE_BACK_ORIGIN}/api/planes/${planId}/descargar-pdf-plan`
: `${import.meta.env.VITE_BACK_ORIGIN}/api/planes/${planId}/descargar-pdf-asignaturas`;
const link = document.createElement('a');
link.href = pdfUrl;
link.download = opcion === "plan"
? `plan_estudios_${planId}.pdf`
: `asignaturas_plan_estudios_${planId}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error("Error al descargar el PDF:", error);
alert("Hubo un error al descargar el PDF. Por favor, inténtalo de nuevo.");
}
}

View File

@@ -109,6 +109,7 @@ function SectionPanel({ title, icon: Icon, color, children, id }: { title: strin
===================================================== */
export function AcademicSections({ planId, color }: { planId: string; color?: string | null }) {
const qc = useQueryClient()
if(!planId) return <div>Cargando</div>
const { data: plan } = useSuspenseQuery(planTextOptions(planId))
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
@@ -175,17 +176,18 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
>
Copiar
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
setEditing({ key: s.key, title: s.title })
setDraft(current)
}}
>
Editar
</Button>
{s.key !== "prompt" &&
(<Button
variant="ghost"
size="sm"
onClick={() => {
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
setEditing({ key: s.key, title: s.title })
setDraft(current)
}}
>
Editar
</Button>)}
</div>
</SectionPanel>
)

View File

@@ -73,7 +73,6 @@ function useUserDisplay() {
avatar: claims?.avatar ?? null,
initials: getInitials([nombre, apellidos].filter(Boolean).join(" ")),
role,
isAdmin: Boolean(claims?.claims_admin),
}
}
@@ -150,7 +149,7 @@ function Layout() {
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const { claims } = useSupabaseAuth()
const isAdmin = Boolean(claims?.claims_admin)
const isAdmin = claims?.role === 'lci' || claims?.role === 'vicerrectoria'
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')
@@ -189,18 +188,18 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
</Link>
)}
{isAdmin && (
<Link
to="/facultades"
key='facultades'
activeOptions={{ exact: true }}
activeProps={{ className: "bg-primary/10 text-foreground" }}
className="group inline-flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-muted-foreground hover:bg-primary/10 hover:text-foreground"
>
<KeySquare className="h-4 w-4" />
<span className="truncate">Facultades</span>
</Link>
)}
<Link
to="/facultades"
key='facultades'
activeOptions={{ exact: true }}
activeProps={{ className: "bg-primary/10 text-foreground" }}
className="group inline-flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-muted-foreground hover:bg-primary/10 hover:text-foreground"
>
<KeySquare className="h-4 w-4" />
<span className="truncate">Facultades</span>
</Link>
</nav>
</ScrollArea>
<Separator className="mt-auto" />

View File

@@ -1,7 +1,7 @@
// routes/_authenticated/archivos.tsx
import { createFileRoute, useRouter } from "@tanstack/react-router"
import { useMemo, useState } from "react"
import { supabase } from "@/auth/supabase"
import { use, useMemo, useState } from "react"
import { supabase, useSupabaseAuth } from "@/auth/supabase"
import * as Icons from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
@@ -16,12 +16,13 @@ import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@
import { DetailDialog } from "@/components/archivos/DetailDialog"
import type { RefRow } from "@/types/RefRow"
import { uuid } from "zod"
export const Route = createFileRoute("/_authenticated/archivos")({
component: RouteComponent,
loader: async () => {
const { data, error } = await supabase
.from("fine_tuning_referencias")
.from("documentos")
.select("*")
.order("fecha_subida", { ascending: false })
.limit(200)
@@ -67,10 +68,25 @@ function RouteComponent() {
async function remove(id: string) {
if (!confirm("¿Eliminar archivo de referencia?")) return
const { error } = await supabase
.from("fine_tuning_referencias")
.from("documentos")
.delete()
.eq("fine_tuning_referencias_id", id)
.eq("documentos_id", id)
if (error) return alert(error.message)
try {
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/eliminar/documento`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentos_id: id }),
})
if (!res.ok) {
throw new Error("Se falló al eliminar el documento")
}
} catch (err) {
console.error("Error al eliminar el documento:", err)
}
router.invalidate()
}
@@ -123,7 +139,7 @@ function RouteComponent() {
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map((r) => (
<article
key={r.fine_tuning_referencias_id}
key={r.documentos_id}
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
>
<header className="min-w-0">
@@ -151,6 +167,7 @@ function RouteComponent() {
<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) => (
@@ -159,13 +176,13 @@ function RouteComponent() {
</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.fine_tuning_referencias_id)}>
<Button variant="ghost" size="sm" onClick={() => remove(r.documentos_id)}>
<Icons.Trash2 className="w-4 h-4 mr-1" /> Eliminar
</Button>
</div>
@@ -192,6 +209,7 @@ function RouteComponent() {
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("")
@@ -222,6 +240,7 @@ function UploadDialog({
prompt: instrucciones,
fileBase64,
insert: true,
uuid: supabaseAuth.user?.id ?? null,
}),
})
if (!res.ok) {
@@ -234,21 +253,21 @@ function UploadDialog({
try {
const payload = await res.json()
createdId =
payload?.fine_tuning_referencias_id ||
payload?.documentos_id ||
payload?.id ||
payload?.data?.fine_tuning_referencias_id ||
payload?.data?.documentos_id ||
null
} catch { /* noop */ }
if (createdId && (tags.trim() || fuente.trim() || typeof interno === "boolean")) {
await supabase
.from("fine_tuning_referencias")
.from("documentos")
.update({
tags: tags.trim() ? tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
fuente_autoridad: fuente.trim() || undefined,
interno,
})
.eq("fine_tuning_referencias_id", createdId)
.eq("documentos_id", createdId)
}
onOpenChange(false)

View File

@@ -1,4 +1,5 @@
// routes/_authenticated/asignatura/$asignaturaId.tsx
import { useQueryClient } from "@tanstack/react-query";
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
import * as Icons from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
@@ -134,7 +135,7 @@ function Page() {
{/* ===== Hero ===== */}
<div className="relative overflow-hidden rounded-3xl border shadow-sm">
<div className={`absolute inset-0 bg-gradient-to-br ${style.halo} via-white to-transparent`} />
<div className="relative p-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="relative p-6 flex flex-col grid grid-cols-1 gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="inline-flex items-center gap-2 text-xs text-neutral-600">
<Icons.BookOpen className="h-4 w-4" /> Asignatura
@@ -165,6 +166,7 @@ function Page() {
</Button>
<EditAsignaturaButton asignatura={a} onUpdate={setA} />
<MejorarAIButton asignaturaId={a.id} onApply={(nuevo) => setA(nuevo)} />
<BorrarAsignaturaButton asignatura_id={a.id} />
</div>
</div>
@@ -191,7 +193,7 @@ function Page() {
)}
{/* Syllabus */}
{unidades.length > 0 && (
<Section id="syllabus" title="Programa / Contenidos" icon={Icons.ListTree}>
<div className="flex items-center gap-2 mb-2">
<div className="relative flex-1">
@@ -285,7 +287,7 @@ function Page() {
)
})()}
</Section>
)}
{/* Bibliografía */}
@@ -578,6 +580,51 @@ function MejorarAIButton({ asignaturaId, onApply }: {
)
}
function BorrarAsignaturaButton({ asignatura_id, onDeleted }: { asignatura_id: string; onDeleted?: () => void }) {
const [confirm, setConfirm] = useState(false)
const [loading, setLoading] = useState(false)
const router = useRouter()
const queryClient = useQueryClient()
async function handleDelete() {
setLoading(true)
try {
const { error, status, statusText } = await supabase.from("asignaturas").delete().eq("id", asignatura_id)
console.log({ status, statusText });
if (error) throw error
setConfirm(false)
queryClient.invalidateQueries({ queryKey: ["asignaturas"] })
if (onDeleted) onDeleted()
router.navigate({ to: "/asignaturas", search: {
q: "", // Término de búsqueda vacío
planId: "", // ID del plan (vacío si no aplica)
carreraId: "", // ID de la carrera (vacío si no aplica)
facultadId: "", // ID de la facultad (vacío si no aplica)
f: "", // Filtro vacío
}})
} catch (e: any) {
alert(e?.message || "Error al eliminar la asignatura")
} finally {
setLoading(false)
}
}
return confirm ? (
<div className="flex gap-2">
<Button variant="outline" onClick={() => setConfirm(false)} disabled={loading}>Cancelar</Button>
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
{loading ? "Eliminando…" : "Confirmar eliminación"}
</Button>
</div>
) : (
<Button variant="outline" onClick={() => setConfirm(true)}>
Eliminar asignatura
</Button>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
@@ -632,9 +679,9 @@ export function EditContenidosButton({
}
return { title, temas }
})
return entries.length ? entries : [{ title: "Unidad 1", temas: [] }]
return entries.length ? entries : [{ title: "", temas: [] }]
} catch {
return [{ title: "Unidad 1", temas: [] }]
return [{ title: "", temas: [] }]
}
}, [])
@@ -650,7 +697,7 @@ export function EditContenidosButton({
.forEach((t, i) => {
sub[String(i + 1)] = t
})
out[k] = { titulo: (u.title || "").trim() || `Unidad ${k}`, subtemas: sub }
out[k] = { titulo: (u.title || "").trim(), subtemas: sub }
})
return out
}, [])
@@ -669,7 +716,7 @@ export function EditContenidosButton({
return true
})
return {
title: (u.title || "").trim() || `Unidad ${idx + 1}`,
title: (u.title || "").trim(),
temas,
}
})
@@ -839,7 +886,7 @@ export function EditContenidosButton({
<Button
variant="secondary"
onClick={() =>
setUnits((prev) => [...prev, { title: `Unidad ${prev.length + 1}`, temas: [] }])
setUnits((prev) => [...prev, { title: "", temas: [] }])
}
>
<Icons.Plus className="w-4 h-4 mr-2" /> Agregar unidad
@@ -850,7 +897,7 @@ export function EditContenidosButton({
<DialogFooter className="px-6 pb-5">
<Button variant="outline" onClick={cancel}>Cancelar</Button>
<Button onClick={save} disabled={saving}>
<Button onClick={save} disabled={saving || !hasChanges || units.some(u => !u.title.trim())}>
{saving ? (
<span className="inline-flex items-center gap-2">
<Icons.Loader2 className="h-4 w-4 animate-spin" /> Guardando

View File

@@ -175,7 +175,7 @@ function RouteComponent() {
const name = auth.user?.user_metadata?.nombre || auth.user?.email?.split('@')[0] || '¡Hola!'
const isAdmin = !!auth.claims?.claims_admin
const isAdmin = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
const role = auth.claims?.role as 'lci' | 'vicerrectoria' | 'secretario_academico' | 'jefe_carrera' | 'planeacion' | undefined
const navigate = useNavigate({ from: Route.fullPath })

View File

@@ -19,8 +19,9 @@ import { Textarea } from "@/components/ui/textarea"
import { AuroraButton } from "@/components/effect/aurora-button"
import { DeletePlanButton } from "@/components/planes/DeletePlan"
import { AddAsignaturaButton } from "@/components/planes/AddAsignaturaButton"
import { DescargarPdfButton } from "@/components/planes/GenerarPdfButton"
type LoaderData = { planId: string }
type LoaderData = { plan: PlanFull; asignaturas: AsignaturaLite[] }
export const Route = createFileRoute("/_authenticated/plan/$planId")({
component: RouteComponent,
@@ -33,24 +34,24 @@ export const Route = createFileRoute("/_authenticated/plan/$planId")({
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
const { planId } = params
await Promise.all([
if (!planId) throw new Error("planId is required")
console.log("Cargando planId", planId)
const [plan, asignaturas] = await Promise.all([
queryClient.ensureQueryData(planByIdOptions(planId)),
queryClient.ensureQueryData(asignaturasCountOptions(planId)),
// queryClient.ensureQueryData(asignaturasCountOptions(planId)),
queryClient.ensureQueryData(asignaturasPreviewOptions(planId)),
])
return { planId }
return { plan, asignaturas }
},
})
// ...existing code...
function RouteComponent() {
const qc = useQueryClient()
const { planId } = Route.useLoaderData() as LoaderData
const { plan, asignaturas: asignaturasPreview } = Route.useLoaderData() as LoaderData
const auth = useSupabaseAuth()
const { data: plan } = useSuspenseQuery(planByIdOptions(planId))
const { data: asignaturasCount } = useSuspenseQuery(asignaturasCountOptions(planId))
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(planId))
const asignaturasCount = asignaturasPreview.length
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
const showCarrera = auth.claims?.role === 'secretario_academico'
@@ -78,7 +79,7 @@ function RouteComponent() {
</nav>
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
<div className="absolute inset-0 -z-0" style={accent} />
<CardHeader className="relative z-10 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardHeader className="relative z-10 flex flex-col grid grid-cols-1 gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 min-w-0">
<span className="hdr-icon inline-flex items-center justify-center rounded-2xl border px-3 py-2 bg-white/70"
style={{ borderColor: accent.borderColor as string }}>
@@ -98,11 +99,13 @@ function RouteComponent() {
{plan.estado}
</Badge>
)}
<div className='flex gap-2'>
{/* <div className='flex gap-2'> */}
<EditPlanButton plan={plan} />
<AdjustAIButton plan={plan} />
<DescargarPdfButton planId={plan.id} opcion="plan" />
<DescargarPdfButton planId={plan.id} opcion="asignaturas" />
<DeletePlanButton planId={plan.id} />
</div>
{/* </div> */}
</div>
</CardHeader>
<CardContent ref={statsRef}>

View File

@@ -45,6 +45,7 @@ export const Route = createFileRoute("/_authenticated/planes")({
`)
.order("fecha_creacion", { ascending: false })
.limit(100)
console.log({ data, error })
if (error) throw new Error(error.message)
return (data ?? []) as PlanRow[]
},
@@ -154,7 +155,7 @@ function RouteComponent() {
className="bg-white/60"
style={{ borderColor: (chipTint(fac?.color).borderColor as string) }}
>
{p.estado}
{p.estado && p.estado.length > 10 ? `${p.estado.slice(0, 10)}` : p.estado}
</Badge>
)}
</div>

View File

@@ -3,6 +3,7 @@ import { createFileRoute, useRouter } from "@tanstack/react-router"
import { useMemo, useState } from "react"
import { useSuspenseQuery, queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"
import { supabase, useSupabaseAuth } from "@/auth/supabase"
import type { Role, UserClaims } from "@/auth/supabase"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
@@ -10,58 +11,19 @@ import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
import {
RefreshCcw, ShieldCheck, ShieldAlert, Pencil, Mail,
Cpu, Building2, ScrollText, GraduationCap, GanttChart, Plus, Eye, EyeOff,
Ban as BanIcon, Check
} from "lucide-react"
import { SupabaseClient } from "@supabase/supabase-js"
import * as Icons from "lucide-react"
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
import { toast } from "sonner"
/* -------------------- Tipos -------------------- */
type AdminUser = {
id: string
email: string | null
created_at: string
last_sign_in_at: string | null
user_metadata: any
app_metadata: any
banned_until?: string | null
}
const SCOPED_ROLES = ["director_facultad", "secretario_academico", "jefe_carrera"] as const
const ROLES = [
"lci",
"vicerrectoria",
"director_facultad",
"secretario_academico",
"jefe_carrera",
"planeacion",
] as const
export type Role = typeof ROLES[number]
const ROLE_META: Record<Role, { label: string; Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; className: string }> = {
lci: { label: "Laboratorio de Cómputo de Ingeniería", Icon: Cpu, className: "bg-neutral-900 text-white" },
vicerrectoria: { label: "Vicerrectoría Académica", Icon: Building2, className: "bg-indigo-600 text-white" },
director_facultad: { label: "Director(a) de Facultad", Icon: Building2, className: "bg-purple-600 text-white" },
secretario_academico: { label: "Secretario Académico", Icon: ScrollText, className: "bg-emerald-600 text-white" },
jefe_carrera: { label: "Jefe de Carrera", Icon: GraduationCap, className: "bg-orange-600 text-white" },
planeacion: { label: "Planeación Curricular", Icon: GanttChart, className: "bg-sky-600 text-white" },
}
function RolePill({ role }: { role: Role }) {
const meta = ROLE_META[role]
if (!meta) return null
const { Icon, className, label } = meta
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:text-[11px] ${className} max-w-[160px] sm:max-w-none truncate`} title={label}>
<Icon className="h-3 w-3 shrink-0" />
<span className="truncate">{label}</span>
</span>
)
}
/* -------------------- Query Keys & Fetcher -------------------- */
const usersKeys = {
@@ -69,13 +31,58 @@ const usersKeys = {
list: () => [...usersKeys.root, "list"] as const,
}
async function fetchUsers(): Promise<AdminUser[]> {
// ⚠️ Dev only: service role en cliente
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
const { data } = await admin.auth.admin.listUsers()
return (data?.users ?? []) as AdminUser[]
async function fetchUsers(): Promise<UserClaims[]> {
try {
const { data: perfiles, error } = await supabase.from("perfiles").select("id");
if (error) {
console.error("Error al obtener usuarios:", error.message);
return []; // Devuelve un arreglo vacío en caso de error
}
if (!perfiles || perfiles.length === 0) {
console.log("No se encontraron perfiles.");
return []; // Devuelve un arreglo vacío si no hay datos
}
// Llama a `obtener_claims_usuario` para cada perfil
const usuarios = await Promise.all(
perfiles.map(async (perfil) => {
const { data: claims, error: rpcError } = await supabase.rpc("obtener_claims_usuario", {
p_user_id: perfil.id, // Pasa el ID del perfil como parámetro
});
console.log("Claims para perfil", perfil.id, claims[0]);
if (rpcError) {
console.error(`Error al obtener claims para el perfil ${perfil.id}:`, rpcError.message);
return null; // Devuelve null si hay un error
}
return {
id: perfil.id,
role: claims[0]?.role,
title: claims[0]?.title,
facultad_id: claims[0]?.facultad_id,
carrera_id: claims[0]?.carrera_id,
facultad_color: claims[0]?.facultad_color,
clave: claims[0]?.clave,
nombre: claims[0]?.nombre,
apellidos: claims[0]?.apellidos,
avatar: claims[0]?.avatar,
};
})
);
// Filtra los resultados nulos (errores en las llamadas RPC)
return usuarios.filter((u) => u !== null) as UserClaims[];
} catch (err) {
console.error("Error inesperado:", err);
return []; // Devuelve un arreglo vacío en caso de error inesperado
}
}
const usersOptions = () =>
queryOptions({ queryKey: usersKeys.list(), queryFn: fetchUsers, staleTime: 60_000 })
@@ -91,12 +98,37 @@ export const Route = createFileRoute("/_authenticated/usuarios")({
/* -------------------- Página -------------------- */
function RouteComponent() {
const auth = useSupabaseAuth()
if (auth.claims?.role !== "lci" && auth.claims?.role !== "vicerrectoria") {
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
}
const { ROLES, ROLE_META } = useMemo(() => {
if (!auth.roles) return { ROLES: [], ROLE_META: {} };
// Construir ROLES como un arreglo de strings
const rolesArray = auth.roles.map((role) => role.nombre);
// Construir ROLE_META como un objeto basado en ROLES
const rolesMeta = auth.roles.reduce((acc, role) => {
acc[role.nombre] = {
id: role.id,
label: role.label,
Icon: (Icons as any)[role.icono] || Icons.Cpu, // Icono por defecto si no está definido
className: /* role.nombre_clase || */ "bg-gray-500 text-white", // Clase por defecto si no está definida
};
return acc;
}, {} as Record<string, { id: string; label: string; Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; className: string }>);
return { ROLES: rolesArray, ROLE_META: rolesMeta };
}, [auth.roles]);
const router = useRouter()
const qc = useQueryClient()
const { data } = useSuspenseQuery(usersOptions())
const [q, setQ] = useState("")
const [editing, setEditing] = useState<AdminUser | null>(null)
const [editing, setEditing] = useState<UserClaims | null>(null)
const [form, setForm] = useState<{
role?: Role
claims_admin?: boolean
@@ -118,10 +150,47 @@ function RouteComponent() {
}>({ 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]
if (!meta) return null
const { Icon, className, label } = meta
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] sm:text-[11px] ${className} max-w-[160px] sm:max-w-none truncate`} title={label}>
<Icon className="h-3 w-3 shrink-0" />
<span className="truncate">{label}</span>
</span>
)
}
/* ---------- Mutations ---------- */
const invalidateAll = async () => {
await qc.invalidateQueries({ queryKey: usersKeys.root })
@@ -167,11 +236,13 @@ function RouteComponent() {
})
const toggleBan = useMutation({
mutationFn: async (u: AdminUser) => {
const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
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)
// const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
// if (error) throw new Error(error.message)
return !banned
},
onSuccess: async (isBanned) => {
@@ -183,39 +254,42 @@ function RouteComponent() {
const createUser = useMutation({
mutationFn: async (payload: typeof createForm) => {
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
const password = payload.password?.trim() || genPassword()
const { error, data } = await admin.auth.admin.createUser({
// Validaciones previas
if (!payload.role) {
throw new Error("Selecciona un rol para el usuario.");
}
if ((payload.role === "secretario_academico" || payload.role === "director_facultad") && !payload.facultad_id) {
throw new Error("Selecciona una facultad para este rol.");
}
if (payload.role === "jefe_carrera" && (!payload.facultad_id || !payload.carrera_id)) {
throw new Error("Selecciona una facultad y una carrera para este rol.");
}
const password = payload.password?.trim()
const { data, error } = await supabase.auth.signUp({
email: payload.email.trim(),
password,
email_confirm: true,
user_metadata: {
nombre: payload.nombre ?? "",
apellidos: payload.apellidos ?? "",
title: payload.title ?? "",
clave: payload.clave ?? "",
avatar: payload.avatar ?? "",
},
app_metadata: {
role: payload.role,
claims_admin: !!payload.claims_admin,
facultad_id: payload.facultad_id ?? null,
carrera_id: payload.carrera_id ?? null,
},
})
if (error) throw new Error(error.message)
const uid = data.user?.id
if (uid && payload.role && (SCOPED_ROLES as readonly string[]).includes(payload.role)) {
if (payload.role === "director_facultad") {
if (!payload.facultad_id) throw new Error("Selecciona facultad")
await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "director_facultad", facultad_id: payload.facultad_id })
} else if (payload.role === "secretario_academico") {
if (!payload.facultad_id) throw new Error("Selecciona facultad")
await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "secretario_academico", facultad_id: payload.facultad_id })
} else if (payload.role === "jefe_carrera") {
if (!payload.facultad_id || !payload.carrera_id) throw new Error("Selecciona facultad y carrera")
await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "jefe_carrera", facultad_id: payload.facultad_id, carrera_id: payload.carrera_id })
options: {
data: {
nombre: payload.nombre ?? "",
apellidos: payload.apellidos ?? "",
title: payload.title ?? "",
clave: payload.clave ?? "",
avatar: payload.avatar ?? "",
role: payload.role,
role_id: payload.role ? ROLE_META[payload.role]?.id : null,
facultad_id: payload.facultad_id ?? null,
carrera_id: payload.carrera_id ?? null,
}
}
});
if (error) throw new Error(error.message)
const uid = data.user?.id
if(!uid) {
throw new Error("No se pudo obtener el ID del usuario creado.");
}
},
onSuccess: async () => {
@@ -228,19 +302,23 @@ function RouteComponent() {
})
const saveUser = useMutation({
mutationFn: async ({ u, f }: { u: AdminUser; f: typeof form }) => {
// 1) Actualiza metadatos (tu Edge Function; placeholder aquí)
// await fetch('/functions/update-user', { method: 'POST', body: JSON.stringify({ id: u.id, ...f }) })
// Simula éxito:
// 2) Nombramiento si aplica
if (f.role && (SCOPED_ROLES as readonly string[]).includes(f.role)) {
if (f.role === "director_facultad") {
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "director_facultad", facultad_id: f.facultad_id! })
} else if (f.role === "secretario_academico") {
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "secretario_academico", facultad_id: f.facultad_id! })
} else if (f.role === "jefe_carrera") {
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "jefe_carrera", facultad_id: f.facultad_id!, carrera_id: f.carrera_id! })
mutationFn: async ({ u, f }: { u: UserClaims; f: typeof form }) => {
const { error } = await supabase.rpc('actualizar_perfil_y_rol', {
datos: {
user_id: u.id,
rol_nombre: f.role,
titulo: f.title,
facultad_id: f.facultad_id,
carrera_id: f.carrera_id,
nombre: f.nombre,
apellidos: f.apellidos,
avatar: f.avatar,
}
});
if (error) {
throw new Error(error.message);
}
},
onSuccess: async () => {
@@ -251,34 +329,29 @@ function RouteComponent() {
onError: (e: any) => toast.error(e?.message || "No se pudo guardar"),
})
if (!auth.claims?.claims_admin) {
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
}
const filtered = useMemo(() => {
const t = q.trim().toLowerCase()
if (!t) return data
return data.filter((u) => {
const role: Role | undefined = u.app_metadata?.role
const role: Role | undefined = u.role
const label = role ? ROLE_META[role]?.label : ""
return [u.email, u.user_metadata?.nombre, u.user_metadata?.apellidos, label]
return [u.nombre, u.apellidos, label]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(t))
})
}, [q, data])
function openEdit(u: AdminUser) {
function openEdit(u: UserClaims) {
setEditing(u)
setForm({
role: u.app_metadata?.role,
claims_admin: !!u.app_metadata?.claims_admin,
nombre: u.user_metadata?.nombre ?? "",
apellidos: u.user_metadata?.apellidos ?? "",
title: u.user_metadata?.title ?? "",
clave: u.user_metadata?.clave ?? "",
avatar: u.user_metadata?.avatar ?? "",
facultad_id: u.app_metadata?.facultad_id ?? null,
carrera_id: u.app_metadata?.carrera_id ?? null,
role: u.role,
nombre: u.nombre ?? "",
apellidos: u.apellidos ?? "",
title: u.title ?? "",
clave: u.clave ?? "",
avatar: u.avatar ?? "",
facultad_id: u.facultad_id ?? null,
carrera_id: u.carrera_id ?? null,
})
}
@@ -301,10 +374,10 @@ function RouteComponent() {
<div className="flex items-center gap-2">
<Input placeholder="Buscar por nombre, email o rol…" value={q} onChange={(e) => setQ(e.target.value)} className="w-full" />
<Button variant="outline" size="icon" title="Recargar" onClick={invalidateAll}>
<RefreshCcw className="w-4 h-4" />
<Icons.RefreshCcw className="w-4 h-4" />
</Button>
<Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap">
<Plus className="w-4 h-4 mr-1" /> Nuevo usuario
<Icons.Plus className="w-4 h-4 mr-1" /> Nuevo usuario
</Button>
</div>
</CardHeader>
@@ -312,48 +385,48 @@ function RouteComponent() {
<CardContent>
<div className="grid gap-3">
{filtered.map((u) => {
const m = u.user_metadata || {}
const a = u.app_metadata || {}
const roleCode: Role | undefined = a.role
const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now()
const roleCode: Role | undefined = u.role
const banned = false // cuando se tenga acceso a ese campo
// const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now()
return (
<div key={u.id} className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 sm:p-4 hover:shadow-sm transition">
<div className="flex items-start gap-3 sm:gap-4">
<img src={m.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(m.nombre || u.email || "U")}`} alt="" className="h-10 w-10 rounded-full object-cover" />
<img src={u.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(u.nombre || /* u.email || */ "U")}`} alt="" className="h-10 w-10 rounded-full object-cover" />
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-medium truncate">{m.title ? `${m.title} ` : ""}{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}</div>
<div className="font-medium truncate">{u.title ? `${u.title} ` : ""}{u.nombre ? `${u.nombre} ${u.apellidos ?? ""}` : /* (u.email ?? "—") */ "—"}</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 sm:gap-2">
{roleCode && <RolePill role={roleCode} />}
{a.claims_admin ? (
<Badge className="gap-1" variant="secondary"><ShieldCheck className="w-3 h-3" /> Admin</Badge>
{u.role === "lci" || u.role === "vicerrectoria" ? (
<Badge className="gap-1" variant="secondary"><Icons.ShieldCheck className="w-3 h-3" /> Admin</Badge>
) : (
<Badge className="gap-1" variant="outline"><ShieldAlert className="w-3 h-3" /> Usuario</Badge>
<Badge className="gap-1" variant="outline"><Icons.ShieldAlert className="w-3 h-3" /> Usuario</Badge>
)}
<Badge variant={banned ? ("destructive" as any) : "secondary"} className="gap-1">
{banned ? <BanIcon className="w-3 h-3" /> : <Check className="w-3 h-3" />} {banned ? "Baneado" : "Activo"}
{banned ? <Icons.BanIcon className="w-3 h-3" /> : <Icons.Check className="w-3 h-3" />} {banned ? "Baneado" : "Activo"}
</Badge>
</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">
<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 variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
<Pencil className="w-4 h-4 mr-1" /> Editar
<Icons.Pencil className="w-4 h-4 mr-1" /> Editar
</Button>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] sm:text-xs text-neutral-600">
<span className="inline-flex items-center gap-1"><Mail className="w-3 h-3" /> {u.email ?? "—"}</span>
{/* Cuando se tenga acceso a esta info, se mostrará
<span className="inline-flex items-center gap-1"><Icons.Mail className="w-3 h-3" /> {u.email ?? "—"}</span>
<span className="hidden xs:inline">Creado: {new Date(u.created_at).toLocaleDateString()}</span>
<span className="hidden md:inline">Último acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}</span>
<span className="hidden md:inline"ltimo acceso: {u.last_sign_in_at ? new Date(u.last_sign_in_at).toLocaleDateString() : "—"}</span> */}
</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"><BanIcon className="w-4 h-4" /></Button>
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Pencil 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>
</div>
</div>
</div>
@@ -371,7 +444,7 @@ function RouteComponent() {
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1"><Label>Nombre</Label><Input value={form.nombre ?? ""} onChange={(e) => setForm((s) => ({ ...s, nombre: e.target.value }))} /></div>
<div className="space-y-1"><Label>Apellidos</Label><Input value={form.apellidos ?? ""} onChange={(e) => setForm((s) => ({ ...s, apellidos: e.target.value }))} /></div>
<div className="space-y-1"><Label>Título</Label><Input value={form.title ?? ""} onChange={(e) => setForm((s) => ({ ...s, title: e.target.value }))} /></div>
<div className="space-y-1"><Label>Título <small>(opcional)</small></Label><Input value={form.title ?? ""} onChange={(e) => setForm((s) => ({ ...s, title: e.target.value }))} /></div>
<div className="space-y-1"><Label>Clave</Label><Input value={form.clave ?? ""} onChange={(e) => setForm((s) => ({ ...s, clave: e.target.value }))} /></div>
<div className="space-y-1"><Label>Avatar (URL)</Label><Input value={form.avatar ?? ""} onChange={(e) => setForm((s) => ({ ...s, avatar: e.target.value }))} /></div>
<div className="space-y-1">
@@ -422,6 +495,7 @@ function RouteComponent() {
</div>
)}
{/* Probablemente ya no sea necesario
<div className="space-y-1">
<Label>Permisos</Label>
<Select value={String(!!form.claims_admin)} onValueChange={(v) => setForm((s) => ({ ...s, claims_admin: v === 'true' }))}>
@@ -431,7 +505,7 @@ function RouteComponent() {
<SelectItem value="false">Usuario</SelectItem>
</SelectContent>
</Select>
</div>
</div> */}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
@@ -463,16 +537,16 @@ function RouteComponent() {
<div className="space-y-1 md:col-span-2">
<Label>Contraseña temporal</Label>
<div className="flex gap-2">
<Input type={showPwd ? "text" : "password"} value={createForm.password} onChange={(e) => setCreateForm((s) => ({ ...s, password: e.target.value }))} placeholder="Se generará si la dejas vacía" />
<Button type="button" variant="outline" onClick={() => setCreateForm((s) => ({ ...s, password: genPassword() }))}>Generar</Button>
<Button type="button" variant="outline" onClick={() => setShowPwd((v) => !v)} aria-label="Mostrar u ocultar">{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}</Button>
<Input type={showPwd ? "text" : "password"} value={createForm.password} onChange={(e) => setCreateForm((s) => ({ ...s, password: e.target.value }))} placeholder="abCD12&;" />
{/* <Button type="button" variant="outline" onClick={() => setCreateForm((s) => ({ ...s, password: genPassword() }))}>Generar</Button> */}
<Button type="button" variant="outline" onClick={() => setShowPwd((v) => !v)} aria-label="Mostrar u ocultar">{showPwd ? <Icons.EyeOff className="w-4 h-4" /> : <Icons.Eye className="w-4 h-4" />}</Button>
</div>
<p className="text-[11px] text-neutral-500">Pídeles cambiarla al iniciar sesión.</p>
</div>
<div className="space-y-1"><Label>Nombre</Label><Input value={createForm.nombre ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, nombre: e.target.value }))} /></div>
<div className="space-y-1"><Label>Apellidos</Label><Input value={createForm.apellidos ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, apellidos: e.target.value }))} /></div>
<div className="space-y-1"><Label>Título</Label><Input value={createForm.title ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, title: e.target.value }))} /></div>
<div className="space-y-1"><Label>Título <small>(opcional)</small></Label><Input value={createForm.title ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, title: e.target.value }))} /></div>
<div className="space-y-1"><Label>Clave</Label><Input value={createForm.clave ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, clave: e.target.value }))} /></div>
<div className="space-y-1 md:col-span-2"><Label>Avatar (URL)</Label><Input value={createForm.avatar ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, avatar: e.target.value }))} /></div>
@@ -483,6 +557,7 @@ function RouteComponent() {
onValueChange={(v) => {
setCreateForm((s) => {
const role = v as Role
console.log("Rol seleccionado: ", role, ROLE_META[role]);
if (role === "jefe_carrera") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
if (role === "secretario_academico" || role === "director_facultad") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: null }
return { ...s, role, facultad_id: null, carrera_id: null }
@@ -523,6 +598,7 @@ function RouteComponent() {
</div>
)}
{/* Probablemente ya no sea necesario
<div className="space-y-1 md:col-span-2">
<Label>Permisos</Label>
<Select value={String(!!createForm.claims_admin)} onValueChange={(v) => setCreateForm((s) => ({ ...s, claims_admin: v === "true" }))}>
@@ -532,12 +608,12 @@ function RouteComponent() {
<SelectItem value="false">Usuario</SelectItem>
</SelectContent>
</Select>
</div>
</div> */}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
<Button onClick={() => createUser.mutate(createForm)} disabled={!createForm.email || createUser.isPending}>
<Button onClick={() => createUser.mutate(createForm)} disabled={!createForm.email || !createForm.password || createUser.isPending}>
{createUser.isPending ? "Creando…" : "Crear usuario"}
</Button>
</DialogFooter>

View File

@@ -1,4 +1,4 @@
import { createFileRoute, redirect } from "@tanstack/react-router"
import { createFileRoute, redirect, useRouter } from "@tanstack/react-router"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -27,6 +27,7 @@ function LoginComponent() {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const [showPassword, setShowPassword] = useState(false)
const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -35,7 +36,7 @@ function LoginComponent() {
try {
await auth.login(email, password)
window.location.href = redirect
router.navigate({ to: redirect})
} catch (err: any) {
setError(err.message || "No fue posible iniciar sesión")
} finally {
@@ -95,12 +96,6 @@ function LoginComponent() {
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Contraseña</Label>
<a
href="/reset-password"
className="text-xs text-muted-foreground underline-offset-4 hover:underline"
>
¿Olvidaste tu contraseña?
</a>
</div>
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex w-10 items-center justify-center">
@@ -124,6 +119,14 @@ function LoginComponent() {
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<div>
<a
href="/reset-password"
className="text-xs text-muted-foreground underline-offset-4 hover:underline"
>
¿Olvidaste tu contraseña?
</a>
</div>
</div>
<Button type="submit" disabled={isLoading} className="w-full font-mono" size="lg">

View File

@@ -1,5 +1,5 @@
export type RefRow = {
fine_tuning_referencias_id: string
documentos_id: string
titulo_archivo: string | null
descripcion: string | null
s3_file_path: string | null // Added this property to match the API requirements.