Compare commits
12 Commits
f92a3dae70
...
14b188d3ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 14b188d3ca | |||
| 3fccdc0478 | |||
| d491100c73 | |||
| ce2cd6b397 | |||
| f2b3010ac9 | |||
| c49c0bbc0a | |||
| 101758da24 | |||
| e03d5f5e36 | |||
| b3ca317e5e | |||
| e12d0ad8b1 | |||
| 4be34e8d6a | |||
| da4cf5a5e0 |
22
.vscode/launch.json
vendored
Normal file
22
.vscode/launch.json
vendored
Normal 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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,26 +44,33 @@ 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(() => {
|
||||
// Carga inicial
|
||||
supabase.auth.getSession().then(async ({ data: { session } }) => {
|
||||
// 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(({ 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,
|
||||
});
|
||||
|
||||
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, // 🎨
|
||||
const data: UserClaims[] | null = result.data;
|
||||
const error = result.error;
|
||||
|
||||
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 || [];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
33
src/components/planes/GenerarPdfButton.tsx
Normal file
33
src/components/planes/GenerarPdfButton.tsx
Normal 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.");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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…
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user