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
|
isAuthenticated: boolean
|
||||||
user: User | null
|
user: User | null
|
||||||
claims: UserClaims | null
|
claims: UserClaims | null
|
||||||
|
roles: RolCatalogo[] | null
|
||||||
login: (email: string, password: string) => Promise<void>
|
login: (email: string, password: string) => Promise<void>
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Role =
|
export interface RolCatalogo {
|
||||||
| 'lci'
|
id: string
|
||||||
| 'vicerrectoria'
|
nombre: string
|
||||||
| 'director_facultad' // 👈 NEW
|
icono: string
|
||||||
| 'secretario_academico'
|
nombre_clase: string
|
||||||
| 'jefe_carrera'
|
label: string
|
||||||
| 'planeacion'
|
}
|
||||||
|
|
||||||
type UserClaims = {
|
export type Role = string;
|
||||||
claims_admin: boolean
|
|
||||||
clave: string
|
export type UserClaims = {
|
||||||
|
id: string | null
|
||||||
|
clave?: string
|
||||||
nombre: string
|
nombre: string
|
||||||
apellidos: string
|
apellidos: string
|
||||||
title: string
|
title?: string
|
||||||
avatar: string | null
|
avatar?: string | null
|
||||||
carrera_id?: string | null
|
carrera_id?: string | null
|
||||||
facultad_id?: string | null
|
facultad_id?: string | null
|
||||||
facultad_color?: string | null // 🎨 NEW
|
facultad_color?: string | null // 🎨 NEW
|
||||||
@@ -41,28 +44,35 @@ const SupabaseAuthContext = createContext<SupabaseAuthState | undefined>(undefin
|
|||||||
export function SupabaseAuthProvider({ children }: { children: React.ReactNode }) {
|
export function SupabaseAuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
const [claims, setClaims] = useState<UserClaims | null>(null)
|
const [claims, setClaims] = useState<UserClaims | null>(null)
|
||||||
|
const [roles, setRoles] = useState<RolCatalogo[] | null>(null)
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
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
|
// Carga inicial
|
||||||
supabase.auth.getSession().then(async ({ data: { session } }) => {
|
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||||
const u = session?.user ?? null
|
handleSession(session)
|
||||||
setUser(u)
|
|
||||||
setIsAuthenticated(!!u)
|
|
||||||
setClaims(await buildClaims(session))
|
|
||||||
setIsLoading(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Carga roles catálogo
|
||||||
|
fetchRoles().then(fetchedRoles => {
|
||||||
|
setRoles(fetchedRoles);
|
||||||
|
});
|
||||||
|
|
||||||
// Suscripción a cambios de sesión
|
// Suscripción a cambios de sesión
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => {
|
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
const u = session?.user ?? null
|
handleSession(session)
|
||||||
setUser(u)
|
|
||||||
setIsAuthenticated(!!u)
|
|
||||||
setClaims(await buildClaims(session))
|
|
||||||
setIsLoading(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => subscription.unsubscribe()
|
return () => subscription.unsubscribe()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -80,7 +90,7 @@ export function SupabaseAuthProvider({ children }: { children: React.ReactNode }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SupabaseAuthContext.Provider
|
<SupabaseAuthContext.Provider
|
||||||
value={{ isAuthenticated, user, claims, login, logout, isLoading }}
|
value={{ isAuthenticated, user, claims, roles, login, logout, isLoading }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</SupabaseAuthContext.Provider>
|
</SupabaseAuthContext.Provider>
|
||||||
@@ -99,49 +109,54 @@ export function useSupabaseAuth() {
|
|||||||
* Helpers
|
* 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> {
|
async function buildClaims(session: Session | null): Promise<UserClaims | null> {
|
||||||
const u = session?.user
|
// Validar sesión
|
||||||
if (!u) return null
|
if (!session || !session.user) {
|
||||||
|
console.warn('No session or user found');
|
||||||
const app = (u.app_metadata ?? {}) as Partial<UserClaims> & { role?: Role }
|
return null;
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
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 {
|
if (error) {
|
||||||
claims_admin: !!base.claims_admin,
|
console.error('Error al obtener la información:', error);
|
||||||
role: (base.role ?? 'lci') as Role,
|
throw new Error('Error al obtener la información del usuario');
|
||||||
clave: base.clave ?? '',
|
}
|
||||||
nombre: base.nombre ?? '',
|
|
||||||
apellidos: base.apellidos ?? '',
|
console.log(data);
|
||||||
title: base.title ?? '',
|
if (!data || data.length === 0) {
|
||||||
avatar: base.avatar ?? null,
|
console.warn('No se encontró información para el usuario');
|
||||||
facultad_id: (base.facultad_id as string | null) ?? null,
|
return null;
|
||||||
carrera_id: (base.carrera_id as string | null) ?? null,
|
}
|
||||||
facultad_color, // 🎨
|
|
||||||
|
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"
|
import type { RefRow } from "@/types/RefRow"
|
||||||
|
|
||||||
// POST -> recibe blob PDF y (opcional) Content-Disposition
|
// 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, {
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -35,6 +35,7 @@ export function DetailDialog({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
pdfUrl?: string
|
pdfUrl?: string
|
||||||
}) {
|
}) {
|
||||||
|
console.log("DetailDialog render", { row })
|
||||||
const [viewerUrl, setViewerUrl] = useState<string | null>(null)
|
const [viewerUrl, setViewerUrl] = useState<string | null>(null)
|
||||||
const [currentBlob, setCurrentBlob] = useState<Blob | null>(null)
|
const [currentBlob, setCurrentBlob] = useState<Blob | null>(null)
|
||||||
const [filename, setFilename] = useState<string>("archivo.pdf")
|
const [filename, setFilename] = useState<string>("archivo.pdf")
|
||||||
@@ -48,13 +49,15 @@ export function DetailDialog({
|
|||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!row?.s3_file_path) {
|
console.log(row)
|
||||||
|
if (!row?.documentos_id) {
|
||||||
setViewerUrl(null)
|
setViewerUrl(null)
|
||||||
setCurrentBlob(null)
|
setCurrentBlob(null)
|
||||||
|
console.warn("No hay documentos_id en el row")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
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
|
if (ctrl.signal.aborted) return
|
||||||
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
|
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
|
||||||
setFilename(name)
|
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)
|
// 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.")
|
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`, { 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 })
|
||||||
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
|
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
|
||||||
const link = document.createElement("a")
|
const link = document.createElement("a")
|
||||||
const href = URL.createObjectURL(blob)
|
const href = URL.createObjectURL(blob)
|
||||||
@@ -111,7 +114,7 @@ export function DetailDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="max-w-3xl">
|
<DialogContent className="max-w-fit">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="font-mono">{row?.titulo_archivo ?? "(Sin título)"}</DialogTitle>
|
<DialogTitle className="font-mono">{row?.titulo_archivo ?? "(Sin título)"}</DialogTitle>
|
||||||
<DialogDescription>{row?.descripcion || "Sin descripción"}</DialogDescription>
|
<DialogDescription>{row?.descripcion || "Sin descripción"}</DialogDescription>
|
||||||
@@ -131,13 +134,13 @@ export function DetailDialog({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Tags
|
||||||
{row.tags?.length ? (
|
{row.tags?.length ? (
|
||||||
<div className="text-xs text-neutral-600">
|
<div className="text-xs text-neutral-600">
|
||||||
<span className="font-medium">Tags: </span>
|
<span className="font-medium">Tags: </span>
|
||||||
{row.tags.join(", ")}
|
{row.tags.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null} */}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-neutral-600">Instrucciones</Label>
|
<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 { AuroraButton } from "@/components/effect/aurora-button"
|
||||||
import confetti from "canvas-confetti"
|
import confetti from "canvas-confetti"
|
||||||
import { useQueryClient } from "@tanstack/react-query"
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
import { supabase } from "@/auth/supabase"
|
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||||
import { Field } from "./Field"
|
import { Field } from "./Field"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
||||||
import { asignaturaKeys } from "./planQueries"
|
import { asignaturaKeys } from "./planQueries"
|
||||||
|
|
||||||
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
|
export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const supabaseAuth = useSupabaseAuth()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [mode, setMode] = useState<"manual" | "ia">("manual")
|
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_teoricas: toNum(f.horas_teoricas),
|
||||||
horas_practicas: toNum(f.horas_practicas),
|
horas_practicas: toNum(f.horas_practicas),
|
||||||
objetivos: toNull(f.objetivos),
|
objetivos: toNull(f.objetivos),
|
||||||
contenidos: {}, bibliografia: [], criterios_evaluacion: null,
|
contenidos: [], bibliografia: [], criterios_evaluacion: null,
|
||||||
}
|
}
|
||||||
const { error } = await supabase.from("asignaturas").insert([payload])
|
const { error } = await supabase.from("asignaturas").insert([payload])
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
if (error) { alert(error.message); return }
|
if (error) { alert(error.message); return }
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
onAdded?.()
|
onAdded?.()
|
||||||
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
|
||||||
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWithAI() {
|
async function createWithAI() {
|
||||||
if (!canIA) return
|
if (!canIA) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
// inserte la asignatura generada directamente
|
||||||
|
// obtengas el uuid que se insertó
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/generar/asignatura`, {
|
const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/generar/asignatura`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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())
|
if (!res.ok) throw new Error(await res.text())
|
||||||
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
|
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
onAdded?.()
|
onAdded?.()
|
||||||
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
// qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
|
||||||
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
// qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e?.message ?? "Error al generar la asignatura")
|
alert(e?.message ?? "Error al generar la asignatura")
|
||||||
} finally { setSaving(false) }
|
} finally { setSaving(false) }
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
async function fetchDbFiles() {
|
async function fetchDbFiles() {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("fine_tuning_referencias")
|
.from("documentos")
|
||||||
.select("fine_tuning_referencias_id, titulo_archivo, s3_file_path, fecha_subida, tags")
|
.select("documentos_id, titulo_archivo, fecha_subida, tags")
|
||||||
.ilike("titulo_archivo", `%${debouncedSearchTerm}%`)
|
.ilike("titulo_archivo", `%${debouncedSearchTerm}%`)
|
||||||
.range((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage - 1);
|
.range((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage - 1);
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
}
|
}
|
||||||
|
|
||||||
setDbFiles((data || []).map((file: any) => ({
|
setDbFiles((data || []).map((file: any) => ({
|
||||||
id: file.fine_tuning_referencias_id,
|
id: file.documentos_id,
|
||||||
titulo: file.titulo_archivo,
|
titulo: file.titulo_archivo,
|
||||||
s3_file_path: file.s3_file_path,
|
s3_file_path: file.s3_file_path,
|
||||||
fecha_subida: file.fecha_subida,
|
fecha_subida: file.fecha_subida,
|
||||||
@@ -162,9 +162,10 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
try {
|
try {
|
||||||
const res = await postAPI("/api/generar/plan", {
|
const res = await postAPI("/api/generar/plan", {
|
||||||
carreraId,
|
carreraId,
|
||||||
prompt,
|
prompt: prompt,
|
||||||
insert: true,
|
insert: true,
|
||||||
files: selectedFiles,
|
files: selectedFiles,
|
||||||
|
uuid: auth.user?.id,
|
||||||
})
|
})
|
||||||
const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id
|
const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id
|
||||||
if (newId) {
|
if (newId) {
|
||||||
@@ -260,8 +261,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
const ext = fileExt(file.titulo);
|
const ext = fileExt(file.titulo);
|
||||||
const selected = isSelected(file.s3_file_path);
|
const selected = isSelected(file.s3_file_path);
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
type="button"
|
|
||||||
key={file.id}
|
key={file.id}
|
||||||
role="gridcell"
|
role="gridcell"
|
||||||
aria-selected={selected}
|
aria-selected={selected}
|
||||||
@@ -269,7 +269,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPreviewRow({
|
setPreviewRow({
|
||||||
fine_tuning_referencias_id: file.id,
|
documentos_id: file.id,
|
||||||
created_by: "unknown",
|
created_by: "unknown",
|
||||||
s3_file_path: file.s3_file_path,
|
s3_file_path: file.s3_file_path,
|
||||||
titulo_archivo: file.titulo,
|
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>
|
<p className="text-xs text-neutral-500">Fecha desconocida</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{file.tags && file.tags.length > 0 && (
|
{file.tags && file.tags.length > 0 && (
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{file.tags.map((tag, i) => (
|
{file.tags.map((tag, i) => (
|
||||||
@@ -330,7 +331,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPreviewRow({
|
setPreviewRow({
|
||||||
fine_tuning_referencias_id: file.id,
|
documentos_id: file.id,
|
||||||
created_by: "unknown",
|
created_by: "unknown",
|
||||||
s3_file_path: file.s3_file_path,
|
s3_file_path: file.s3_file_path,
|
||||||
titulo_archivo: file.titulo,
|
titulo_archivo: file.titulo,
|
||||||
@@ -352,7 +353,7 @@ export function CreatePlanDialog({ open, onOpenChange }: { open: boolean; onOpen
|
|||||||
<span className="truncate">{ext.toUpperCase()}</span>
|
<span className="truncate">{ext.toUpperCase()}</span>
|
||||||
{selected ? <span className="font-medium">Seleccionado</span> : <span className="opacity-60">Click para seleccionar</span>}
|
{selected ? <span className="font-medium">Seleccionado</span> : <span className="opacity-60">Click para seleccionar</span>}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ export function DeletePlanButton({ planId, onDeleted }: { planId: string; onDele
|
|||||||
|
|
||||||
return confirm ? (
|
return confirm ? (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setConfirm(false)} disabled={loading}>Cancelar</Button>
|
||||||
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
|
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
|
||||||
{loading ? "Eliminando…" : "Confirmar eliminación"}
|
{loading ? "Eliminando…" : "Confirmar eliminación"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setConfirm(false)} disabled={loading}>Cancelar</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" onClick={() => setConfirm(true)}>
|
<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 }) {
|
export function AcademicSections({ planId, color }: { planId: string; color?: string | null }) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
if(!planId) return <div>Cargando…</div>
|
||||||
const { data: plan } = useSuspenseQuery(planTextOptions(planId))
|
const { data: plan } = useSuspenseQuery(planTextOptions(planId))
|
||||||
|
|
||||||
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
|
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
|
Copiar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{s.key !== "prompt" &&
|
||||||
variant="ghost"
|
(<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => {
|
size="sm"
|
||||||
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
onClick={() => {
|
||||||
setEditing({ key: s.key, title: s.title })
|
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||||
setDraft(current)
|
setEditing({ key: s.key, title: s.title })
|
||||||
}}
|
setDraft(current)
|
||||||
>
|
}}
|
||||||
Editar
|
>
|
||||||
</Button>
|
Editar
|
||||||
|
</Button>)}
|
||||||
</div>
|
</div>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ function useUserDisplay() {
|
|||||||
avatar: claims?.avatar ?? null,
|
avatar: claims?.avatar ?? null,
|
||||||
initials: getInitials([nombre, apellidos].filter(Boolean).join(" ")),
|
initials: getInitials([nombre, apellidos].filter(Boolean).join(" ")),
|
||||||
role,
|
role,
|
||||||
isAdmin: Boolean(claims?.claims_admin),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +149,7 @@ function Layout() {
|
|||||||
|
|
||||||
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
const { claims } = useSupabaseAuth()
|
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 ?? '')
|
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')
|
||||||
|
|
||||||
@@ -189,18 +188,18 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<Link
|
<Link
|
||||||
to="/facultades"
|
to="/facultades"
|
||||||
key='facultades'
|
key='facultades'
|
||||||
activeOptions={{ exact: true }}
|
activeOptions={{ exact: true }}
|
||||||
activeProps={{ className: "bg-primary/10 text-foreground" }}
|
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"
|
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" />
|
<KeySquare className="h-4 w-4" />
|
||||||
<span className="truncate">Facultades</span>
|
<span className="truncate">Facultades</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<Separator className="mt-auto" />
|
<Separator className="mt-auto" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// routes/_authenticated/archivos.tsx
|
// routes/_authenticated/archivos.tsx
|
||||||
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||||
import { useMemo, useState } from "react"
|
import { use, useMemo, useState } from "react"
|
||||||
import { supabase } from "@/auth/supabase"
|
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||||
import * as Icons from "lucide-react"
|
import * as Icons from "lucide-react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -16,12 +16,13 @@ import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@
|
|||||||
import { DetailDialog } from "@/components/archivos/DetailDialog"
|
import { DetailDialog } from "@/components/archivos/DetailDialog"
|
||||||
|
|
||||||
import type { RefRow } from "@/types/RefRow"
|
import type { RefRow } from "@/types/RefRow"
|
||||||
|
import { uuid } from "zod"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/archivos")({
|
export const Route = createFileRoute("/_authenticated/archivos")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("fine_tuning_referencias")
|
.from("documentos")
|
||||||
.select("*")
|
.select("*")
|
||||||
.order("fecha_subida", { ascending: false })
|
.order("fecha_subida", { ascending: false })
|
||||||
.limit(200)
|
.limit(200)
|
||||||
@@ -67,10 +68,25 @@ function RouteComponent() {
|
|||||||
async function remove(id: string) {
|
async function remove(id: string) {
|
||||||
if (!confirm("¿Eliminar archivo de referencia?")) return
|
if (!confirm("¿Eliminar archivo de referencia?")) return
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from("fine_tuning_referencias")
|
.from("documentos")
|
||||||
.delete()
|
.delete()
|
||||||
.eq("fine_tuning_referencias_id", id)
|
.eq("documentos_id", id)
|
||||||
if (error) return alert(error.message)
|
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()
|
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">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{filtered.map((r) => (
|
{filtered.map((r) => (
|
||||||
<article
|
<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"
|
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
|
||||||
>
|
>
|
||||||
<header className="min-w-0">
|
<header className="min-w-0">
|
||||||
@@ -151,6 +167,7 @@ function RouteComponent() {
|
|||||||
<p className="text-sm text-neutral-700 line-clamp-3">{r.descripcion}</p>
|
<p className="text-sm text-neutral-700 line-clamp-3">{r.descripcion}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tags
|
||||||
{r.tags && r.tags.length > 0 && (
|
{r.tags && r.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{r.tags.map((t, i) => (
|
{r.tags.map((t, i) => (
|
||||||
@@ -159,13 +176,13 @@ function RouteComponent() {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between gap-2">
|
<div className="mt-auto flex items-center justify-between gap-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setViewing(r)}>
|
<Button variant="ghost" size="sm" onClick={() => setViewing(r)}>
|
||||||
<Icons.Eye className="w-4 h-4 mr-1" /> Ver
|
<Icons.Eye className="w-4 h-4 mr-1" /> Ver
|
||||||
</Button>
|
</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
|
<Icons.Trash2 className="w-4 h-4 mr-1" /> Eliminar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,6 +209,7 @@ function RouteComponent() {
|
|||||||
function UploadDialog({
|
function UploadDialog({
|
||||||
open, onOpenChange, onDone,
|
open, onOpenChange, onDone,
|
||||||
}: { open: boolean; onOpenChange: (o: boolean) => void; onDone: () => void }) {
|
}: { open: boolean; onOpenChange: (o: boolean) => void; onDone: () => void }) {
|
||||||
|
const supabaseAuth = useSupabaseAuth()
|
||||||
const [file, setFile] = useState<File | null>(null)
|
const [file, setFile] = useState<File | null>(null)
|
||||||
const [instrucciones, setInstrucciones] = useState("")
|
const [instrucciones, setInstrucciones] = useState("")
|
||||||
const [tags, setTags] = useState("")
|
const [tags, setTags] = useState("")
|
||||||
@@ -222,6 +240,7 @@ function UploadDialog({
|
|||||||
prompt: instrucciones,
|
prompt: instrucciones,
|
||||||
fileBase64,
|
fileBase64,
|
||||||
insert: true,
|
insert: true,
|
||||||
|
uuid: supabaseAuth.user?.id ?? null,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -234,21 +253,21 @@ function UploadDialog({
|
|||||||
try {
|
try {
|
||||||
const payload = await res.json()
|
const payload = await res.json()
|
||||||
createdId =
|
createdId =
|
||||||
payload?.fine_tuning_referencias_id ||
|
payload?.documentos_id ||
|
||||||
payload?.id ||
|
payload?.id ||
|
||||||
payload?.data?.fine_tuning_referencias_id ||
|
payload?.data?.documentos_id ||
|
||||||
null
|
null
|
||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
|
|
||||||
if (createdId && (tags.trim() || fuente.trim() || typeof interno === "boolean")) {
|
if (createdId && (tags.trim() || fuente.trim() || typeof interno === "boolean")) {
|
||||||
await supabase
|
await supabase
|
||||||
.from("fine_tuning_referencias")
|
.from("documentos")
|
||||||
.update({
|
.update({
|
||||||
tags: tags.trim() ? tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
|
tags: tags.trim() ? tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
|
||||||
fuente_autoridad: fuente.trim() || undefined,
|
fuente_autoridad: fuente.trim() || undefined,
|
||||||
interno,
|
interno,
|
||||||
})
|
})
|
||||||
.eq("fine_tuning_referencias_id", createdId)
|
.eq("documentos_id", createdId)
|
||||||
}
|
}
|
||||||
|
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// routes/_authenticated/asignatura/$asignaturaId.tsx
|
// routes/_authenticated/asignatura/$asignaturaId.tsx
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
|
import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
|
||||||
import * as Icons from "lucide-react"
|
import * as Icons from "lucide-react"
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
@@ -134,7 +135,7 @@ function Page() {
|
|||||||
{/* ===== Hero ===== */}
|
{/* ===== Hero ===== */}
|
||||||
<div className="relative overflow-hidden rounded-3xl border shadow-sm">
|
<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={`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="min-w-0">
|
||||||
<div className="inline-flex items-center gap-2 text-xs text-neutral-600">
|
<div className="inline-flex items-center gap-2 text-xs text-neutral-600">
|
||||||
<Icons.BookOpen className="h-4 w-4" /> Asignatura
|
<Icons.BookOpen className="h-4 w-4" /> Asignatura
|
||||||
@@ -165,6 +166,7 @@ function Page() {
|
|||||||
</Button>
|
</Button>
|
||||||
<EditAsignaturaButton asignatura={a} onUpdate={setA} />
|
<EditAsignaturaButton asignatura={a} onUpdate={setA} />
|
||||||
<MejorarAIButton asignaturaId={a.id} onApply={(nuevo) => setA(nuevo)} />
|
<MejorarAIButton asignaturaId={a.id} onApply={(nuevo) => setA(nuevo)} />
|
||||||
|
<BorrarAsignaturaButton asignatura_id={a.id} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -191,7 +193,7 @@ function Page() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Syllabus */}
|
{/* Syllabus */}
|
||||||
{unidades.length > 0 && (
|
|
||||||
<Section id="syllabus" title="Programa / Contenidos" icon={Icons.ListTree}>
|
<Section id="syllabus" title="Programa / Contenidos" icon={Icons.ListTree}>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
@@ -285,7 +287,7 @@ function Page() {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
{/* Bibliografía */}
|
{/* 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 }) {
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -632,9 +679,9 @@ export function EditContenidosButton({
|
|||||||
}
|
}
|
||||||
return { title, temas }
|
return { title, temas }
|
||||||
})
|
})
|
||||||
return entries.length ? entries : [{ title: "Unidad 1", temas: [] }]
|
return entries.length ? entries : [{ title: "", temas: [] }]
|
||||||
} catch {
|
} catch {
|
||||||
return [{ title: "Unidad 1", temas: [] }]
|
return [{ title: "", temas: [] }]
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -650,7 +697,7 @@ export function EditContenidosButton({
|
|||||||
.forEach((t, i) => {
|
.forEach((t, i) => {
|
||||||
sub[String(i + 1)] = t
|
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
|
return out
|
||||||
}, [])
|
}, [])
|
||||||
@@ -669,7 +716,7 @@ export function EditContenidosButton({
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
title: (u.title || "").trim() || `Unidad ${idx + 1}`,
|
title: (u.title || "").trim(),
|
||||||
temas,
|
temas,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -839,7 +886,7 @@ export function EditContenidosButton({
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() =>
|
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
|
<Icons.Plus className="w-4 h-4 mr-2" /> Agregar unidad
|
||||||
@@ -850,7 +897,7 @@ export function EditContenidosButton({
|
|||||||
|
|
||||||
<DialogFooter className="px-6 pb-5">
|
<DialogFooter className="px-6 pb-5">
|
||||||
<Button variant="outline" onClick={cancel}>Cancelar</Button>
|
<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 ? (
|
{saving ? (
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<Icons.Loader2 className="h-4 w-4 animate-spin" /> Guardando…
|
<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 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 role = auth.claims?.role as 'lci' | 'vicerrectoria' | 'secretario_academico' | 'jefe_carrera' | 'planeacion' | undefined
|
||||||
|
|
||||||
const navigate = useNavigate({ from: Route.fullPath })
|
const navigate = useNavigate({ from: Route.fullPath })
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ import { Textarea } from "@/components/ui/textarea"
|
|||||||
import { AuroraButton } from "@/components/effect/aurora-button"
|
import { AuroraButton } from "@/components/effect/aurora-button"
|
||||||
import { DeletePlanButton } from "@/components/planes/DeletePlan"
|
import { DeletePlanButton } from "@/components/planes/DeletePlan"
|
||||||
import { AddAsignaturaButton } from "@/components/planes/AddAsignaturaButton"
|
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")({
|
export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
@@ -33,24 +34,24 @@ export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
|||||||
|
|
||||||
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
|
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
|
||||||
const { planId } = params
|
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(planByIdOptions(planId)),
|
||||||
queryClient.ensureQueryData(asignaturasCountOptions(planId)),
|
// queryClient.ensureQueryData(asignaturasCountOptions(planId)),
|
||||||
queryClient.ensureQueryData(asignaturasPreviewOptions(planId)),
|
queryClient.ensureQueryData(asignaturasPreviewOptions(planId)),
|
||||||
])
|
])
|
||||||
return { planId }
|
|
||||||
|
return { plan, asignaturas }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// ...existing code...
|
// ...existing code...
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { planId } = Route.useLoaderData() as LoaderData
|
const { plan, asignaturas: asignaturasPreview } = Route.useLoaderData() as LoaderData
|
||||||
const auth = useSupabaseAuth()
|
const auth = useSupabaseAuth()
|
||||||
|
const asignaturasCount = asignaturasPreview.length
|
||||||
const { data: plan } = useSuspenseQuery(planByIdOptions(planId))
|
|
||||||
const { data: asignaturasCount } = useSuspenseQuery(asignaturasCountOptions(planId))
|
|
||||||
const { data: asignaturasPreview } = useSuspenseQuery(asignaturasPreviewOptions(planId))
|
|
||||||
|
|
||||||
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
|
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
|
||||||
const showCarrera = auth.claims?.role === 'secretario_academico'
|
const showCarrera = auth.claims?.role === 'secretario_academico'
|
||||||
@@ -78,7 +79,7 @@ function RouteComponent() {
|
|||||||
</nav>
|
</nav>
|
||||||
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
|
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
|
||||||
<div className="absolute inset-0 -z-0" style={accent} />
|
<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">
|
<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"
|
<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 }}>
|
style={{ borderColor: accent.borderColor as string }}>
|
||||||
@@ -98,11 +99,13 @@ function RouteComponent() {
|
|||||||
{plan.estado}
|
{plan.estado}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<div className='flex gap-2'>
|
{/* <div className='flex gap-2'> */}
|
||||||
<EditPlanButton plan={plan} />
|
<EditPlanButton plan={plan} />
|
||||||
<AdjustAIButton plan={plan} />
|
<AdjustAIButton plan={plan} />
|
||||||
|
<DescargarPdfButton planId={plan.id} opcion="plan" />
|
||||||
|
<DescargarPdfButton planId={plan.id} opcion="asignaturas" />
|
||||||
<DeletePlanButton planId={plan.id} />
|
<DeletePlanButton planId={plan.id} />
|
||||||
</div>
|
{/* </div> */}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent ref={statsRef}>
|
<CardContent ref={statsRef}>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const Route = createFileRoute("/_authenticated/planes")({
|
|||||||
`)
|
`)
|
||||||
.order("fecha_creacion", { ascending: false })
|
.order("fecha_creacion", { ascending: false })
|
||||||
.limit(100)
|
.limit(100)
|
||||||
|
console.log({ data, error })
|
||||||
if (error) throw new Error(error.message)
|
if (error) throw new Error(error.message)
|
||||||
return (data ?? []) as PlanRow[]
|
return (data ?? []) as PlanRow[]
|
||||||
},
|
},
|
||||||
@@ -154,7 +155,7 @@ function RouteComponent() {
|
|||||||
className="bg-white/60"
|
className="bg-white/60"
|
||||||
style={{ borderColor: (chipTint(fac?.color).borderColor as string) }}
|
style={{ borderColor: (chipTint(fac?.color).borderColor as string) }}
|
||||||
>
|
>
|
||||||
{p.estado}
|
{p.estado && p.estado.length > 10 ? `${p.estado.slice(0, 10)}…` : p.estado}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createFileRoute, useRouter } from "@tanstack/react-router"
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { useSuspenseQuery, queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useSuspenseQuery, queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
import { supabase, useSupabaseAuth } from "@/auth/supabase"
|
||||||
|
import type { Role, UserClaims } from "@/auth/supabase"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
||||||
import {
|
import * as Icons from "lucide-react"
|
||||||
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 { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* -------------------- Tipos -------------------- */
|
/* -------------------- 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 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 -------------------- */
|
/* -------------------- Query Keys & Fetcher -------------------- */
|
||||||
const usersKeys = {
|
const usersKeys = {
|
||||||
@@ -69,13 +31,58 @@ const usersKeys = {
|
|||||||
list: () => [...usersKeys.root, "list"] as const,
|
list: () => [...usersKeys.root, "list"] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUsers(): Promise<AdminUser[]> {
|
async function fetchUsers(): Promise<UserClaims[]> {
|
||||||
// ⚠️ Dev only: service role en cliente
|
try {
|
||||||
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
|
const { data: perfiles, error } = await supabase.from("perfiles").select("id");
|
||||||
const { data } = await admin.auth.admin.listUsers()
|
|
||||||
return (data?.users ?? []) as AdminUser[]
|
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 = () =>
|
const usersOptions = () =>
|
||||||
queryOptions({ queryKey: usersKeys.list(), queryFn: fetchUsers, staleTime: 60_000 })
|
queryOptions({ queryKey: usersKeys.list(), queryFn: fetchUsers, staleTime: 60_000 })
|
||||||
|
|
||||||
@@ -91,12 +98,37 @@ export const Route = createFileRoute("/_authenticated/usuarios")({
|
|||||||
/* -------------------- Página -------------------- */
|
/* -------------------- Página -------------------- */
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const auth = useSupabaseAuth()
|
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 router = useRouter()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { data } = useSuspenseQuery(usersOptions())
|
const { data } = useSuspenseQuery(usersOptions())
|
||||||
|
|
||||||
const [q, setQ] = useState("")
|
const [q, setQ] = useState("")
|
||||||
const [editing, setEditing] = useState<AdminUser | null>(null)
|
const [editing, setEditing] = useState<UserClaims | null>(null)
|
||||||
const [form, setForm] = useState<{
|
const [form, setForm] = useState<{
|
||||||
role?: Role
|
role?: Role
|
||||||
claims_admin?: boolean
|
claims_admin?: boolean
|
||||||
@@ -118,10 +150,47 @@ function RouteComponent() {
|
|||||||
}>({ email: "", password: "" })
|
}>({ email: "", password: "" })
|
||||||
|
|
||||||
function genPassword() {
|
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("")
|
const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("")
|
||||||
return s.slice(0, 14)
|
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 ---------- */
|
/* ---------- Mutations ---------- */
|
||||||
const invalidateAll = async () => {
|
const invalidateAll = async () => {
|
||||||
await qc.invalidateQueries({ queryKey: usersKeys.root })
|
await qc.invalidateQueries({ queryKey: usersKeys.root })
|
||||||
@@ -167,11 +236,13 @@ function RouteComponent() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const toggleBan = useMutation({
|
const toggleBan = useMutation({
|
||||||
mutationFn: async (u: AdminUser) => {
|
mutationFn: async (u: UserClaims) => {
|
||||||
const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
|
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 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)
|
// const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
|
||||||
if (error) throw new Error(error.message)
|
// if (error) throw new Error(error.message)
|
||||||
return !banned
|
return !banned
|
||||||
},
|
},
|
||||||
onSuccess: async (isBanned) => {
|
onSuccess: async (isBanned) => {
|
||||||
@@ -183,39 +254,42 @@ function RouteComponent() {
|
|||||||
|
|
||||||
const createUser = useMutation({
|
const createUser = useMutation({
|
||||||
mutationFn: async (payload: typeof createForm) => {
|
mutationFn: async (payload: typeof createForm) => {
|
||||||
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
|
// Validaciones previas
|
||||||
const password = payload.password?.trim() || genPassword()
|
if (!payload.role) {
|
||||||
const { error, data } = await admin.auth.admin.createUser({
|
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(),
|
email: payload.email.trim(),
|
||||||
password,
|
password,
|
||||||
email_confirm: true,
|
options: {
|
||||||
user_metadata: {
|
data: {
|
||||||
nombre: payload.nombre ?? "",
|
nombre: payload.nombre ?? "",
|
||||||
apellidos: payload.apellidos ?? "",
|
apellidos: payload.apellidos ?? "",
|
||||||
title: payload.title ?? "",
|
title: payload.title ?? "",
|
||||||
clave: payload.clave ?? "",
|
clave: payload.clave ?? "",
|
||||||
avatar: payload.avatar ?? "",
|
avatar: payload.avatar ?? "",
|
||||||
},
|
role: payload.role,
|
||||||
app_metadata: {
|
role_id: payload.role ? ROLE_META[payload.role]?.id : null,
|
||||||
role: payload.role,
|
facultad_id: payload.facultad_id ?? null,
|
||||||
claims_admin: !!payload.claims_admin,
|
carrera_id: payload.carrera_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 && 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 })
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
onSuccess: async () => {
|
||||||
@@ -228,19 +302,23 @@ function RouteComponent() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const saveUser = useMutation({
|
const saveUser = useMutation({
|
||||||
mutationFn: async ({ u, f }: { u: AdminUser; f: typeof form }) => {
|
mutationFn: async ({ u, f }: { u: UserClaims; 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 }) })
|
const { error } = await supabase.rpc('actualizar_perfil_y_rol', {
|
||||||
// Simula éxito:
|
datos: {
|
||||||
// 2) Nombramiento si aplica
|
user_id: u.id,
|
||||||
if (f.role && (SCOPED_ROLES as readonly string[]).includes(f.role)) {
|
rol_nombre: f.role,
|
||||||
if (f.role === "director_facultad") {
|
titulo: f.title,
|
||||||
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "director_facultad", facultad_id: f.facultad_id! })
|
facultad_id: f.facultad_id,
|
||||||
} else if (f.role === "secretario_academico") {
|
carrera_id: f.carrera_id,
|
||||||
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "secretario_academico", facultad_id: f.facultad_id! })
|
nombre: f.nombre,
|
||||||
} else if (f.role === "jefe_carrera") {
|
apellidos: f.apellidos,
|
||||||
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "jefe_carrera", facultad_id: f.facultad_id!, carrera_id: f.carrera_id! })
|
avatar: f.avatar,
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -251,34 +329,29 @@ function RouteComponent() {
|
|||||||
onError: (e: any) => toast.error(e?.message || "No se pudo guardar"),
|
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 filtered = useMemo(() => {
|
||||||
const t = q.trim().toLowerCase()
|
const t = q.trim().toLowerCase()
|
||||||
if (!t) return data
|
if (!t) return data
|
||||||
return data.filter((u) => {
|
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 : ""
|
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)
|
.filter(Boolean)
|
||||||
.some((v) => String(v).toLowerCase().includes(t))
|
.some((v) => String(v).toLowerCase().includes(t))
|
||||||
})
|
})
|
||||||
}, [q, data])
|
}, [q, data])
|
||||||
|
|
||||||
function openEdit(u: AdminUser) {
|
function openEdit(u: UserClaims) {
|
||||||
setEditing(u)
|
setEditing(u)
|
||||||
setForm({
|
setForm({
|
||||||
role: u.app_metadata?.role,
|
role: u.role,
|
||||||
claims_admin: !!u.app_metadata?.claims_admin,
|
nombre: u.nombre ?? "",
|
||||||
nombre: u.user_metadata?.nombre ?? "",
|
apellidos: u.apellidos ?? "",
|
||||||
apellidos: u.user_metadata?.apellidos ?? "",
|
title: u.title ?? "",
|
||||||
title: u.user_metadata?.title ?? "",
|
clave: u.clave ?? "",
|
||||||
clave: u.user_metadata?.clave ?? "",
|
avatar: u.avatar ?? "",
|
||||||
avatar: u.user_metadata?.avatar ?? "",
|
facultad_id: u.facultad_id ?? null,
|
||||||
facultad_id: u.app_metadata?.facultad_id ?? null,
|
carrera_id: u.carrera_id ?? null,
|
||||||
carrera_id: u.app_metadata?.carrera_id ?? null,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,10 +374,10 @@ function RouteComponent() {
|
|||||||
<div className="flex items-center gap-2">
|
<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" />
|
<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}>
|
<Button variant="outline" size="icon" title="Recargar" onClick={invalidateAll}>
|
||||||
<RefreshCcw className="w-4 h-4" />
|
<Icons.RefreshCcw className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap">
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -312,48 +385,48 @@ function RouteComponent() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{filtered.map((u) => {
|
{filtered.map((u) => {
|
||||||
const m = u.user_metadata || {}
|
const roleCode: Role | undefined = u.role
|
||||||
const a = u.app_metadata || {}
|
const banned = false // cuando se tenga acceso a ese campo
|
||||||
const roleCode: Role | undefined = a.role
|
// const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now()
|
||||||
const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now()
|
|
||||||
return (
|
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 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">
|
<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="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<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">
|
<div className="mt-1 flex flex-wrap items-center gap-1.5 sm:gap-2">
|
||||||
{roleCode && <RolePill role={roleCode} />}
|
{roleCode && <RolePill role={roleCode} />}
|
||||||
{a.claims_admin ? (
|
{u.role === "lci" || u.role === "vicerrectoria" ? (
|
||||||
<Badge className="gap-1" variant="secondary"><ShieldCheck className="w-3 h-3" /> Admin</Badge>
|
<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">
|
<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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button variant="outline" size="sm" onClick={() => toggleBan.mutate(u)} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
|
<Button variant="outline" size="sm" onClick={() => 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>
|
||||||
<Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
|
<Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
|
||||||
<Pencil className="w-4 h-4 mr-1" /> Editar
|
<Icons.Pencil className="w-4 h-4 mr-1" /> Editar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] sm:text-xs text-neutral-600">
|
<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 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>
|
</div>
|
||||||
<div className="sm:hidden self-start shrink-0 flex gap-1">
|
<div className="sm:hidden self-start shrink-0 flex gap-1">
|
||||||
<Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><BanIcon className="w-4 h-4" /></Button>
|
<Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><Icons.BanIcon className="w-4 h-4" /></Button>
|
||||||
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Pencil className="w-4 h-4" /></Button>
|
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Icons.Pencil className="w-4 h-4" /></Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -371,7 +444,7 @@ function RouteComponent() {
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<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>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>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>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"><Label>Avatar (URL)</Label><Input value={form.avatar ?? ""} onChange={(e) => setForm((s) => ({ ...s, avatar: e.target.value }))} /></div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -422,6 +495,7 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Probablemente ya no sea necesario
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Permisos</Label>
|
<Label>Permisos</Label>
|
||||||
<Select value={String(!!form.claims_admin)} onValueChange={(v) => setForm((s) => ({ ...s, claims_admin: v === 'true' }))}>
|
<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>
|
<SelectItem value="false">Usuario</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
||||||
@@ -463,16 +537,16 @@ function RouteComponent() {
|
|||||||
<div className="space-y-1 md:col-span-2">
|
<div className="space-y-1 md:col-span-2">
|
||||||
<Label>Contraseña temporal</Label>
|
<Label>Contraseña temporal</Label>
|
||||||
<div className="flex gap-2">
|
<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" />
|
<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={() => 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>
|
<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>
|
</div>
|
||||||
<p className="text-[11px] text-neutral-500">Pídeles cambiarla al iniciar sesión.</p>
|
<p className="text-[11px] text-neutral-500">Pídeles cambiarla al iniciar sesión.</p>
|
||||||
</div>
|
</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>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>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"><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>
|
<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) => {
|
onValueChange={(v) => {
|
||||||
setCreateForm((s) => {
|
setCreateForm((s) => {
|
||||||
const role = v as Role
|
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 === "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 }
|
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 }
|
return { ...s, role, facultad_id: null, carrera_id: null }
|
||||||
@@ -523,6 +598,7 @@ function RouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Probablemente ya no sea necesario
|
||||||
<div className="space-y-1 md:col-span-2">
|
<div className="space-y-1 md:col-span-2">
|
||||||
<Label>Permisos</Label>
|
<Label>Permisos</Label>
|
||||||
<Select value={String(!!createForm.claims_admin)} onValueChange={(v) => setCreateForm((s) => ({ ...s, claims_admin: v === "true" }))}>
|
<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>
|
<SelectItem value="false">Usuario</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
<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"}
|
{createUser.isPending ? "Creando…" : "Crear usuario"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</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 { useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
@@ -27,6 +27,7 @@ function LoginComponent() {
|
|||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -35,7 +36,7 @@ function LoginComponent() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await auth.login(email, password)
|
await auth.login(email, password)
|
||||||
window.location.href = redirect
|
router.navigate({ to: redirect})
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "No fue posible iniciar sesión")
|
setError(err.message || "No fue posible iniciar sesión")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -95,12 +96,6 @@ function LoginComponent() {
|
|||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="password">Contraseña</Label>
|
<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>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex w-10 items-center justify-center">
|
<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" />}
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="/reset-password"
|
||||||
|
className="text-xs text-muted-foreground underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
¿Olvidaste tu contraseña?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={isLoading} className="w-full font-mono" size="lg">
|
<Button type="submit" disabled={isLoading} className="w-full font-mono" size="lg">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type RefRow = {
|
export type RefRow = {
|
||||||
fine_tuning_referencias_id: string
|
documentos_id: string
|
||||||
titulo_archivo: string | null
|
titulo_archivo: string | null
|
||||||
descripcion: string | null
|
descripcion: string | null
|
||||||
s3_file_path: string | null // Added this property to match the API requirements.
|
s3_file_path: string | null // Added this property to match the API requirements.
|
||||||
|
|||||||
Reference in New Issue
Block a user