Refactor user management in usuarios.tsx: integrate react-query for data fetching and mutations, streamline role handling, and enhance user ban/unban functionality.

This commit is contained in:
2025-08-27 16:15:42 -06:00
parent 234c41d0b6
commit 3bc4498e4f
11 changed files with 1279 additions and 1313 deletions

View File

@@ -19,7 +19,8 @@
"@supabase/supabase-js": "^2.55.0", "@supabase/supabase-js": "^2.55.0",
"@tailwindcss/vite": "^4.1.12", "@tailwindcss/vite": "^4.1.12",
"@tanstack/react-devtools": "^0.2.2", "@tanstack/react-devtools": "^0.2.2",
"@tanstack/react-router": "^1.130.2", "@tanstack/react-query": "^5.85.5",
"@tanstack/react-router": "^1.131.28",
"@tanstack/react-router-devtools": "^1.131.5", "@tanstack/react-router-devtools": "^1.131.5",
"@tanstack/router-plugin": "^1.121.2", "@tanstack/router-plugin": "^1.121.2",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
@@ -392,15 +393,19 @@
"@tanstack/history": ["@tanstack/history@1.131.2", "", {}, "sha512-cs1WKawpXIe+vSTeiZUuSBy8JFjEuDgdMKZFRLKwQysKo8y2q6Q1HvS74Yw+m5IhOW1nTZooa6rlgdfXcgFAaw=="], "@tanstack/history": ["@tanstack/history@1.131.2", "", {}, "sha512-cs1WKawpXIe+vSTeiZUuSBy8JFjEuDgdMKZFRLKwQysKo8y2q6Q1HvS74Yw+m5IhOW1nTZooa6rlgdfXcgFAaw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.85.5", "", {}, "sha512-KO0WTob4JEApv69iYp1eGvfMSUkgw//IpMnq+//cORBzXf0smyRwPLrUvEe5qtAEGjwZTXrjxg+oJNP/C00t6w=="],
"@tanstack/react-devtools": ["@tanstack/react-devtools@0.2.2", "", { "dependencies": { "@tanstack/devtools": "0.3.0" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-Ig8ZYqUPJ+nwRvF/RpkQHPbgEkrL3b2PjeYBgXgT5OemyRUlmG12UutvMBV+bJuBsSOKHrNf29IvzC0Vw9Bt1A=="], "@tanstack/react-devtools": ["@tanstack/react-devtools@0.2.2", "", { "dependencies": { "@tanstack/devtools": "0.3.0" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-Ig8ZYqUPJ+nwRvF/RpkQHPbgEkrL3b2PjeYBgXgT5OemyRUlmG12UutvMBV+bJuBsSOKHrNf29IvzC0Vw9Bt1A=="],
"@tanstack/react-router": ["@tanstack/react-router@1.131.26", "", { "dependencies": { "@tanstack/history": "1.131.2", "@tanstack/react-store": "^0.7.0", "@tanstack/router-core": "1.131.26", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-bXfONifen0f3EBfHXTSSCQMT/svV+/te/ncgZSUdxrN/nE01GqGsBvD590wOQMV9CBw5iqFfxEM3kA5GM3RhXw=="], "@tanstack/react-query": ["@tanstack/react-query@5.85.5", "", { "dependencies": { "@tanstack/query-core": "5.85.5" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/X4EFNcnPiSs8wM2v+b6DqS5mmGeuJQvxBglmDxl6ZQb5V26ouD2SJYAcC3VjbNwqhY2zjxVD15rDA5nGbMn3A=="],
"@tanstack/react-router": ["@tanstack/react-router@1.131.28", "", { "dependencies": { "@tanstack/history": "1.131.2", "@tanstack/react-store": "^0.7.0", "@tanstack/router-core": "1.131.28", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-vWExhrqHJuT9v+6/2DCQ4pVvPaYoLazMNw8WXiLNuzBXh1FuEoIGaW3jw3DEP0OJCmMiWtTi34NzQnakkQZlQg=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.131.26", "", { "dependencies": { "@tanstack/router-devtools-core": "1.131.26" }, "peerDependencies": { "@tanstack/react-router": "^1.131.26", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-QdDF2t3ILZLqblBYDWQXpQ8QsHzo2ZJcWhaeQEdAkMZ0w0mlfKdZKOGigA21KvDbyTOgkfuQBj+DlkiQPqKYMA=="], "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.131.26", "", { "dependencies": { "@tanstack/router-devtools-core": "1.131.26" }, "peerDependencies": { "@tanstack/react-router": "^1.131.26", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-QdDF2t3ILZLqblBYDWQXpQ8QsHzo2ZJcWhaeQEdAkMZ0w0mlfKdZKOGigA21KvDbyTOgkfuQBj+DlkiQPqKYMA=="],
"@tanstack/react-store": ["@tanstack/react-store@0.7.3", "", { "dependencies": { "@tanstack/store": "0.7.2", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-3Dnqtbw9P2P0gw8uUM8WP2fFfg8XMDSZCTsywRPZe/XqqYW8PGkXKZTvP0AHkE4mpqP9Y43GpOg9vwO44azu6Q=="], "@tanstack/react-store": ["@tanstack/react-store@0.7.3", "", { "dependencies": { "@tanstack/store": "0.7.2", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-3Dnqtbw9P2P0gw8uUM8WP2fFfg8XMDSZCTsywRPZe/XqqYW8PGkXKZTvP0AHkE4mpqP9Y43GpOg9vwO44azu6Q=="],
"@tanstack/router-core": ["@tanstack/router-core@1.131.26", "", { "dependencies": { "@tanstack/history": "1.131.2", "@tanstack/store": "^0.7.0", "cookie-es": "^1.2.2", "seroval": "^1.3.2", "seroval-plugins": "^1.3.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-MED3i/vhHqBFfQZp309JduePtnJwG30KTM+swKgBWBwDoQHvYbtTWhJKPAm1EhkuFyIXuZo/mWTCwdzo/Te7pA=="], "@tanstack/router-core": ["@tanstack/router-core@1.131.28", "", { "dependencies": { "@tanstack/history": "1.131.2", "@tanstack/store": "^0.7.0", "cookie-es": "^1.2.2", "seroval": "^1.3.2", "seroval-plugins": "^1.3.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-f+vdfr3WKSS/BcqgI5s4vZg9xYb7NkvIolkaMELrbz3l+khkw1aTjx8wqCHRY4dqwIAxq+iZBZtMWXA7pztGJg=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.131.26", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.5" }, "peerDependencies": { "@tanstack/router-core": "^1.131.26", "csstype": "^3.0.10", "tiny-invariant": "^1.3.3" }, "optionalPeers": ["csstype"] }, "sha512-TGHmRDQpYphuRbDH+jJp418vQuIydzITaUx7MiPk5U1ZZ+2O/GxcF/ycXmyYR0IHTpSky35I83X3bKTiv+thyw=="], "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.131.26", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.5" }, "peerDependencies": { "@tanstack/router-core": "^1.131.26", "csstype": "^3.0.10", "tiny-invariant": "^1.3.3" }, "optionalPeers": ["csstype"] }, "sha512-TGHmRDQpYphuRbDH+jJp418vQuIydzITaUx7MiPk5U1ZZ+2O/GxcF/ycXmyYR0IHTpSky35I83X3bKTiv+thyw=="],
@@ -892,8 +897,12 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/router-generator/@tanstack/router-core": ["@tanstack/router-core@1.131.26", "", { "dependencies": { "@tanstack/history": "1.131.2", "@tanstack/store": "^0.7.0", "cookie-es": "^1.2.2", "seroval": "^1.3.2", "seroval-plugins": "^1.3.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-MED3i/vhHqBFfQZp309JduePtnJwG30KTM+swKgBWBwDoQHvYbtTWhJKPAm1EhkuFyIXuZo/mWTCwdzo/Te7pA=="],
"@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@tanstack/router-plugin/@tanstack/router-core": ["@tanstack/router-core@1.131.26", "", { "dependencies": { "@tanstack/history": "1.131.2", "@tanstack/store": "^0.7.0", "cookie-es": "^1.2.2", "seroval": "^1.3.2", "seroval-plugins": "^1.3.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-MED3i/vhHqBFfQZp309JduePtnJwG30KTM+swKgBWBwDoQHvYbtTWhJKPAm1EhkuFyIXuZo/mWTCwdzo/Te7pA=="],
"@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],

View File

@@ -25,7 +25,8 @@
"@supabase/supabase-js": "^2.55.0", "@supabase/supabase-js": "^2.55.0",
"@tailwindcss/vite": "^4.1.12", "@tailwindcss/vite": "^4.1.12",
"@tanstack/react-devtools": "^0.2.2", "@tanstack/react-devtools": "^0.2.2",
"@tanstack/react-router": "^1.130.2", "@tanstack/react-query": "^5.85.5",
"@tanstack/react-router": "^1.131.28",
"@tanstack/react-router-devtools": "^1.131.5", "@tanstack/react-router-devtools": "^1.131.5",
"@tanstack/router-plugin": "^1.121.2", "@tanstack/router-plugin": "^1.121.2",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",

View File

@@ -1,80 +1,21 @@
import * as Icons from "lucide-react" import * as Icons from "lucide-react"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useSuspenseQuery, useMutation, useQueryClient, queryOptions } from "@tanstack/react-query"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { supabase } from "@/auth/supabase" import { supabase } from "@/auth/supabase"
import { toast } from "sonner"
/* ---------- helpers de color ---------- */ /* =====================================================
function hexToRgb(hex?: string | null): [number, number, number] { Query keys & fetcher
if (!hex) return [37, 99, 235] ===================================================== */
const h = hex.replace("#", "") export const planKeys = {
const v = h.length === 3 ? h.split("").map(c => c + c).join("") : h root: ["plan"] as const,
const n = parseInt(v, 16) byId: (id: string) => [...planKeys.root, id] as const,
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`
/* ---------- texto expandible (acepta string o string[]) ---------- */
function ExpandableText({ text, mono = false }: { text?: string | string[] | null; mono?: boolean }) {
const [open, setOpen] = useState(false)
if (!text || (Array.isArray(text) && text.length === 0)) {
return <span className="text-neutral-400"></span>
}
const content = Array.isArray(text) ? text.join("\n• ") : text
const rendered = Array.isArray(text) ? `${content}` : content
return (
<div>
<div className={`${mono ? "font-mono whitespace-pre-wrap" : ""} text-sm ${open ? "" : "line-clamp-10"}`}>
{rendered}
</div>
{String(rendered).length > 220 && (
<button onClick={() => setOpen(v => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline">
{open ? "Ver menos" : "Ver más"}
</button>
)}
</div>
)
} }
/* ---------- panel con aurora mesh ---------- */ export type PlanTextFields = {
function SectionPanel({
title, icon: Icon, color, children, id,
}: { title: string; icon: any; color?: string | null; children: React.ReactNode; id: string }) {
const rgb = hexToRgb(color)
return (
<section id={id} className="relative rounded-3xl border overflow-hidden shadow-sm bg-white/70 dark:bg-neutral-900/60">
{/* aurora mesh sutil */}
<div className="pointer-events-none absolute inset-0 -z-0">
<div
className="absolute -top-20 -left-20 w-[28rem] h-[28rem] rounded-full blur-3xl"
style={{ background: `radial-gradient(circle, ${rgba(rgb, .20)}, transparent 60%)` }}
/>
<div
className="absolute -bottom-24 -right-16 w-[24rem] h-[24rem] rounded-full blur-[60px]"
style={{ background: `radial-gradient(circle, ${rgba(rgb, .14)}, transparent 60%)` }}
/>
</div>
<div
className="relative z-10 px-4 py-3 flex items-center gap-2 border-b"
style={{ background: `linear-gradient(180deg, ${rgba(rgb, .10)}, transparent)` }}
>
<span
className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2 bg-white/80"
style={{ borderColor: rgba(rgb, .25) }}
>
<Icon className="w-4 h-4" />
</span>
<h3 className="font-semibold">{title}</h3>
</div>
<div className="relative z-10 p-5">{children}</div>
</section>
)
}
/* ---------- Secciones integradas (sin tabs) ---------- */
type PlanTextFields = {
objetivo_general?: string | string[] | null objetivo_general?: string | string[] | null
sistema_evaluacion?: string | string[] | null sistema_evaluacion?: string | string[] | null
perfil_ingreso?: string | string[] | null perfil_ingreso?: string | string[] | null
@@ -86,15 +27,121 @@ type PlanTextFields = {
prompt?: string | null prompt?: string | null
} }
export function AcademicSections({ async function fetchPlanText(planId: string): Promise<PlanTextFields> {
planId, plan, color, const { data, error } = await supabase
}: { planId: string; plan: PlanTextFields; color?: string | null }) { .from("plan_estudios")
const [local, setLocal] = useState<PlanTextFields>({ ...plan }) .select(
`objetivo_general, sistema_evaluacion, perfil_ingreso, perfil_egreso,
competencias_genericas, competencias_especificas, indicadores_desempeno,
pertinencia, prompt`
)
.eq("id", planId)
.single()
if (error) throw error
return (data ?? {}) as PlanTextFields
}
export const planTextOptions = (planId: string) =>
queryOptions({
queryKey: planKeys.byId(planId),
queryFn: () => fetchPlanText(planId),
staleTime: 60_000,
})
/* =====================================================
Color helpers
===================================================== */
function hexToRgb(hex?: string | null): [number, number, number] {
if (!hex) return [37, 99, 235]
const h = hex.replace("#", "")
const v = h.length === 3 ? h.split("").map((c) => c + c).join("") : h
const n = parseInt(v, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`
/* =====================================================
Expandable text
===================================================== */
function ExpandableText({ text, mono = false }: { text?: string | string[] | null; mono?: boolean }) {
const [open, setOpen] = useState(false)
if (!text || (Array.isArray(text) && text.length === 0)) {
return <span className="text-neutral-400"></span>
}
const content = Array.isArray(text) ? text.join("\n• ") : text
const rendered = Array.isArray(text) ? `${content}` : content
return (
<div>
<div className={`${mono ? "font-mono whitespace-pre-wrap" : ""} text-sm ${open ? "" : "line-clamp-10"}`}>{rendered}</div>
{String(rendered).length > 220 && (
<button onClick={() => setOpen((v) => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline">
{open ? "Ver menos" : "Ver más"}
</button>
)}
</div>
)
}
/* =====================================================
Section panel
===================================================== */
function SectionPanel({ title, icon: Icon, color, children, id }: { title: string; icon: any; color?: string | null; children: React.ReactNode; id: string }) {
const rgb = hexToRgb(color)
return (
<section id={id} className="relative rounded-3xl border overflow-hidden shadow-sm bg-white/70 dark:bg-neutral-900/60">
<div className="pointer-events-none absolute inset-0 -z-0">
<div className="absolute -top-20 -left-20 w-[28rem] h-[28rem] rounded-full blur-3xl" style={{ background: `radial-gradient(circle, ${rgba(rgb, 0.2)}, transparent 60%)` }} />
<div className="absolute -bottom-24 -right-16 w-[24rem] h-[24rem] rounded-full blur-[60px]" style={{ background: `radial-gradient(circle, ${rgba(rgb, 0.14)}, transparent 60%)` }} />
</div>
<div className="relative z-10 px-4 py-3 flex items-center gap-2 border-b" style={{ background: `linear-gradient(180deg, ${rgba(rgb, 0.1)}, transparent)` }}>
<span className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2 bg-white/80" style={{ borderColor: rgba(rgb, 0.25) }}>
<Icon className="w-4 h-4" />
</span>
<h3 className="font-semibold">{title}</h3>
</div>
<div className="relative z-10 p-5">{children}</div>
</section>
)
}
/* =====================================================
AcademicSections (con React Query)
===================================================== */
export function AcademicSections({ planId, color }: { planId: string; color?: string | null }) {
const qc = useQueryClient()
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)
const [draft, setDraft] = useState("") const [draft, setDraft] = useState("")
const [saving, setSaving] = useState(false)
const sections = useMemo(() => [ // --- mutation con actualización optimista ---
const updateField = useMutation({
mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => {
const payload: Record<string, any> = { [key]: value }
const { error } = await supabase.from("plan_estudios").update(payload).eq("id", planId)
if (error) throw error
return payload
},
onMutate: async ({ key, value }) => {
await qc.cancelQueries({ queryKey: planKeys.byId(planId) })
const prev = qc.getQueryData<PlanTextFields>(planKeys.byId(planId))
qc.setQueryData<PlanTextFields>(planKeys.byId(planId), (old) => ({ ...(old ?? {}), [key]: value }))
return { prev }
},
onError: (e, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(planKeys.byId(planId), ctx.prev)
toast.error((e as any)?.message || "No se pudo guardar 😓")
},
onSuccess: () => {
toast.success("Guardado ✅")
},
onSettled: async () => {
await qc.invalidateQueries({ queryKey: planKeys.byId(planId) })
},
})
const sections = useMemo(
() => [
{ id: "sec-obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false }, { id: "sec-obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false },
{ id: "sec-eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false }, { id: "sec-eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false },
{ id: "sec-ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false }, { id: "sec-ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false },
@@ -104,30 +151,15 @@ export function AcademicSections({
{ id: "sec-ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false }, { id: "sec-ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false },
{ id: "sec-per", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false }, { id: "sec-per", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false },
{ id: "sec-prm", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true }, { id: "sec-prm", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true },
], []) ],
[]
async function handleSave() { )
if (!editing) return
setSaving(true)
const payload: any = { [editing.key]: draft }
const { error } = await supabase.from("plan_estudios").update(payload).eq("id", planId)
setSaving(false)
if (error) {
console.error(error)
alert("No se pudo guardar 😓")
return
}
setLocal(prev => ({ ...prev, [editing.key]: draft }))
setEditing(null)
}
return ( return (
<> <>
{/* Todas las tarjetas visibles */}
<div className="grid gap-5 md:grid-cols-2"> <div className="grid gap-5 md:grid-cols-2">
{sections.map(s => { {sections.map((s) => {
const text = local[s.key] ?? null const text = plan[s.key] ?? null
return ( return (
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}> <SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
<ExpandableText text={text} mono={s.mono} /> <ExpandableText text={text} mono={s.mono} />
@@ -164,19 +196,21 @@ export function AcademicSections({
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}> <Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}</DialogTitle>
{editing ? `Editar: ${sections.find(x => x.key === editing.key)?.title}` : ""}
</DialogTitle>
</DialogHeader> </DialogHeader>
<Textarea <Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`} placeholder="Escribe aquí…" />
value={draft}
onChange={(e) => setDraft(e.target.value)}
className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`}
placeholder="Escribe aquí…"
/>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button> <Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? "Guardando…" : "Guardar"}</Button> <Button
onClick={() => {
if (!editing) return
updateField.mutate({ key: editing.key, value: draft })
setEditing(null)
}}
disabled={updateField.isPending}
>
{updateField.isPending ? "Guardando…" : "Guardar"}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -1,6 +1,8 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router' import { RouterProvider, createRouter } from '@tanstack/react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// Import the generated route tree // Import the generated route tree
import { routeTree } from './routeTree.gen' import { routeTree } from './routeTree.gen'
@@ -9,6 +11,8 @@ import './styles.css'
import reportWebVitals from './reportWebVitals.ts' import reportWebVitals from './reportWebVitals.ts'
import { SupabaseAuthProvider, useSupabaseAuth } from './auth/supabase.tsx' import { SupabaseAuthProvider, useSupabaseAuth } from './auth/supabase.tsx'
const queryClient = new QueryClient()
const router = createRouter({ const router = createRouter({
routeTree, routeTree,
defaultPreload: 'intent', defaultPreload: 'intent',
@@ -17,6 +21,7 @@ const router = createRouter({
defaultPreloadStaleTime: 0, defaultPreloadStaleTime: 0,
context: { context: {
auth: undefined!, auth: undefined!,
queryClient
}, },
}) })
@@ -50,9 +55,13 @@ if (rootElement && !rootElement.innerHTML) {
root.render( root.render(
<StrictMode> <StrictMode>
<SupabaseAuthProvider> <SupabaseAuthProvider>
<QueryClientProvider client={queryClient}>
<InnerApp /> <InnerApp />
</QueryClientProvider>,
</SupabaseAuthProvider> </SupabaseAuthProvider>
</StrictMode>,
</StrictMode >,
) )
} }

View File

@@ -1,12 +1,15 @@
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
import type { SupabaseAuthState } from '@/auth/supabase' import type { SupabaseAuthState } from '@/auth/supabase'
import { ThemeProvider } from '@/components/theme-provider' import { ThemeProvider } from '@/components/theme-provider'
import type { QueryClient } from '@tanstack/react-query'
interface AuthContext {
auth: SupabaseAuthState interface Context {
auth: SupabaseAuthState,
queryClient: QueryClient
} }
export const Route = createRootRouteWithContext<AuthContext>()({ export const Route = createRootRouteWithContext<Context>()({
component: () => ( component: () => (
<> <>
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme"> <ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">

View File

@@ -1,4 +1,5 @@
import { createFileRoute, Link, useRouter } from '@tanstack/react-router' import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
import { supabase } from '@/auth/supabase' import { supabase } from '@/auth/supabase'
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
@@ -6,7 +7,6 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select' import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu' import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'
// NEW
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -35,68 +35,51 @@ type Asignatura = {
plan_id?: string | null plan_id?: string | null
} }
type LoaderData = { type SearchState = {
asignaturas: Asignatura[] q: string
planes: PlanMini[] // NEW: para elegir destino planId: string
carreraId: string
facultadId: string
f: 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | ''
} }
/* ================== Ruta ================== */ /* ================== Query Keys & Options ================== */
export const Route = createFileRoute('/_authenticated/asignaturas')({ const asignaturasKeys = {
component: RouteComponent, root: ['asignaturas'] as const,
pendingComponent: PageSkeleton, list: (search: SearchState) => [...asignaturasKeys.root, { search }] as const,
validateSearch: (search: Record<string, unknown>) => { }
return { const planesKeys = {
q: (search.q as string) ?? '', root: ['planes'] as const,
planId: (search.planId as string) ?? '', all: () => [...planesKeys.root, 'all'] as const,
carreraId: (search.carreraId as string) ?? '', }
facultadId: (search.facultadId as string) ?? '',
f: (search.f as 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | '') ?? '',
}
},
loader: async (ctx): Promise<LoaderData> => {
const search = (ctx.location?.search ?? {}) as {
q?: string
planId?: string
carreraId?: string
facultadId?: string
f?: 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | ''
}
async function fetchPlanIdsByScope(search: Pick<SearchState, 'planId'|'carreraId'|'facultadId'>): Promise<string[] | null> {
const { planId, carreraId, facultadId } = search const { planId, carreraId, facultadId } = search
let planIds: string[] | null = null if (planId) return [planId]
if (carreraId) {
if (planId) { const { data, error } = await supabase.from('plan_estudios').select('id').eq('carrera_id', carreraId)
planIds = [planId]
} else if (carreraId) {
const { data: planesCar, error } = await supabase
.from('plan_estudios')
.select('id')
.eq('carrera_id', carreraId)
if (error) throw error if (error) throw error
planIds = (planesCar ?? []).map(p => p.id) return (data ?? []).map(p => p.id)
} else if (facultadId) { }
const { data: carreras, error: carErr } = await supabase if (facultadId) {
.from('carreras') const { data: carreras, error: carErr } = await supabase.from('carreras').select('id').eq('facultad_id', facultadId)
.select('id')
.eq('facultad_id', facultadId)
if (carErr) throw carErr if (carErr) throw carErr
const cIds = (carreras ?? []).map(c => c.id) const cIds = (carreras ?? []).map(c => c.id)
if (!cIds.length) { if (!cIds.length) return []
return { asignaturas: [], planes: [] }
}
const { data: planesFac, error: plaErr } = await supabase const { data: planesFac, error: plaErr } = await supabase
.from('plan_estudios') .from('plan_estudios')
.select('id, nombre, carrera:carreras(id, nombre, facultad:facultades(id, nombre, color, icon))') .select('id')
.in('carrera_id', cIds) .in('carrera_id', cIds)
if (plaErr) throw plaErr if (plaErr) throw plaErr
planIds = (planesFac ?? []).map(p => p.id) return (planesFac ?? []).map(p => p.id)
} }
return null
}
if (planIds && planIds.length === 0) { async function fetchAsignaturas(search: SearchState): Promise<Asignatura[]> {
return { asignaturas: [], planes: [] } const planIds = await fetchPlanIdsByScope(search)
} if (planIds && planIds.length === 0) return []
// Traer asignaturas
let query = supabase let query = supabase
.from('asignaturas') .from('asignaturas')
.select(` .select(`
@@ -113,11 +96,13 @@ export const Route = createFileRoute('/_authenticated/asignaturas')({
.order('semestre', { ascending: true }) .order('semestre', { ascending: true })
.order('nombre', { ascending: true }) .order('nombre', { ascending: true })
if (planIds) query = query.in('plan_id', planIds) if (planIds) query = query.in('plan_id', planIds)
const { data, error: aErr } = await query const { data, error } = await query
if (aErr) throw aErr if (error) throw error
return (data ?? []) as unknown as Asignatura[]
}
// Traer planes (para selector destino) async function fetchPlanes(): Promise<PlanMini[]> {
const { data: planesAll, error: pErr } = await supabase const { data, error } = await supabase
.from('plan_estudios') .from('plan_estudios')
.select(` .select(`
id, nombre, id, nombre,
@@ -127,20 +112,55 @@ export const Route = createFileRoute('/_authenticated/asignaturas')({
) )
`) `)
.order('nombre', { ascending: true }) .order('nombre', { ascending: true })
if (pErr) throw pErr if (error) throw error
return (data ?? []) as unknown as PlanMini[]
}
const asignaturasOptions = (search: SearchState) => queryOptions({
queryKey: asignaturasKeys.list(search),
queryFn: () => fetchAsignaturas(search),
staleTime: 60_000,
})
const planesOptions = () => queryOptions({
queryKey: planesKeys.all(),
queryFn: fetchPlanes,
staleTime: 5 * 60_000,
})
/* ================== Ruta ================== */
export const Route = createFileRoute('/_authenticated/asignaturas')({
component: RouteComponent,
pendingComponent: PageSkeleton,
validateSearch: (search: Record<string, unknown>) => {
return { return {
asignaturas: (data ?? []) as unknown as Asignatura[], q: (search.q as string) ?? '',
planes: (planesAll ?? []) as unknown as PlanMini[], planId: (search.planId as string) ?? '',
carreraId: (search.carreraId as string) ?? '',
facultadId: (search.facultadId as string) ?? '',
f: (search.f as 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | '') ?? '',
} }
}, },
loader: async ({ context: { queryClient }, location }) => {
const search = (location?.search ?? {}) as SearchState
// Pre-hydrate ambas queries con QueryClient (sin llamadas "sueltas" aquí)
await Promise.all([
queryClient.ensureQueryData(asignaturasOptions(search)),
queryClient.ensureQueryData(planesOptions()),
])
return null
},
}) })
/* ================== Página ================== */ /* ================== Página ================== */
function RouteComponent() { function RouteComponent() {
const { asignaturas, planes } = Route.useLoaderData() as LoaderData
const router = useRouter() const router = useRouter()
const search = Route.useSearch() as { q: string; f: '' | 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' } const qc = useQueryClient()
const search = Route.useSearch() as SearchState
// Datos por TanStack Query (suspense-friendly)
const { data: asignaturas } = useSuspenseQuery(asignaturasOptions(search))
const { data: planes } = useSuspenseQuery(planesOptions())
// Filtros // Filtros
const [q, setQ] = useState(search.q ?? '') const [q, setQ] = useState(search.q ?? '')
@@ -323,6 +343,11 @@ function RouteComponent() {
toast.success(`Clonadas ${cart.length} asignaturas`) toast.success(`Clonadas ${cart.length} asignaturas`)
setBulkOpen(false) setBulkOpen(false)
clearCart() clearCart()
// Invalida ambas queries y la ruta
await Promise.all([
qc.invalidateQueries({ queryKey: asignaturasKeys.root }),
qc.invalidateQueries({ queryKey: planesKeys.root }),
])
router.invalidate() router.invalidate()
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e)
@@ -362,7 +387,7 @@ function RouteComponent() {
</span> </span>
)} )}
</Button> </Button>
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar"> <Button variant="outline" size="icon" onClick={() => { qc.invalidateQueries({ queryKey: asignaturasKeys.root }); router.invalidate() }} title="Recargar">
<Icons.RefreshCcw className="w-4 h-4" /> <Icons.RefreshCcw className="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -541,6 +566,7 @@ function RouteComponent() {
}) })
toast.success('Asignatura clonada') toast.success('Asignatura clonada')
setCloneOpen(false) setCloneOpen(false)
await qc.invalidateQueries({ queryKey: asignaturasKeys.root })
router.invalidate() router.invalidate()
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e)

View File

@@ -1,6 +1,7 @@
// routes/_authenticated/carreras.tsx // routes/_authenticated/carreras.tsx (refactor a TanStack Query v5)
import { createFileRoute, useRouter } from "@tanstack/react-router" import { createFileRoute, useRouter } from "@tanstack/react-router"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useSuspenseQuery, useQueryClient, queryOptions, useQuery } from "@tanstack/react-query"
import { supabase } from "@/auth/supabase" import { supabase } from "@/auth/supabase"
import * as Icons from "lucide-react" import * as Icons from "lucide-react"
@@ -9,20 +10,14 @@ import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
} from "@/components/ui/dialog" import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion"
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from "@/components/ui/select"
import {
Accordion, AccordionItem, AccordionTrigger, AccordionContent,
} from "@/components/ui/accordion"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
/* -------------------- Tipos -------------------- */ /* -------------------- Tipos -------------------- */
type FacultadLite = { id: string; nombre: string; color?: string | null; icon?: string | null } type FacultadLite = { id: string; nombre: string; color?: string | null; icon?: string | null }
type CarreraRow = { export type CarreraRow = {
id: string id: string
nombre: string nombre: string
semestres: number semestres: number
@@ -30,39 +25,102 @@ type CarreraRow = {
facultad_id: string | null facultad_id: string | null
facultades?: FacultadLite | null facultades?: FacultadLite | null
} }
type LoaderData = { carreras: CarreraRow[]; facultades: FacultadLite[] }
/* -------------------- Query Keys & Fetchers -------------------- */
const carrerasKeys = {
root: ["carreras"] as const,
list: () => [...carrerasKeys.root, "list"] as const,
}
const facultadesKeys = {
root: ["facultades"] as const,
all: () => [...facultadesKeys.root, "all"] as const,
}
const criteriosKeys = {
root: ["criterios_carrera"] as const,
byCarrera: (id: string) => [...criteriosKeys.root, { carreraId: id }] as const,
}
async function fetchCarreras(): Promise<CarreraRow[]> {
const { data, error } = await supabase
.from("carreras")
.select(
`id, nombre, semestres, activo, facultad_id, facultades:facultades ( id, nombre, color, icon )`
)
.order("nombre", { ascending: true })
if (error) throw error
return (data ?? []) as unknown as CarreraRow[]
}
async function fetchFacultades(): Promise<FacultadLite[]> {
const { data, error } = await supabase
.from("facultades")
.select("id, nombre, color, icon")
.order("nombre", { ascending: true })
if (error) throw error
return (data ?? []) as FacultadLite[]
}
export type CriterioRow = {
id: number
nombre: string
descripcion: string | null
tipo: string | null
obligatorio: boolean | null
referencia_documento: string | null
fecha_creacion: string | null
}
async function fetchCriterios(carreraId: string): Promise<CriterioRow[]> {
const { data, error } = await supabase
.from("criterios_carrera")
.select("id, nombre, descripcion, tipo, obligatorio, referencia_documento, fecha_creacion")
.eq("carrera_id", carreraId)
.order("fecha_creacion", { ascending: true })
if (error) throw error
return (data ?? []) as CriterioRow[]
}
const carrerasOptions = () =>
queryOptions({ queryKey: carrerasKeys.list(), queryFn: fetchCarreras, staleTime: 60_000 })
const facultadesOptions = () =>
queryOptions({ queryKey: facultadesKeys.all(), queryFn: fetchFacultades, staleTime: 5 * 60_000 })
const criteriosOptions = (carreraId: string) =>
queryOptions({ queryKey: criteriosKeys.byCarrera(carreraId), queryFn: () => fetchCriterios(carreraId) })
/* -------------------- Ruta -------------------- */ /* -------------------- Ruta -------------------- */
export const Route = createFileRoute("/_authenticated/carreras")({ export const Route = createFileRoute("/_authenticated/carreras")({
component: RouteComponent, component: RouteComponent,
loader: async (): Promise<LoaderData> => { // Prefetch con TanStack Query (sin llamadas sueltas a Supabase fuera de QueryClient)
const [{ data: carreras, error: e1 }, { data: facultades, error: e2 }] = await Promise.all([ loader: async ({ context: { queryClient } }) => {
supabase await Promise.all([
.from("carreras") queryClient.ensureQueryData(carrerasOptions()),
.select(`id, nombre, semestres, activo, facultad_id, facultades:facultades ( id, nombre, color, icon )`) queryClient.ensureQueryData(facultadesOptions()),
.order("nombre", { ascending: true }),
supabase.from("facultades").select("id, nombre, color, icon").order("nombre", { ascending: true }),
]) ])
if (e1) throw e1 return null
if (e2) throw e2
return {
carreras: (carreras ?? []) as unknown as CarreraRow[],
facultades: (facultades ?? []) as FacultadLite[],
}
}, },
}) })
/* -------------------- Helpers UI -------------------- */ /* -------------------- Helpers UI -------------------- */
const tint = (hex?: string | null, a = .18) => { const tint = (hex?: string | null, a = 0.18) => {
if (!hex) return `rgba(37,99,235,${a})` if (!hex) return `rgba(37,99,235,${a})`
const h = hex.replace("#", "") const h = hex.replace("#", "")
const v = h.length === 3 ? h.split("").map(c => c + c).join("") : h const v = h.length === 3 ? h.split("").map((c) => c + c).join("") : h
const n = parseInt(v, 16) const n = parseInt(v, 16)
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255 const r = (n >> 16) & 255,
g = (n >> 8) & 255,
b = n & 255
return `rgba(${r},${g},${b},${a})` return `rgba(${r},${g},${b},${a})`
} }
const StatusPill = ({ active }: { active: boolean }) => ( const StatusPill = ({ active }: { active: boolean }) => (
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${active ? "bg-emerald-50 text-emerald-700 border-emerald-200" : "bg-neutral-100 text-neutral-700 border-neutral-200"}`}> <span
className={`text-[10px] px-2 py-0.5 rounded-full border ${
active
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: "bg-neutral-100 text-neutral-700 border-neutral-200"
}`}
>
{active ? "Activa" : "Inactiva"} {active ? "Activa" : "Inactiva"}
</span> </span>
) )
@@ -70,7 +128,10 @@ const StatusPill = ({ active }: { active: boolean }) => (
/* -------------------- Página -------------------- */ /* -------------------- Página -------------------- */
function RouteComponent() { function RouteComponent() {
const router = useRouter() const router = useRouter()
const { carreras, facultades } = Route.useLoaderData() as LoaderData const qc = useQueryClient()
const { data: carreras } = useSuspenseQuery(carrerasOptions())
const { data: facultades } = useSuspenseQuery(facultadesOptions())
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [fac, setFac] = useState<string>("todas") const [fac, setFac] = useState<string>("todas")
@@ -87,8 +148,7 @@ function RouteComponent() {
if (state === "activas" && !c.activo) return false if (state === "activas" && !c.activo) return false
if (state === "inactivas" && c.activo) return false if (state === "inactivas" && c.activo) return false
if (!term) return true if (!term) return true
return [c.nombre, c.facultades?.nombre].filter(Boolean) return [c.nombre, c.facultades?.nombre].filter(Boolean).some((v) => String(v).toLowerCase().includes(term))
.some(v => String(v).toLowerCase().includes(term))
}) })
}, [q, fac, state, carreras]) }, [q, fac, state, carreras])
@@ -100,19 +160,18 @@ function RouteComponent() {
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row md:items-center"> <div className="flex w-full flex-col gap-2 md:w-auto md:flex-row md:items-center">
<div className="relative w-full md:w-80"> <div className="relative w-full md:w-80">
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" /> <Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar por nombre o facultad…" className="pl-8" />
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Buscar por nombre o facultad…"
className="pl-8"
/>
</div> </div>
<Select value={fac} onValueChange={(v) => setFac(v)}> <Select value={fac} onValueChange={(v) => setFac(v)}>
<SelectTrigger className="md:w-[220px]"><SelectValue placeholder="Facultad" /></SelectTrigger> <SelectTrigger className="md:w-[220px]"><SelectValue placeholder="Facultad" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="todas">Todas las facultades</SelectItem> <SelectItem value="todas">Todas las facultades</SelectItem>
{facultades.map(f => <SelectItem key={f.id} value={f.id}>{f.nombre}</SelectItem>)} {facultades.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.nombre}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -125,7 +184,18 @@ function RouteComponent() {
</SelectContent> </SelectContent>
</Select> </Select>
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar"> <Button
variant="outline"
size="icon"
onClick={async () => {
await Promise.all([
qc.invalidateQueries({ queryKey: carrerasKeys.root }),
qc.invalidateQueries({ queryKey: facultadesKeys.root }),
])
router.invalidate()
}}
title="Recargar"
>
<Icons.RefreshCcw className="h-4 w-4" /> <Icons.RefreshCcw className="h-4 w-4" />
</Button> </Button>
@@ -137,10 +207,10 @@ function RouteComponent() {
<CardContent> <CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{filtered.map(c => { {filtered.map((c) => {
const fac = c.facultades const fac = c.facultades
const border = tint(fac?.color, .28) const border = tint(fac?.color, 0.28)
const chip = tint(fac?.color, .10) const chip = tint(fac?.color, 0.1)
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2 const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
return ( return (
<article <article
@@ -150,15 +220,12 @@ function RouteComponent() {
> >
<div className="p-5 h-44 flex flex-col justify-between"> <div className="p-5 h-44 flex flex-col justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2 bg-white/70" <span className="inline-flex items-center justify-center rounded-2xl border px-2.5 py-2 bg-white/70" style={{ borderColor: border }}>
style={{ borderColor: border }}>
<IconComp className="w-6 h-6" /> <IconComp className="w-6 h-6" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<div className="font-semibold truncate">{c.nombre}</div> <div className="font-semibold truncate">{c.nombre}</div>
<div className="text-xs text-neutral-600 truncate"> <div className="text-xs text-neutral-600 truncate">{fac?.nombre ?? "—"} · {c.semestres} semestres</div>
{fac?.nombre ?? "—"} · {c.semestres} semestres
</div>
</div> </div>
</div> </div>
@@ -179,9 +246,7 @@ function RouteComponent() {
})} })}
</div> </div>
{!filtered.length && ( {!filtered.length && <div className="text-center text-sm text-neutral-500 py-10">No hay resultados</div>}
<div className="text-center text-sm text-neutral-500 py-10">No hay resultados</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -191,7 +256,10 @@ function RouteComponent() {
onOpenChange={setCreateOpen} onOpenChange={setCreateOpen}
facultades={facultades} facultades={facultades}
mode="create" mode="create"
onSaved={() => router.invalidate()} onSaved={async () => {
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
router.invalidate()
}}
/> />
<CarreraFormDialog <CarreraFormDialog
open={!!editCarrera} open={!!editCarrera}
@@ -199,18 +267,34 @@ function RouteComponent() {
facultades={facultades} facultades={facultades}
mode="edit" mode="edit"
carrera={editCarrera ?? undefined} carrera={editCarrera ?? undefined}
onSaved={() => { setEditCarrera(null); router.invalidate() }} onSaved={async () => {
setEditCarrera(null)
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
router.invalidate()
}}
/> />
{/* Detalle + añadir criterio */} {/* Detalle + añadir criterio */}
<CarreraDetailDialog carrera={detail} onOpenChange={setDetail} onChanged={() => router.invalidate()} /> <CarreraDetailDialog
carrera={detail}
onOpenChange={setDetail}
onChanged={async () => {
await qc.invalidateQueries({ queryKey: carrerasKeys.root })
if (detail) await qc.invalidateQueries({ queryKey: criteriosKeys.byCarrera(detail.id) })
}}
/>
</div> </div>
) )
} }
/* -------------------- Form crear/editar -------------------- */ /* -------------------- Form crear/editar -------------------- */
function CarreraFormDialog({ function CarreraFormDialog({
open, onOpenChange, mode, carrera, facultades, onSaved, open,
onOpenChange,
mode,
carrera,
facultades,
onSaved,
}: { }: {
open: boolean open: boolean
onOpenChange: (o: boolean) => void onOpenChange: (o: boolean) => void
@@ -240,7 +324,10 @@ function CarreraFormDialog({
}, [mode, carrera, open]) }, [mode, carrera, open])
async function save() { async function save() {
if (!nombre.trim()) { alert("Escribe un nombre"); return } if (!nombre.trim()) {
alert("Escribe un nombre")
return
}
setSaving(true) setSaving(true)
const payload = { const payload = {
nombre: nombre.trim(), nombre: nombre.trim(),
@@ -249,13 +336,17 @@ function CarreraFormDialog({
facultad_id: facultadId === "none" ? null : facultadId, facultad_id: facultadId === "none" ? null : facultadId,
} }
const action = mode === "create" const action =
mode === "create"
? supabase.from("carreras").insert([payload]).select("id").single() ? supabase.from("carreras").insert([payload]).select("id").single()
: supabase.from("carreras").update(payload).eq("id", carrera!.id).select("id").single() : supabase.from("carreras").update(payload).eq("id", carrera!.id).select("id").single()
const { error } = await action const { error } = await action
setSaving(false) setSaving(false)
if (error) { alert(error.message); return } if (error) {
alert(error.message)
return
}
onOpenChange(false) onOpenChange(false)
onSaved?.() onSaved?.()
} }
@@ -279,13 +370,7 @@ function CarreraFormDialog({
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label>Semestres</Label> <Label>Semestres</Label>
<Input <Input type="number" min={1} max={20} value={semestres} onChange={(e) => setSemestres(parseInt(e.target.value || "9", 10))} />
type="number"
min={1}
max={20}
value={semestres}
onChange={(e) => setSemestres(parseInt(e.target.value || "9", 10))}
/>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Estado</Label> <Label>Estado</Label>
@@ -299,18 +384,28 @@ function CarreraFormDialog({
<div className="space-y-1"> <div className="space-y-1">
<Label>Facultad</Label> <Label>Facultad</Label>
<Select value={facultadId} onValueChange={(v) => setFacultadId(v as any)}> <Select value={facultadId} onValueChange={(v) => setFacultadId(v as any)}>
<SelectTrigger><SelectValue placeholder="Selecciona una facultad (opcional)" /></SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Selecciona una facultad (opcional)" />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">Sin facultad</SelectItem> <SelectItem value="none">Sin facultad</SelectItem>
{facultades.map(f => <SelectItem key={f.id} value={f.id}>{f.nombre}</SelectItem>)} {facultades.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.nombre}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button> <Button variant="outline" onClick={() => onOpenChange(false)}>
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : (mode === "create" ? "Crear" : "Guardar")}</Button> Cancelar
</Button>
<Button onClick={save} disabled={saving}>
{saving ? "Guardando…" : mode === "create" ? "Crear" : "Guardar"}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -327,41 +422,19 @@ function CarreraDetailDialog({
onOpenChange: (c: CarreraRow | null) => void onOpenChange: (c: CarreraRow | null) => void
onChanged?: () => void onChanged?: () => void
}) { }) {
const [loading, setLoading] = useState(false) const carreraId = carrera?.id ?? ""
const [criterios, setCriterios] = useState<Array<{ const { data: criterios = [], isFetching } = useQuery({
id: number ...criteriosOptions(carreraId || "noop"),
nombre: string enabled: !!carreraId,
descripcion: string | null })
tipo: string | null
obligatorio: boolean | null
referencia_documento: string | null
fecha_creacion: string | null
}>>([])
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [newCritOpen, setNewCritOpen] = useState(false) const [newCritOpen, setNewCritOpen] = useState(false)
async function load() {
if (!carrera) return
setLoading(true)
const { data, error } = await supabase
.from("criterios_carrera")
.select("id, nombre, descripcion, tipo, obligatorio, referencia_documento, fecha_creacion")
.eq("carrera_id", carrera.id)
.order("fecha_creacion", { ascending: true })
setLoading(false)
if (error) { alert(error.message); return }
setCriterios(data ?? [])
}
useEffect(() => { load() /* eslint-disable-next-line */ }, [carrera?.id])
const filtered = useMemo(() => { const filtered = useMemo(() => {
const t = q.trim().toLowerCase() const t = q.trim().toLowerCase()
if (!t) return criterios if (!t) return criterios
return criterios.filter(c => return criterios.filter((c) =>
[c.nombre, c.descripcion, c.tipo, c.referencia_documento] [c.nombre, c.descripcion, c.tipo, c.referencia_documento].filter(Boolean).some((v) => String(v).toLowerCase().includes(t))
.filter(Boolean)
.some(v => String(v).toLowerCase().includes(t)),
) )
}, [q, criterios]) }, [q, criterios])
@@ -371,9 +444,10 @@ function CarreraDetailDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>{carrera?.nombre}</DialogTitle> <DialogTitle>{carrera?.nombre}</DialogTitle>
<DialogDescription> <DialogDescription>
{carrera?.facultades?.nombre ?? "—"} · {carrera?.semestres} semestres{" "} {carrera?.facultades?.nombre ?? "—"} · {carrera?.semestres} semestres {typeof carrera?.activo === "boolean" && (
{typeof carrera?.activo === "boolean" && ( <Badge variant="outline" className="ml-2">
<Badge variant="outline" className="ml-2">{carrera?.activo ? "Activa" : "Inactiva"}</Badge> {carrera?.activo ? "Activa" : "Inactiva"}
</Badge>
)} )}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -382,25 +456,18 @@ function CarreraDetailDialog({
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" /> <Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar criterio por nombre, tipo o referencia…" className="pl-8" />
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Buscar criterio por nombre, tipo o referencia…"
className="pl-8"
/>
</div> </div>
<Button onClick={() => setNewCritOpen(true)}> <Button onClick={() => setNewCritOpen(true)}>
<Icons.Plus className="h-4 w-4 mr-2" /> Añadir criterio <Icons.Plus className="h-4 w-4 mr-2" /> Añadir criterio
</Button> </Button>
</div> </div>
{loading ? ( {isFetching ? (
<div className="text-sm text-neutral-500">Cargando criterios</div> <div className="text-sm text-neutral-500">Cargando criterios</div>
) : ( ) : (
<> <>
<div className="text-xs text-neutral-600"> <div className="text-xs text-neutral-600">{filtered.length} criterio(s){q ? " (filtrado)" : ""}</div>
{filtered.length} criterio(s){q ? " (filtrado)" : ""}
</div>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="text-sm text-neutral-500 py-6 text-center">No hay criterios</div> <div className="text-sm text-neutral-500 py-6 text-center">No hay criterios</div>
@@ -453,7 +520,7 @@ function CarreraDetailDialog({
open={newCritOpen} open={newCritOpen}
onOpenChange={setNewCritOpen} onOpenChange={setNewCritOpen}
carreraId={carrera?.id ?? ""} carreraId={carrera?.id ?? ""}
onSaved={() => { setNewCritOpen(false); load(); onChanged?.() }} onSaved={onChanged}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -462,13 +529,17 @@ function CarreraDetailDialog({
/* -------------------- Form crear criterio -------------------- */ /* -------------------- Form crear criterio -------------------- */
function CriterioFormDialog({ function CriterioFormDialog({
open, onOpenChange, carreraId, onSaved, open,
onOpenChange,
carreraId,
onSaved,
}: { }: {
open: boolean open: boolean
onOpenChange: (o: boolean) => void onOpenChange: (o: boolean) => void
carreraId: string carreraId: string
onSaved?: () => void onSaved?: () => void
}) { }) {
const qc = useQueryClient()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [nombre, setNombre] = useState("") const [nombre, setNombre] = useState("")
const [tipo, setTipo] = useState<string>("") const [tipo, setTipo] = useState<string>("")
@@ -478,24 +549,38 @@ function CriterioFormDialog({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setNombre(""); setTipo(""); setDescripcion(""); setObligatorio(true); setReferencia("") setNombre("")
setTipo("")
setDescripcion("")
setObligatorio(true)
setReferencia("")
} }
}, [open]) }, [open])
async function save() { async function save() {
if (!carreraId) return if (!carreraId) return
if (!nombre.trim()) { alert("Escribe un nombre"); return } if (!nombre.trim()) {
alert("Escribe un nombre")
return
}
setSaving(true) setSaving(true)
const { error } = await supabase.from("criterios_carrera").insert([{ const { error } = await supabase.from("criterios_carrera").insert([
{
nombre: nombre.trim(), nombre: nombre.trim(),
tipo: tipo || null, tipo: tipo || null,
descripcion: descripcion || null, descripcion: descripcion || null,
obligatorio, obligatorio,
referencia_documento: referencia || null, referencia_documento: referencia || null,
carrera_id: carreraId, carrera_id: carreraId,
}]) },
])
setSaving(false) setSaving(false)
if (error) { alert(error.message); return } if (error) {
alert(error.message)
return
}
onOpenChange(false)
await qc.invalidateQueries({ queryKey: criteriosKeys.byCarrera(carreraId) })
onSaved?.() onSaved?.()
} }
@@ -536,8 +621,12 @@ function CriterioFormDialog({
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button> <Button variant="outline" onClick={() => onOpenChange(false)}>
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : "Crear"}</Button> Cancelar
</Button>
<Button onClick={save} disabled={saving}>
{saving ? "Guardando…" : "Crear"}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -1,4 +1,5 @@
import { createFileRoute, Link, useRouter } from '@tanstack/react-router' import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { useSuspenseQuery, queryOptions, useQueryClient } from '@tanstack/react-query'
import { supabase, useSupabaseAuth } from '@/auth/supabase' import { supabase, useSupabaseAuth } from '@/auth/supabase'
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
@@ -18,6 +19,7 @@ type Plan = {
sistema_evaluacion: string | null sistema_evaluacion: string | null
total_creditos: number | null total_creditos: number | null
} }
type Asignatura = { type Asignatura = {
id: string id: string
nombre: string nombre: string
@@ -34,14 +36,14 @@ type LoaderData = {
recientes: Array<{ tipo: 'plan' | 'asignatura'; id: string; nombre: string; fecha: string | null }> recientes: Array<{ tipo: 'plan' | 'asignatura'; id: string; nombre: string; fecha: string | null }>
} }
/* ========= Loader ========= */ /* ========= Query Key & Fetcher ========= */
export const Route = createFileRoute('/_authenticated/dashboard')({ const dashboardKeys = {
component: RouteComponent, root: ['dashboard'] as const,
pendingComponent: DashboardSkeleton, summary: () => [...dashboardKeys.root, 'summary'] as const,
loader: async (): Promise<LoaderData> => { }
// KPI counts
const [{ count: facCount }, { count: carCount }, { data: planesRaw }, { data: asignRaw }] = async function fetchDashboard(): Promise<LoaderData> {
await Promise.all([ const [facRes, carRes, planesRes, asigRes] = await Promise.all([
supabase.from('facultades').select('*', { count: 'exact', head: true }), supabase.from('facultades').select('*', { count: 'exact', head: true }),
supabase.from('carreras').select('*', { count: 'exact', head: true }), supabase.from('carreras').select('*', { count: 'exact', head: true }),
supabase supabase
@@ -51,11 +53,11 @@ export const Route = createFileRoute('/_authenticated/dashboard')({
), ),
supabase supabase
.from('asignaturas') .from('asignaturas')
.select('id, nombre, fecha_creacion, contenidos, criterios_evaluacion, bibliografia') .select('id, nombre, fecha_creacion, contenidos, criterios_evaluacion, bibliografia'),
]) ])
const planes = (planesRaw ?? []) as Plan[] const planes = (planesRes.data ?? []) as Plan[]
const asignaturas = (asignRaw ?? []) as Asignatura[] const asignaturas = (asigRes.data ?? []) as Asignatura[]
// Calidad de planes // Calidad de planes
const needed: (keyof Plan)[] = [ const needed: (keyof Plan)[] = [
@@ -63,49 +65,57 @@ export const Route = createFileRoute('/_authenticated/dashboard')({
'perfil_ingreso', 'perfil_ingreso',
'perfil_egreso', 'perfil_egreso',
'sistema_evaluacion', 'sistema_evaluacion',
'total_creditos' 'total_creditos',
] ]
const completos = planes.filter(p => const completos = planes.filter((p) => needed.every((k) => p[k] !== null && String(p[k] ?? '').trim() !== '')).length
needed.every(k => p[k] !== null && String(p[k] ?? '').toString().trim() !== '')
).length
const calidadPlanesPct = planes.length ? Math.round((completos / planes.length) * 100) : 0 const calidadPlanesPct = planes.length ? Math.round((completos / planes.length) * 100) : 0
// Salud de asignaturas // Salud de asignaturas
const sinBibliografia = asignaturas.filter( const sinBibliografia = asignaturas.filter(
a => !a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0) (a) => !a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0)
).length ).length
const sinCriterios = asignaturas.filter(a => !a.criterios_evaluacion?.trim()).length const sinCriterios = asignaturas.filter((a) => !a.criterios_evaluacion?.trim()).length
const sinContenidos = asignaturas.filter( const sinContenidos = asignaturas.filter(
a => !a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0) (a) => !a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0)
).length ).length
// Actividad reciente (últimos 8 ítems) // Actividad reciente (últimos 8 ítems)
const recientes = [ const recientes = [
...planes.map(p => ({ tipo: 'plan' as const, id: p.id, nombre: p.nombre, fecha: p.fecha_creacion })), ...planes.map((p) => ({ tipo: 'plan' as const, id: p.id, nombre: p.nombre, fecha: p.fecha_creacion })),
...asignaturas.map(a => ({ tipo: 'asignatura' as const, id: a.id, nombre: a.nombre, fecha: a.fecha_creacion })) ...asignaturas.map((a) => ({ tipo: 'asignatura' as const, id: a.id, nombre: a.nombre, fecha: a.fecha_creacion })),
] ]
.sort((a, b) => new Date(b.fecha ?? 0).getTime() - new Date(a.fecha ?? 0).getTime()) .sort((a, b) => new Date(b.fecha ?? 0).getTime() - new Date(a.fecha ?? 0).getTime())
.slice(0, 8) .slice(0, 8)
return { return {
kpis: { kpis: {
facultades: facCount ?? 0, facultades: facRes.count ?? 0,
carreras: carCount ?? 0, carreras: carRes.count ?? 0,
planes: planes.length, planes: planes.length,
asignaturas: asignaturas.length asignaturas: asignaturas.length,
}, },
calidadPlanesPct, calidadPlanesPct,
saludAsignaturas: { sinBibliografia, sinCriterios, sinContenidos }, saludAsignaturas: { sinBibliografia, sinCriterios, sinContenidos },
recientes recientes,
}
} }
}
const dashboardOptions = () =>
queryOptions({ queryKey: dashboardKeys.summary(), queryFn: fetchDashboard, staleTime: 30_000 })
/* ========= Ruta ========= */
export const Route = createFileRoute('/_authenticated/dashboard')({
component: RouteComponent,
pendingComponent: DashboardSkeleton,
loader: async ({ context: { queryClient } }) => {
await queryClient.ensureQueryData(dashboardOptions())
return null
},
}) })
/* ========= Helpers visuales ========= */ /* ========= Helpers visuales ========= */
function gradient(bg = '#2563eb') { function gradient(bg = '#2563eb') {
return { return { background: `linear-gradient(135deg, ${bg} 0%, ${bg}cc 45%, ${bg}a6 75%, ${bg}66 100%)` } as React.CSSProperties
background: `linear-gradient(135deg, ${bg} 0%, ${bg}cc 45%, ${bg}a6 75%, ${bg}66 100%)`
} as React.CSSProperties
} }
function hex(color?: string | null, fallback = '#2563eb') { function hex(color?: string | null, fallback = '#2563eb') {
return color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color) ? color : fallback return color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color) ? color : fallback
@@ -139,22 +149,9 @@ function Ring({ pct, color }: { pct: number; color: string }) {
) )
} }
function Tile({ function Tile({ to, label, value, Icon }: { to: string; label: string; value: number | string; Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> }) {
to,
label,
value,
Icon
}: {
to: string
label: string
value: number | string
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
}) {
return ( return (
<Link <Link to={to} className="group rounded-2xl ring-1 ring-black/5 bg-white/80 dark:bg-neutral-900/60 p-5 flex items-center justify-between hover:-translate-y-0.5 transition-all">
to={to}
className="group rounded-2xl ring-1 ring-black/5 bg-white/80 dark:bg-neutral-900/60 p-5 flex items-center justify-between hover:-translate-y-0.5 transition-all"
>
<div> <div>
<div className="text-sm text-neutral-500">{label}</div> <div className="text-sm text-neutral-500">{label}</div>
<div className="text-3xl font-bold tabular-nums">{value}</div> <div className="text-3xl font-bold tabular-nums">{value}</div>
@@ -168,21 +165,18 @@ function Tile({
/* ========= Página ========= */ /* ========= Página ========= */
function RouteComponent() { function RouteComponent() {
const { kpis, calidadPlanesPct, saludAsignaturas, recientes } = Route.useLoaderData() as LoaderData const { data } = useSuspenseQuery(dashboardOptions())
const { kpis, calidadPlanesPct, saludAsignaturas, recientes } = data
const auth = useSupabaseAuth() const auth = useSupabaseAuth()
const router = useRouter() const router = useRouter()
const primary = hex(auth.claims?.facultad_color, '#1d4ed8') // si guardan color de facultad en claims const qc = useQueryClient()
const primary = hex(auth.claims?.facultad_color, '#1d4ed8')
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?.claims_admin
const role = auth.claims?.role as const role = auth.claims?.role as 'lci' | 'vicerrectoria' | 'secretario_academico' | 'jefe_carrera' | 'planeacion' | undefined
| 'lci'
| 'vicerrectoria'
| 'secretario_academico'
| 'jefe_carrera'
| 'planeacion'
| undefined
// Mensaje contextual // Mensaje contextual
const roleHint = useMemo(() => { const roleHint = useMemo(() => {
@@ -204,10 +198,7 @@ function RouteComponent() {
<div className="p-6 space-y-8"> <div className="p-6 space-y-8">
{/* Header con saludo y búsqueda global */} {/* Header con saludo y búsqueda global */}
<div className="relative rounded-3xl overflow-hidden shadow-xl text-white" style={gradient(primary)}> <div className="relative rounded-3xl overflow-hidden shadow-xl text-white" style={gradient(primary)}>
<div <div className="absolute inset-0 opacity-25" style={{ background: 'radial-gradient(800px 300px at 20% -10%, #fff, transparent 60%)' }} />
className="absolute inset-0 opacity-25"
style={{ background: 'radial-gradient(800px 300px at 20% -10%, #fff, transparent 60%)' }}
/>
<div className="relative p-6 md:p-8 flex flex-col gap-5"> <div className="relative p-6 md:p-8 flex flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
@@ -215,7 +206,11 @@ function RouteComponent() {
<p className="opacity-95">{roleHint}</p> <p className="opacity-95">{roleHint}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{role && <Badge variant="secondary" className="bg-white/20 text-white border-white/30">{role}</Badge>} {role && (
<Badge variant="secondary" className="bg-white/20 text-white border-white/30">
{role}
</Badge>
)}
{isAdmin && ( {isAdmin && (
<Badge className="bg-white/20 text-white border-white/30 flex items-center gap-1"> <Badge className="bg-white/20 text-white border-white/30 flex items-center gap-1">
<Icons.ShieldCheck className="w-3.5 h-3.5" /> admin <Icons.ShieldCheck className="w-3.5 h-3.5" /> admin
@@ -228,7 +223,7 @@ function RouteComponent() {
<Input <Input
placeholder="Buscar planes, asignaturas o personas… (Enter)" placeholder="Buscar planes, asignaturas o personas… (Enter)"
className="bg-white/90 text-neutral-800 placeholder:text-neutral-400" className="bg-white/90 text-neutral-800 placeholder:text-neutral-400"
onKeyDown={e => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
const q = (e.target as HTMLInputElement).value.trim() const q = (e.target as HTMLInputElement).value.trim()
if (!q) return if (!q) return
@@ -239,7 +234,10 @@ function RouteComponent() {
<Button <Button
variant="secondary" variant="secondary"
className="bg-white/20 text-white hover:bg-white/30 border-white/30" className="bg-white/20 text-white hover:bg-white/30 border-white/30"
onClick={() => router.invalidate()} onClick={async () => {
await qc.invalidateQueries({ queryKey: dashboardKeys.root })
router.invalidate()
}}
title="Actualizar" title="Actualizar"
> >
<Icons.RefreshCcw className="w-4 h-4" /> <Icons.RefreshCcw className="w-4 h-4" />
@@ -248,23 +246,18 @@ function RouteComponent() {
{/* Atajos rápidos (según rol) */} {/* Atajos rápidos (según rol) */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Link <Link to="/planes" className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30">
to="/planes"
className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30"
>
<Icons.ScrollText className="w-4 h-4" /> Nuevo plan <Icons.ScrollText className="w-4 h-4" /> Nuevo plan
</Link> </Link>
<Link <Link
to="/asignaturas" to="/asignaturas"
search={{ carreraId: '', f: '', facultadId: '', planId: '', q: '' }} search={{ carreraId: '', f: '', facultadId: '', planId: '', q: '' }}
className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30"> className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30"
>
<Icons.BookOpen className="w-4 h-4" /> Nueva asignatura <Icons.BookOpen className="w-4 h-4" /> Nueva asignatura
</Link> </Link>
{isAdmin && ( {isAdmin && (
<Link <Link to="/usuarios" className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30">
to="/usuarios"
className="inline-flex items-center gap-2 rounded-xl bg-white/20 border border-white/30 px-3 py-1.5 text-sm hover:bg-white/30"
>
<Icons.UserPlus className="w-4 h-4" /> Invitar usuario <Icons.UserPlus className="w-4 h-4" /> Invitar usuario
</Link> </Link>
)} )}
@@ -303,21 +296,9 @@ function RouteComponent() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<HealthRow <HealthRow to="/_authenticated/asignaturas?f=sinBibliografia" label="Sin bibliografía" value={saludAsignaturas.sinBibliografia} />
to="/_authenticated/asignaturas?f=sinBibliografia" <HealthRow to="/_authenticated/asignaturas?f=sinCriterios" label="Sin criterios de evaluación" value={saludAsignaturas.sinCriterios} />
label="Sin bibliografía" <HealthRow to="/_authenticated/asignaturas?f=sinContenidos" label="Sin contenidos" value={saludAsignaturas.sinContenidos} />
value={saludAsignaturas.sinBibliografia}
/>
<HealthRow
to="/_authenticated/asignaturas?f=sinCriterios"
label="Sin criterios de evaluación"
value={saludAsignaturas.sinCriterios}
/>
<HealthRow
to="/_authenticated/asignaturas?f=sinContenidos"
label="Sin contenidos"
value={saludAsignaturas.sinContenidos}
/>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -330,11 +311,9 @@ function RouteComponent() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{recientes.length === 0 && ( {recientes.length === 0 && <div className="text-sm text-neutral-500">Sin actividad registrada.</div>}
<div className="text-sm text-neutral-500">Sin actividad registrada.</div>
)}
<ul className="divide-y"> <ul className="divide-y">
{recientes.map(r => ( {recientes.map((r) => (
<li key={`${r.tipo}-${r.id}`} className="py-2 flex items-center justify-between gap-3"> <li key={`${r.tipo}-${r.id}`} className="py-2 flex items-center justify-between gap-3">
<Link <Link
to={r.tipo === 'plan' ? '/plan/$planId' : '/asignatura/$asignaturaId'} to={r.tipo === 'plan' ? '/plan/$planId' : '/asignatura/$asignaturaId'}
@@ -349,9 +328,7 @@ function RouteComponent() {
)} )}
<span className="truncate">{r.nombre}</span> <span className="truncate">{r.nombre}</span>
</Link> </Link>
<span className="text-xs text-neutral-500"> <span className="text-xs text-neutral-500">{r.fecha ? new Date(r.fecha).toLocaleDateString() : ''}</span>
{r.fecha ? new Date(r.fecha).toLocaleDateString() : ''}
</span>
</li> </li>
))} ))}
</ul> </ul>
@@ -367,9 +344,8 @@ function HealthRow({ label, value, to }: { label: string; value: number; to: str
return ( return (
<Link <Link
to={to} to={to}
className={`flex items-center justify-between rounded-xl px-4 py-3 ring-1 ${warn className={`flex items-center justify-between rounded-xl px-4 py-3 ring-1 ${
? 'ring-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100' warn ? 'ring-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100' : 'ring-neutral-200 hover:bg-neutral-50'
: 'ring-neutral-200 hover:bg-neutral-50'
} transition-colors`} } transition-colors`}
> >
<span className="text-sm">{label}</span> <span className="text-sm">{label}</span>

View File

@@ -1,7 +1,8 @@
import { createFileRoute, Link, useRouter } from '@tanstack/react-router' import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useSuspenseQuery, useMutation, useQueryClient, queryOptions } from '@tanstack/react-query'
import * as Icons from 'lucide-react' import * as Icons from 'lucide-react'
import { supabase } from '@/auth/supabase' import { supabase } from '@/auth/supabase'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -9,25 +10,38 @@ 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 { toast } from 'sonner' import { toast } from 'sonner'
type Facultad = { /* -------------------- Tipos -------------------- */
export type Facultad = {
id: string id: string
nombre: string nombre: string
icon: string icon: string
color?: string | null color?: string | null
} }
export const Route = createFileRoute('/_authenticated/facultades')({ /* -------------------- Query Keys & Fetchers -------------------- */
component: RouteComponent, const facultadKeys = {
loader: async () => { root: ['facultades'] as const,
all: () => [...facultadKeys.root, 'all'] as const,
}
async function fetchFacultades(): Promise<Facultad[]> {
const { data, error } = await supabase const { data, error } = await supabase
.from('facultades') .from('facultades')
.select('id, nombre, icon, color') .select('id, nombre, icon, color')
.order('nombre') .order('nombre')
if (error) { if (error) throw error
console.error(error) return (data ?? []) as Facultad[]
return { facultades: [] as Facultad[] } }
}
return { facultades: (data ?? []) as Facultad[] } const facultadesOptions = () =>
queryOptions({ queryKey: facultadKeys.all(), queryFn: fetchFacultades, staleTime: 60_000 })
/* -------------------- Ruta -------------------- */
export const Route = createFileRoute('/_authenticated/facultades')({
component: RouteComponent,
loader: async ({ context: { queryClient } }) => {
await queryClient.ensureQueryData(facultadesOptions())
return null
}, },
}) })
@@ -54,28 +68,59 @@ const PALETTE: { name: string; hex: `#${string}` }[] = [
/* Un set corto y útil de íconos Lucide */ /* Un set corto y útil de íconos Lucide */
const ICON_CHOICES = [ const ICON_CHOICES = [
'Building2', 'Building', 'School', 'University', 'Landmark', 'Library', 'Layers', 'Building2', 'Building', 'School', 'University', 'Landmark', 'Library', 'Layers',
'Atom', 'FlaskConical', 'Microscope', 'Cpu', 'Hammer', 'Palette', 'Shapes', 'BookOpen', 'GraduationCap' 'Atom', 'FlaskConical', 'Microscope', 'Cpu', 'Hammer', 'Palette', 'Shapes', 'BookOpen', 'GraduationCap',
] as const ] as const
type IconName = typeof ICON_CHOICES[number] export type IconName = (typeof ICON_CHOICES)[number]
function gradientFrom(color?: string | null) { function gradientFrom(color?: string | null) {
const base = (color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color)) ? color : '#2563eb' const base = color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color) ? color : '#2563eb'
return `linear-gradient(135deg, ${base} 0%, ${base}CC 40%, ${base}99 70%, ${base}66 100%)` return `linear-gradient(135deg, ${base} 0%, ${base}CC 40%, ${base}99 70%, ${base}66 100%)`
} }
/* -------------------- Página -------------------- */
function RouteComponent() { function RouteComponent() {
const { facultades } = Route.useLoaderData() as { facultades: Facultad[] }
const router = useRouter() const router = useRouter()
const qc = useQueryClient()
const { data: facultades } = useSuspenseQuery(facultadesOptions())
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false) const [editOpen, setEditOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<{ nombre: string; icon: IconName; color: `#${string}` }>( const [form, setForm] = useState<{ nombre: string; icon: IconName; color: `#${string}` }>(
{ nombre: '', icon: 'Building2', color: '#2563EB' } { nombre: '', icon: 'Building2', color: '#2563EB' },
) )
const [editing, setEditing] = useState<Facultad | null>(null) const [editing, setEditing] = useState<Facultad | null>(null)
/* --------- Mutations (create / update) --------- */
const createFacultad = useMutation({
mutationFn: async (payload: { nombre: string; icon: IconName; color: `#${string}` }) => {
const { error } = await supabase.from('facultades').insert(payload)
if (error) throw new Error(error.message)
},
onSuccess: async () => {
toast.success('Facultad creada ✨')
setCreateOpen(false)
await qc.invalidateQueries({ queryKey: facultadKeys.root })
router.invalidate()
},
onError: (e: any) => toast.error(e?.message || 'No se pudo crear'),
})
const updateFacultad = useMutation({
mutationFn: async ({ id, ...payload }: { id: string; nombre: string; icon: IconName; color: `#${string}` }) => {
const { error } = await supabase.from('facultades').update(payload).eq('id', id)
if (error) throw new Error(error.message)
},
onSuccess: async () => {
toast.success('Cambios guardados ✅')
setEditOpen(false)
setEditing(null)
await qc.invalidateQueries({ queryKey: facultadKeys.root })
router.invalidate()
},
onError: (e: any) => toast.error(e?.message || 'No se pudo guardar'),
})
function openCreate() { function openCreate() {
setForm({ nombre: '', icon: 'Building2', color: '#2563EB' }) setForm({ nombre: '', icon: 'Building2', color: '#2563EB' })
setCreateOpen(true) setCreateOpen(true)
@@ -84,39 +129,12 @@ function RouteComponent() {
setEditing(f) setEditing(f)
setForm({ setForm({
nombre: f.nombre, nombre: f.nombre,
icon: (ICON_CHOICES.includes(f.icon as IconName) ? f.icon : 'Building2') as IconName, icon: (ICON_CHOICES.includes(f.icon as IconName) ? (f.icon as IconName) : 'Building2'),
color: ((f.color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(f.color)) ? (f.color as `#${string}`) : '#2563EB') color: f.color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(f.color) ? (f.color as `#${string}`) : '#2563EB',
}) })
setEditOpen(true) setEditOpen(true)
} }
async function doCreate() {
if (!form.nombre.trim()) { toast.error('Nombre requerido'); return }
setSaving(true)
const { error } = await supabase.from('facultades')
.insert({ nombre: form.nombre.trim(), icon: form.icon, color: form.color })
setSaving(false)
if (error) { console.error(error); toast.error('No se pudo crear'); return }
toast.success('Facultad creada ✨')
setCreateOpen(false)
router.invalidate()
}
async function doEdit() {
if (!editing) return
if (!form.nombre.trim()) { toast.error('Nombre requerido'); return }
setSaving(true)
const { error } = await supabase.from('facultades')
.update({ nombre: form.nombre.trim(), icon: form.icon, color: form.color })
.eq('id', editing.id)
setSaving(false)
if (error) { console.error(error); toast.error('No se pudo guardar'); return }
toast.success('Cambios guardados ✅')
setEditOpen(false)
setEditing(null)
router.invalidate()
}
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
{/* Header */} {/* Header */}
@@ -125,7 +143,13 @@ function RouteComponent() {
<Icons.Building2 className="w-5 h-5" /> Facultades <Icons.Building2 className="w-5 h-5" /> Facultades
</h1> </h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" onClick={() => router.invalidate()}> <Button
variant="outline"
onClick={async () => {
await qc.invalidateQueries({ queryKey: facultadKeys.root })
router.invalidate()
}}
>
<Icons.RefreshCcw className="w-4 h-4 mr-2" /> Recargar <Icons.RefreshCcw className="w-4 h-4 mr-2" /> Recargar
</Button> </Button>
<Button onClick={openCreate}> <Button onClick={openCreate}>
@@ -138,8 +162,7 @@ function RouteComponent() {
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{facultades.map((fac) => { {facultades.map((fac) => {
const LucideIcon = (Icons as any)[fac.icon] || Icons.Building2 const LucideIcon = (Icons as any)[fac.icon] || Icons.Building2
const bg = { background: gradientFrom(fac.color) } // ← sin useMemo aquí const bg = { background: gradientFrom(fac.color) }
return ( return (
<div key={fac.id} className="group relative rounded-3xl overflow-hidden shadow-xl border"> <div key={fac.id} className="group relative rounded-3xl overflow-hidden shadow-xl border">
<Link <Link
@@ -163,12 +186,7 @@ function RouteComponent() {
</Link> </Link>
<div className="absolute top-3 right-3"> <div className="absolute top-3 right-3">
<Button <Button size="icon" variant="secondary" className="backdrop-blur bg-white/80 hover:bg-white" onClick={() => openEdit(fac)}>
size="icon"
variant="secondary"
className="backdrop-blur bg-white/80 hover:bg-white"
onClick={() => openEdit(fac)}
>
<Icons.Pencil className="w-4 h-4" /> <Icons.Pencil className="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -177,7 +195,6 @@ function RouteComponent() {
})} })}
</div> </div>
{/* Dialog Crear */} {/* Dialog Crear */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
@@ -189,7 +206,15 @@ function RouteComponent() {
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button> <Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
<Button onClick={doCreate} disabled={saving}>{saving ? 'Guardando…' : 'Crear'}</Button> <Button
onClick={() => {
if (!form.nombre.trim()) { toast.error('Nombre requerido'); return }
createFacultad.mutate({ nombre: form.nombre.trim(), icon: form.icon, color: form.color })
}}
disabled={createFacultad.isPending}
>
{createFacultad.isPending ? 'Guardando…' : 'Crear'}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -205,7 +230,16 @@ function RouteComponent() {
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => { setEditOpen(false); setEditing(null) }}>Cancelar</Button> <Button variant="outline" onClick={() => { setEditOpen(false); setEditing(null) }}>Cancelar</Button>
<Button onClick={doEdit} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button> <Button
onClick={() => {
if (!editing) return
if (!form.nombre.trim()) { toast.error('Nombre requerido'); return }
updateFacultad.mutate({ id: editing.id, nombre: form.nombre.trim(), icon: form.icon, color: form.color })
}}
disabled={updateFacultad.isPending}
>
{updateFacultad.isPending ? 'Guardando…' : 'Guardar'}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -215,7 +249,8 @@ function RouteComponent() {
/* ----------- Subcomponentes ----------- */ /* ----------- Subcomponentes ----------- */
function FormFields({ function FormFields({
form, setForm form,
setForm,
}: { }: {
form: { nombre: string; icon: IconName; color: `#${string}` } form: { nombre: string; icon: IconName; color: `#${string}` }
setForm: React.Dispatch<React.SetStateAction<{ nombre: string; icon: IconName; color: `#${string}` }>> setForm: React.Dispatch<React.SetStateAction<{ nombre: string; icon: IconName; color: `#${string}` }>>
@@ -236,24 +271,22 @@ function FormFields({
{/* Nombre */} {/* Nombre */}
<div className="space-y-1"> <div className="space-y-1">
<Label>Nombre</Label> <Label>Nombre</Label>
<Input <Input value={form.nombre} onChange={(e) => setForm((s) => ({ ...s, nombre: e.target.value }))} placeholder="Facultad de Ingeniería" />
value={form.nombre}
onChange={(e) => setForm(s => ({ ...s, nombre: e.target.value }))}
placeholder="Facultad de Ingeniería"
/>
</div> </div>
{/* Icono */} {/* Icono */}
<div className="space-y-1"> <div className="space-y-1">
<Label>Ícono</Label> <Label>Ícono</Label>
<Select value={form.icon} onValueChange={(v) => setForm(s => ({ ...s, icon: v as IconName }))}> <Select value={form.icon} onValueChange={(v) => setForm((s) => ({ ...s, icon: v as IconName }))}>
<SelectTrigger><SelectValue placeholder="Selecciona ícono" /></SelectTrigger> <SelectTrigger><SelectValue placeholder="Selecciona ícono" /></SelectTrigger>
<SelectContent className="max-h-72"> <SelectContent className="max-h-72">
{ICON_CHOICES.map(k => { {ICON_CHOICES.map((k) => {
const Ico = (Icons as any)[k] const Ico = (Icons as any)[k]
return ( return (
<SelectItem key={k} value={k}> <SelectItem key={k} value={k}>
<span className="inline-flex items-center gap-2"><Ico className="w-4 h-4" /> {k}</span> <span className="inline-flex items-center gap-2">
<Ico className="w-4 h-4" /> {k}
</span>
</SelectItem> </SelectItem>
) )
})} })}
@@ -264,10 +297,7 @@ function FormFields({
{/* Color (paleta curada) */} {/* Color (paleta curada) */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Color</Label> <Label>Color</Label>
<ColorGrid <ColorGrid value={form.color} onChange={(hex) => setForm((s) => ({ ...s, color: hex }))} />
value={form.color}
onChange={(hex) => setForm(s => ({ ...s, color: hex }))}
/>
</div> </div>
</div> </div>
) )
@@ -276,20 +306,17 @@ function FormFields({
function ColorGrid({ value, onChange }: { value: `#${string}`; onChange: (hex: `#${string}`) => void }) { function ColorGrid({ value, onChange }: { value: `#${string}`; onChange: (hex: `#${string}`) => void }) {
return ( return (
<div className="grid grid-cols-8 gap-2"> <div className="grid grid-cols-8 gap-2">
{PALETTE.map(c => ( {PALETTE.map((c) => (
<button <button
key={c.hex} key={c.hex}
type="button" type="button"
onClick={() => onChange(c.hex)} onClick={() => onChange(c.hex)}
className={`relative h-9 rounded-xl ring-1 ring-black/10 transition className={`relative h-9 rounded-xl ring-1 ring-black/10 transition ${value === c.hex ? 'outline outline-2 outline-offset-2 outline-black/70' : 'hover:scale-[1.03]'}`}
${value === c.hex ? 'outline outline-2 outline-offset-2 outline-black/70' : 'hover:scale-[1.03]'}`}
style={{ background: c.hex }} style={{ background: c.hex }}
title={c.name} title={c.name}
aria-label={c.name} aria-label={c.name}
> >
{value === c.hex && ( {value === c.hex && <Icons.Check className="absolute right-1.5 bottom-1.5 w-4 h-4 text-white drop-shadow" />}
<Icons.Check className="absolute right-1.5 bottom-1.5 w-4 h-4 text-white drop-shadow" />
)}
</button> </button>
))} ))}
</div> </div>

View File

@@ -1,24 +1,35 @@
import { createFileRoute, Link, useRouter } from '@tanstack/react-router' import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
import { supabase, useSupabaseAuth } from '@/auth/supabase' import { useEffect, useMemo, useRef, useState } from "react"
import * as Icons from 'lucide-react' import * as Icons from "lucide-react"
import { useEffect, useMemo, useRef, useState } from 'react' import { useQueryClient, useSuspenseQuery, useMutation } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { supabase, useSupabaseAuth } from "@/auth/supabase"
import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from '@/components/ui/button' import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { Button } from "@/components/ui/button"
import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
import { Input } from '@/components/ui/input' import { Label } from "@/components/ui/label"
import { Textarea } from '@/components/ui/textarea' import { Input } from "@/components/ui/input"
import gsap from 'gsap' import { Textarea } from "@/components/ui/textarea"
import { ScrollTrigger } from 'gsap/ScrollTrigger' import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs"
import { AcademicSections } from '@/components/planes/academic-sections' import { AcademicSections } from "@/components/planes/academic-sections"
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@radix-ui/react-tabs' import { AuroraButton } from "@/components/effect/aurora-button"
import confetti from 'canvas-confetti' import confetti from "canvas-confetti"
import { AuroraButton } from '@/components/effect/aurora-button' import gsap from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
gsap.registerPlugin(ScrollTrigger) gsap.registerPlugin(ScrollTrigger)
type PlanFull = { /* ================= Query Keys & Options ================= */
const planKeys = {
byId: (id: string) => ["plan", id] as const,
}
const asignaturaKeys = {
count: (planId: string) => ["asignaturas", "count", planId] as const,
preview: (planId: string) => ["asignaturas", "preview", planId] as const,
extra: (asigId: string) => ["asignatura", "extra", asigId] as const,
}
export type PlanFull = {
id: string; nombre: string; nivel: string | null; id: string; nombre: string; nivel: string | null;
objetivo_general: string | null; perfil_ingreso: string | null; perfil_egreso: string | null; objetivo_general: string | null; perfil_ingreso: string | null; perfil_egreso: string | null;
duracion: string | null; total_creditos: number | null; duracion: string | null; total_creditos: number | null;
@@ -28,44 +39,95 @@ type PlanFull = {
estado: string | null; fecha_creacion: string | null; estado: string | null; fecha_creacion: string | null;
carreras: { id: string; nombre: string; facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null } | null carreras: { id: string; nombre: string; facultades?: { id: string; nombre: string; color?: string | null; icon?: string | null } | null } | null
} }
type AsignaturaLite = { id: string; nombre: string; semestre: number | null; creditos: number | null } export type AsignaturaLite = { id: string; nombre: string; semestre: number | null; creditos: number | null }
type LoaderData = { plan: PlanFull; asignaturasCount: number; asignaturasPreview: AsignaturaLite[] }
/* ============== ROUTE ============== */ type LoaderData = { planId: string }
export const Route = createFileRoute('/_authenticated/plan/$planId')({
component: RouteComponent, /* ---------- Query option builders ---------- */
pendingComponent: PageSkeleton, function planByIdOptions(planId: string) {
loader: async ({ params }): Promise<LoaderData> => { return {
const { data: plan, error } = await supabase queryKey: planKeys.byId(planId),
.from('plan_estudios') queryFn: async (): Promise<PlanFull> => {
const { data, error } = await supabase
.from("plan_estudios")
.select(` .select(`
id, nombre, nivel, objetivo_general, perfil_ingreso, perfil_egreso, duracion, total_creditos, id, nombre, nivel, objetivo_general, perfil_ingreso, perfil_egreso, duracion, total_creditos,
competencias_genericas, competencias_especificas, sistema_evaluacion, indicadores_desempeno, competencias_genericas, competencias_especificas, sistema_evaluacion, indicadores_desempeno,
pertinencia, prompt, estado, fecha_creacion, pertinencia, prompt, estado, fecha_creacion,
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) ) carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
`) `)
.eq('id', params.planId) .eq("id", planId)
.single() .maybeSingle()
if (error || !plan) throw error ?? new Error('Plan no encontrado') if (error || !data) throw error ?? new Error("Plan no encontrado")
return data as unknown as PlanFull
const { count } = await supabase },
.from('asignaturas') staleTime: 60_000,
.select('*', { count: 'exact', head: true }) } as const
.eq('plan_id', params.planId) }
function asignaturasCountOptions(planId: string) {
const { data: asignaturasPreview } = await supabase
.from('asignaturas')
.select('id, nombre, semestre, creditos')
.eq('plan_id', params.planId)
.order('semestre', { ascending: true })
.order('nombre', { ascending: true })
.limit(8)
return { return {
plan: plan as unknown as PlanFull, queryKey: asignaturaKeys.count(planId),
asignaturasCount: count ?? 0, queryFn: async (): Promise<number> => {
asignaturasPreview: (asignaturasPreview ?? []) as AsignaturaLite[], const { count, error } = await supabase
} .from("asignaturas")
.select("*", { count: "exact", head: true })
.eq("plan_id", planId)
if (error) throw error
return count ?? 0
},
staleTime: 30_000,
} as const
}
function asignaturasPreviewOptions(planId: string) {
return {
queryKey: asignaturaKeys.preview(planId),
queryFn: async (): Promise<AsignaturaLite[]> => {
const { data, error } = await supabase
.from("asignaturas")
.select("id, nombre, semestre, creditos")
.eq("plan_id", planId)
.order("semestre", { ascending: true })
.order("nombre", { ascending: true })
.limit(8)
if (error) throw error
return (data ?? []) as unknown as AsignaturaLite[]
},
staleTime: 30_000,
} as const
}
function asignaturaExtraOptions(asigId: string) {
return {
queryKey: asignaturaKeys.extra(asigId),
queryFn: async (): Promise<{
tipo: string | null
horas_teoricas: number | null
horas_practicas: number | null
contenidos: Record<string, Record<string, string>> | null
} | null> => {
const { data, error } = await supabase
.from("asignaturas")
.select("tipo, horas_teoricas, horas_practicas, contenidos")
.eq("id", asigId)
.maybeSingle()
if (error) throw error
return (data as any) ?? null
},
} as const
}
/* ============== ROUTE ============== */
export const Route = createFileRoute("/_authenticated/plan/$planId")({
component: RouteComponent,
pendingComponent: PageSkeleton,
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
const { planId } = params
// Prefetch/ensure all queries needed for the page
await Promise.all([
queryClient.ensureQueryData(planByIdOptions(planId)),
queryClient.ensureQueryData(asignaturasCountOptions(planId)),
queryClient.ensureQueryData(asignaturasPreviewOptions(planId)),
])
return { planId }
}, },
}) })
@@ -129,12 +191,18 @@ function GradientMesh({ color }: { color?: string | null }) {
/* ============== PAGE ============== */ /* ============== PAGE ============== */
function RouteComponent() { function RouteComponent() {
const router = useRouter() const router = useRouter()
const { plan, asignaturasCount, asignaturasPreview } = Route.useLoaderData() as LoaderData const qc = useQueryClient()
const { planId } = Route.useLoaderData() as LoaderData
const auth = useSupabaseAuth() const auth = useSupabaseAuth()
// Fetch via React Query (suspense)
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'
const fac = plan.carreras?.facultades const fac = plan.carreras?.facultades
const accent = useMemo(() => softAccentStyle(fac?.color), [fac?.color]) const accent = useMemo(() => softAccentStyle(fac?.color), [fac?.color])
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2 const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
@@ -145,7 +213,6 @@ function RouteComponent() {
const fieldsRef = useRef<HTMLDivElement>(null) const fieldsRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
// Header intro
if (headerRef.current) { if (headerRef.current) {
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } }) const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
@@ -156,9 +223,7 @@ function RouteComponent() {
return () => ctx.revert() return () => ctx.revert()
} }
}, []) }, [])
useEffect(() => { useEffect(() => {
// Stats y campos con ScrollTrigger
if (statsRef.current) { if (statsRef.current) {
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
gsap.from('.academics', { gsap.from('.academics', {
@@ -182,11 +247,11 @@ function RouteComponent() {
return () => ctx.revert() return () => ctx.revert()
} }
}, []) }, [])
const facColor = plan.carreras?.facultades?.color ?? null const facColor = plan.carreras?.facultades?.color ?? null
return ( return (
<div className="relative p-6 space-y-6"> <div className="relative p-6 space-y-6">
{/* Mesh global */}
<GradientMesh color={fac?.color} /> <GradientMesh color={fac?.color} />
<nav className="relative text-sm text-neutral-500"> <nav className="relative text-sm text-neutral-500">
@@ -195,9 +260,8 @@ function RouteComponent() {
<span className="text-primary">{plan.nombre}</span> <span className="text-primary">{plan.nombre}</span>
</nav> </nav>
{/* Header con acciones y brillo */} {/* Header */}
<Card ref={headerRef} className="relative overflow-hidden border shadow-sm"> <Card ref={headerRef} className="relative overflow-hidden border shadow-sm">
{/* velo de color muy suave */}
<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 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">
@@ -228,37 +292,30 @@ function RouteComponent() {
</div> </div>
</CardHeader> </CardHeader>
{/* stats */} <CardContent ref={statsRef}>
<CardContent
ref={statsRef}
>
<div className="academics relative z-10 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]"> <div className="academics relative z-10 grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(180px,1fr))]">
<StatCard label="Nivel" value={plan.nivel ?? "—"} Icon={Icons.GraduationCap} accent={facColor} /> <StatCard label="Nivel" value={plan.nivel ?? "—"} Icon={Icons.GraduationCap} accent={facColor} />
<StatCard label="Duración" value={plan.duracion ?? "—"} Icon={Icons.Clock} accent={facColor} /> <StatCard label="Duración" value={plan.duracion ?? "—"} Icon={Icons.Clock} accent={facColor} />
<StatCard label="Créditos" value={fmt(plan.total_creditos)} Icon={Icons.Coins} accent={facColor} /> <StatCard label="Créditos" value={fmt(plan.total_creditos)} Icon={Icons.Coins} accent={facColor} />
<StatCard <StatCard label="Creado" value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : "—"} Icon={Icons.CalendarDays} accent={facColor} />
label="Creado"
value={plan.fecha_creacion ? new Date(plan.fecha_creacion).toLocaleDateString() : "—"}
Icon={Icons.CalendarDays}
accent={facColor}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<div className="academics"> <div className="academics">
<AcademicSections planId={plan.id} plan={plan} color={fac?.color} /> <AcademicSections planId={plan.id} color={fac?.color} />
</div> </div>
{/* ===== Asignaturas (preview cards) ===== */} {/* ===== Asignaturas (preview cards) ===== */}
<Card className="border shadow-sm"> <Card className="border shadow-sm">
<CardHeader className="flex items-center justify-between gap-2"> <CardHeader className="flex items-center justify-between gap-2">
<CardTitle className="text-base">Asignaturas ({asignaturasCount})</CardTitle> <CardTitle className="text-base">Asignaturas ({asignaturasCount})</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AddAsignaturaButton planId={plan.id} onAdded={() => router.invalidate()} /> <AddAsignaturaButton planId={plan.id} onAdded={() => {
qc.invalidateQueries({ queryKey: asignaturaKeys.count(plan.id) })
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(plan.id) })
}} />
<Link <Link
to="/asignaturas/$planId" to="/asignaturas/$planId"
search={{ q: "", planId: plan.id, carreraId: "", f: "", facultadId: "" }} search={{ q: "", planId: plan.id, carreraId: "", f: "", facultadId: "" }}
@@ -283,7 +340,6 @@ function RouteComponent() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) )
} }
@@ -298,48 +354,35 @@ function hexToRgbA(hex?: string | null, a = .25) {
} }
const fmt = (n?: number | null) => (n !== null && n !== undefined) ? Intl.NumberFormat().format(n) : "—" const fmt = (n?: number | null) => (n !== null && n !== undefined) ? Intl.NumberFormat().format(n) : "—"
/* ===== UI bits ===== */ /* ===== UI bits ===== */
type StatProps = { function StatCard({ label, value = "—", Icon = Icons.Info, accent, className = "", title }: {
label: string label: string
value?: React.ReactNode value?: React.ReactNode
Icon?: React.ComponentType<React.SVGProps<SVGSVGElement>> Icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>
accent?: string | null // color de facultad (hex) opcional accent?: string | null
className?: string className?: string
title?: string title?: string
} }) {
function StatCard({ label, value = "—", Icon = Icons.Info, accent, className = "", title }: StatProps) {
const border = hexToRgbA(accent, .28) const border = hexToRgbA(accent, .28)
const chipBg = hexToRgbA(accent, .08) const chipBg = hexToRgbA(accent, .08)
const glow = hexToRgbA(accent, .14) const glow = hexToRgbA(accent, .14)
return ( return (
<div <div
className={`group relative overflow-hidden rounded-2xl border p-4 sm:p-5 className={`group relative overflow-hidden rounded-2xl border p-4 sm:p-5 bg-white/70 dark:bg-neutral-900/60 backdrop-blur shadow-sm hover:shadow-md transition-all ${className}`}
bg-white/70 dark:bg-neutral-900/60 backdrop-blur
shadow-sm hover:shadow-md transition-all ${className}`}
style={{ borderColor: border }} style={{ borderColor: border }}
title={title ?? (typeof value === "string" ? value : undefined)} title={title ?? (typeof value === "string" ? value : undefined)}
aria-label={`${label}: ${typeof value === "string" ? value : ""}`} aria-label={`${label}: ${typeof value === "string" ? value : ""}`}
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="text-xs text-neutral-500">{label}</div> <div className="text-xs text-neutral-500">{label}</div>
<span <span className="inline-flex items-center justify-center rounded-xl px-2.5 py-2 border" style={{ borderColor: border, background: chipBg }}>
className="inline-flex items-center justify-center rounded-xl px-2.5 py-2 border"
style={{ borderColor: border, background: chipBg }}
>
<Icon className="h-4 w-4 opacity-80" /> <Icon className="h-4 w-4 opacity-80" />
</span> </span>
</div> </div>
<div className="mt-1 text-2xl font-semibold tabular-nums tracking-tight truncate">{value}</div>
<div className="mt-1 text-2xl font-semibold tabular-nums tracking-tight truncate"> <div className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ background: `radial-gradient(600px 120px at 20% -10%, ${glow}, transparent 60%)` }} />
{value}
</div>
{/* glow sutil en hover */}
<div
className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: `radial-gradient(600px 120px at 20% -10%, ${glow}, transparent 60%)` }}
/>
</div> </div>
) )
} }
@@ -349,17 +392,38 @@ function EditPlanButton({ plan }: { plan: PlanFull }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [form, setForm] = useState<Partial<PlanFull>>({}) const [form, setForm] = useState<Partial<PlanFull>>({})
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const qc = useQueryClient()
const mutation = useMutation({
mutationFn: async (payload: Partial<PlanFull>) => {
const { error } = await supabase.from('plan_estudios').update({
nombre: payload.nombre ?? plan.nombre,
nivel: payload.nivel ?? plan.nivel,
duracion: payload.duracion ?? plan.duracion,
total_creditos: payload.total_creditos ?? plan.total_creditos,
}).eq('id', plan.id)
if (error) throw error
},
onMutate: async (payload) => {
await qc.cancelQueries({ queryKey: planKeys.byId(plan.id) })
const prev = qc.getQueryData<PlanFull>(planKeys.byId(plan.id))
qc.setQueryData<PlanFull>(planKeys.byId(plan.id), (old) => old ? { ...old, ...payload } as PlanFull : old as any)
return { prev }
},
onError: (_e, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(planKeys.byId(plan.id), ctx.prev)
},
onSettled: async () => {
await qc.invalidateQueries({ queryKey: planKeys.byId(plan.id) })
}
})
async function save() { async function save() {
setSaving(true) setSaving(true)
const { error } = await supabase.from('plan_estudios').update({ try {
nombre: form.nombre ?? plan.nombre, await mutation.mutateAsync(form)
nivel: form.nivel ?? plan.nivel, setOpen(false)
duracion: form.duracion ?? plan.duracion, } finally { setSaving(false) }
total_creditos: form.total_creditos ?? plan.total_creditos,
}).eq('id', plan.id)
setSaving(false)
if (!error) setOpen(false)
} }
return ( return (
@@ -380,7 +444,7 @@ function EditPlanButton({ plan }: { plan: PlanFull }) {
<Field label="Créditos totales"><Input value={String(form.total_creditos ?? '')} onChange={(e) => setForm({ ...form, total_creditos: Number(e.target.value) || null })} /></Field> <Field label="Créditos totales"><Input value={String(form.total_creditos ?? '')} onChange={(e) => setForm({ ...form, total_creditos: Number(e.target.value) || null })} /></Field>
</div> </div>
<DialogFooter> <DialogFooter>
<Button onClick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button> <Button onClick={save} disabled={saving || mutation.isPending}>{(saving || mutation.isPending) ? 'Guardando…' : 'Guardar'}</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -410,7 +474,6 @@ function AdjustAIButton({ plan }: { plan: PlanFull }) {
}).catch(() => { }) }).catch(() => { })
setLoading(false) setLoading(false)
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } }) confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
setOpen(false) setOpen(false)
} }
@@ -463,20 +526,101 @@ function PageSkeleton() {
) )
} }
function AddAsignaturaButton({ /* ===== Asignaturas ===== */
planId, onAdded, function AsignaturaPreviewCard({ asignatura }: { asignatura: AsignaturaLite }) {
}: { planId: string; onAdded?: () => void }) { const { data: extra } = useSuspenseQuery(asignaturaExtraOptions(asignatura.id))
const horasT = extra?.horas_teoricas ?? null
const horasP = extra?.horas_practicas ?? null
const horasTot = (horasT ?? 0) + (horasP ?? 0)
const resumenContenidos = useMemo(() => {
const c = extra?.contenidos
if (!c) return { unidades: 0, temas: 0 }
const unidades = Object.keys(c).length
const temas = Object.values(c).reduce((acc, temasObj) => acc + Object.keys(temasObj || {}).length, 0)
return { unidades, temas }
}, [extra?.contenidos])
const tipo = (extra?.tipo ?? "").toLowerCase()
const tipoChip =
tipo.includes("oblig") ? "bg-emerald-50 text-emerald-700 border-emerald-200" :
tipo.includes("opt") ? "bg-amber-50 text-amber-800 border-amber-200" :
tipo.includes("taller") ? "bg-indigo-50 text-indigo-700 border-indigo-200" :
tipo.includes("lab") ? "bg-sky-50 text-sky-700 border-sky-200" :
"bg-neutral-100 text-neutral-700 border-neutral-200"
return (
<article className="group relative overflow-hidden rounded-2xl border bg-white/70 dark:bg-neutral-900/60 backdrop-blur p-4 shadow-sm hover:shadow-md transition-all hover:-translate-y-0.5" role="region" aria-label={asignatura.nombre}>
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
<Icons.BookOpen className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="font-medium truncate" title={asignatura.nombre}>{asignatura.nombre}</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
{asignatura.semestre != null && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
<Icons.Calendar className="h-3 w-3" /> S{asignatura.semestre}
</span>
)}
{asignatura.creditos != null && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
<Icons.Coins className="h-3 w-3" /> {asignatura.creditos} cr
</span>
)}
{extra?.tipo && (
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 ${tipoChip}`}>
<Icons.Tag className="h-3 w-3" /> {extra.tipo}
</span>
)}
</div>
</div>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px]">
<SmallStat icon={Icons.Clock} label="Horas" value={horasTot || "—"} />
<SmallStat icon={Icons.BookMarked} label="Unidades" value={resumenContenidos.unidades || "—"} />
<SmallStat icon={Icons.ListTree} label="Temas" value={resumenContenidos.temas || "—"} />
</div>
<div className="mt-3 flex items-center justify-between">
<div className="text-[11px] text-neutral-500">
{horasT != null || horasP != null ? (
<>H T/P: {horasT ?? "—"}/{horasP ?? "—"}</>
) : (
<span className="opacity-70">Resumen listo</span>
)}
</div>
<Link to="/asignatura/$asignaturaId" params={{ asignaturaId: asignatura.id }} className="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs hover:bg-neutral-50" title="Ver detalle">
Ver detalle <Icons.ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ background: "radial-gradient(600px 120px at 20% -10%, rgba(0,0,0,.06), transparent 60%)" }} />
</article>
)
}
function SmallStat({ icon: Icon, label, value }: { icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; label: string; value: string | number }) {
return (
<div className="rounded-lg border bg-white/60 dark:bg-neutral-900/50 px-2.5 py-2">
<div className="flex items-center gap-1 text-[10px] text-neutral-500">
<Icon className="h-3.5 w-3.5" /> {label}
</div>
<div className="mt-0.5 font-medium tabular-nums">{value}</div>
</div>
)
}
/* ===== Crear Asignatura ===== */
function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: () => void }) {
const qc = useQueryClient()
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")
// --- Manual --- const [f, setF] = useState({ nombre: "", clave: "", tipo: "", semestre: "", creditos: "", horas_teoricas: "", horas_practicas: "", objetivos: "" })
const [f, setF] = useState({
nombre: "", clave: "", tipo: "", semestre: "", creditos: "",
horas_teoricas: "", horas_practicas: "", objetivos: "",
})
// --- IA ---
const [iaPrompt, setIaPrompt] = useState("") const [iaPrompt, setIaPrompt] = useState("")
const [iaSemestre, setIaSemestre] = useState("") const [iaSemestre, setIaSemestre] = useState("")
@@ -505,7 +649,11 @@ function AddAsignaturaButton({
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); onAdded?.() setOpen(false)
onAdded?.()
// Warm up cache quickly
qc.invalidateQueries({ queryKey: asignaturaKeys.preview(planId) })
qc.invalidateQueries({ queryKey: asignaturaKeys.count(planId) })
} }
async function createWithAI() { async function createWithAI() {
@@ -515,22 +663,17 @@ function AddAsignaturaButton({
const res = await fetch("https://genesis-engine.apps.lci.ulsa.mx/api/generar/asignatura", { const res = await fetch("https://genesis-engine.apps.lci.ulsa.mx/api/generar/asignatura", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true }),
planEstudiosId: planId,
prompt: iaPrompt,
semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined,
insert: true, // que la API inserte en DB
}),
}) })
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.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 { } finally { setSaving(false) }
setSaving(false)
}
} }
const submit = () => (mode === "manual" ? createManual() : createWithAI()) const submit = () => (mode === "manual" ? createManual() : createWithAI())
@@ -548,86 +691,41 @@ function AddAsignaturaButton({
<DialogDescription>Elige cómo crearla: manual o generada por IA.</DialogDescription> <DialogDescription>Elige cómo crearla: manual o generada por IA.</DialogDescription>
</DialogHeader> </DialogHeader>
{/* Conmutador elegante */}
<Tabs value={mode} onValueChange={v => setMode(v as "manual" | "ia")} className="w-full"> <Tabs value={mode} onValueChange={v => setMode(v as "manual" | "ia")} className="w-full">
<TabsList <TabsList className="grid w-full grid-cols-2 rounded-xl border bg-neutral-50 p-1" aria-label="Modo de creación">
className="grid w-full grid-cols-2 rounded-xl border bg-neutral-50 p-1" <TabsTrigger value="manual" className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm">
aria-label="Modo de creación"
>
<TabsTrigger
value="manual"
className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm"
>
<Icons.PencilLine className="h-4 w-4 mr-2" /> Manual <Icons.PencilLine className="h-4 w-4 mr-2" /> Manual
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger value="ia" className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm">
value="ia"
className="rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-sm"
>
<Icons.Sparkles className="h-4 w-4 mr-2" /> Generado por IA <Icons.Sparkles className="h-4 w-4 mr-2" /> Generado por IA
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{/* --- Pestaña: Manual --- */}
<TabsContent value="manual" className="mt-4"> <TabsContent value="manual" className="mt-4">
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<Field label="Nombre"> <Field label="Nombre"><Input value={f.nombre} onChange={e => setF(s => ({ ...s, nombre: e.target.value }))} /></Field>
<Input value={f.nombre} onChange={e => setF(s => ({ ...s, nombre: e.target.value }))} /> <Field label="Clave"><Input value={f.clave} onChange={e => setF(s => ({ ...s, clave: e.target.value }))} /></Field>
</Field> <Field label="Tipo"><Input value={f.tipo} onChange={e => setF(s => ({ ...s, tipo: e.target.value }))} placeholder="Obligatoria / Optativa / Taller…" /></Field>
<Field label="Clave"> <Field label="Semestre"><Input value={f.semestre} onChange={e => setF(s => ({ ...s, semestre: e.target.value }))} placeholder="110" /></Field>
<Input value={f.clave} onChange={e => setF(s => ({ ...s, clave: e.target.value }))} /> <Field label="Créditos"><Input value={f.creditos} onChange={e => setF(s => ({ ...s, creditos: e.target.value }))} /></Field>
</Field> <Field label="Horas teóricas"><Input value={f.horas_teoricas} onChange={e => setF(s => ({ ...s, horas_teoricas: e.target.value }))} /></Field>
<Field label="Tipo"> <Field label="Horas prácticas"><Input value={f.horas_practicas} onChange={e => setF(s => ({ ...s, horas_practicas: e.target.value }))} /></Field>
<Input value={f.tipo} onChange={e => setF(s => ({ ...s, tipo: e.target.value }))} placeholder="Obligatoria / Optativa / Taller…" /> <div className="sm:col-span-2"><Field label="Objetivo (opcional)"><Textarea value={f.objetivos} onChange={e => setF(s => ({ ...s, objetivos: e.target.value }))} className="min-h-[90px]" /></Field></div>
</Field>
<Field label="Semestre">
<Input value={f.semestre} onChange={e => setF(s => ({ ...s, semestre: e.target.value }))} placeholder="110" />
</Field>
<Field label="Créditos">
<Input value={f.creditos} onChange={e => setF(s => ({ ...s, creditos: e.target.value }))} />
</Field>
<Field label="Horas teóricas">
<Input value={f.horas_teoricas} onChange={e => setF(s => ({ ...s, horas_teoricas: e.target.value }))} />
</Field>
<Field label="Horas prácticas">
<Input value={f.horas_practicas} onChange={e => setF(s => ({ ...s, horas_practicas: e.target.value }))} />
</Field>
<div className="sm:col-span-2">
<Field label="Objetivo (opcional)">
<Textarea value={f.objetivos} onChange={e => setF(s => ({ ...s, objetivos: e.target.value }))} className="min-h-[90px]" />
</Field>
</div>
</div> </div>
</TabsContent> </TabsContent>
{/* --- Pestaña: IA --- */}
<TabsContent value="ia" className="mt-4"> <TabsContent value="ia" className="mt-4">
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2"> <div className="sm:col-span-2"><Field label="Indica el enfoque / requisitos"><Textarea value={iaPrompt} onChange={e => setIaPrompt(e.target.value)} className="min-h-[120px]" placeholder="Ej.: Diseña una materia de Programación Web con proyectos, evaluación por rúbricas y bibliografía actual…" /></Field></div>
<Field label="Indica el enfoque / requisitos"> <Field label="Periodo (opcional)"><Input value={iaSemestre} onChange={e => setIaSemestre(e.target.value)} placeholder="110" /></Field>
<Textarea
value={iaPrompt}
onChange={e => setIaPrompt(e.target.value)}
className="min-h-[120px]"
placeholder="Ej.: Diseña una materia de Programación Web con proyectos, evaluación por rúbricas y bibliografía actual…"
/>
</Field>
</div>
<Field label="Periodo (opcional)">
<Input value={iaSemestre} onChange={e => setIaSemestre(e.target.value)} placeholder="110" />
</Field>
</div> </div>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button> <Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
<Button >
</Button>
<AuroraButton onClick={submit} disabled={saving || !canSubmit}> <AuroraButton onClick={submit} disabled={saving || !canSubmit}>
{saving {saving ? (mode === "manual" ? "Guardando…" : "Generando…") : (mode === "manual" ? "Crear" : "Generar e insertar")}
? (mode === "manual" ? "Guardando…" : "Generando…")
: (mode === "manual" ? "Crear" : "Generar e insertar")}
</AuroraButton> </AuroraButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -635,146 +733,3 @@ function AddAsignaturaButton({
</> </>
) )
} }
function AsignaturaPreviewCard({
asignatura,
}: {
asignatura: { id: string; nombre: string; semestre: number | null; creditos: number | null }
}) {
const [extra, setExtra] = useState<{
tipo: string | null
horas_teoricas: number | null
horas_practicas: number | null
contenidos: Record<string, Record<string, string>> | null
} | null>(null)
// Carga perezosa de info extra para enriquecer la tarjeta (8 items máx: ok)
useEffect(() => {
let ignore = false
; (async () => {
const { data } = await supabase
.from("asignaturas")
.select("tipo, horas_teoricas, horas_practicas, contenidos")
.eq("id", asignatura.id)
.maybeSingle()
if (!ignore) setExtra((data as any) ?? null)
})()
return () => {
ignore = true
}
}, [asignatura.id])
const horasT = extra?.horas_teoricas ?? null
const horasP = extra?.horas_practicas ?? null
const horasTot = (horasT ?? 0) + (horasP ?? 0)
// Conteo rápido de unidades/temas si existen
const resumenContenidos = useMemo(() => {
const c = extra?.contenidos
if (!c) return { unidades: 0, temas: 0 }
const unidades = Object.keys(c).length
const temas = Object.values(c).reduce((acc, temasObj) => acc + Object.keys(temasObj || {}).length, 0)
return { unidades, temas }
}, [extra?.contenidos])
// estilo por tipo
const tipo = (extra?.tipo ?? "").toLowerCase()
const tipoChip =
tipo.includes("oblig")
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: tipo.includes("opt")
? "bg-amber-50 text-amber-800 border-amber-200"
: tipo.includes("taller")
? "bg-indigo-50 text-indigo-700 border-indigo-200"
: tipo.includes("lab")
? "bg-sky-50 text-sky-700 border-sky-200"
: "bg-neutral-100 text-neutral-700 border-neutral-200"
return (
<article
className="group relative overflow-hidden rounded-2xl border bg-white/70 dark:bg-neutral-900/60 backdrop-blur p-4 shadow-sm hover:shadow-md transition-all hover:-translate-y-0.5"
role="region"
aria-label={asignatura.nombre}
>
{/* header */}
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
<Icons.BookOpen className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="font-medium truncate" title={asignatura.nombre}>
{asignatura.nombre}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
{asignatura.semestre != null && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
<Icons.Calendar className="h-3 w-3" /> S{asignatura.semestre}
</span>
)}
{asignatura.creditos != null && (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
<Icons.Coins className="h-3 w-3" /> {asignatura.creditos} cr
</span>
)}
{extra?.tipo && (
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 ${tipoChip}`}>
<Icons.Tag className="h-3 w-3" /> {extra.tipo}
</span>
)}
</div>
</div>
</div>
{/* cuerpo */}
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px]">
<SmallStat icon={Icons.Clock} label="Horas" value={horasTot || "—"} />
<SmallStat icon={Icons.BookMarked} label="Unidades" value={resumenContenidos.unidades || "—"} />
<SmallStat icon={Icons.ListTree} label="Temas" value={resumenContenidos.temas || "—"} />
</div>
{/* footer */}
<div className="mt-3 flex items-center justify-between">
<div className="text-[11px] text-neutral-500">
{horasT != null || horasP != null ? (
<>H T/P: {horasT ?? "—"}/{horasP ?? "—"}</>
) : (
<span className="opacity-70">Resumen listo</span>
)}
</div>
<Link
to="/asignatura/$asignaturaId"
params={{ asignaturaId: asignatura.id }}
className="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs hover:bg-neutral-50"
title="Ver detalle"
>
Ver detalle <Icons.ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
{/* glow sutil en hover */}
<div className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: "radial-gradient(600px 120px at 20% -10%, rgba(0,0,0,.06), transparent 60%)" }} />
</article>
)
}
function SmallStat({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
label: string
value: string | number
}) {
return (
<div className="rounded-lg border bg-white/60 dark:bg-neutral-900/50 px-2.5 py-2">
<div className="flex items-center gap-1 text-[10px] text-neutral-500">
<Icon className="h-3.5 w-3.5" /> {label}
</div>
<div className="mt-0.5 font-medium tabular-nums">{value}</div>
</div>
)
}

View File

@@ -1,14 +1,13 @@
// routes/_authenticated/usuarios.tsx // routes/_authenticated/usuarios.tsx
import { createFileRoute, useRouter } from "@tanstack/react-router" 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 { supabase, useSupabaseAuth } from "@/auth/supabase" import { supabase, useSupabaseAuth } 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"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
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 {
@@ -20,6 +19,7 @@ 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 -------------------- */
type AdminUser = { type AdminUser = {
id: string id: string
email: string | null email: string | null
@@ -27,58 +27,28 @@ type AdminUser = {
last_sign_in_at: string | null last_sign_in_at: string | null
user_metadata: any user_metadata: any
app_metadata: any app_metadata: any
banned_until?: string | null // NEW: lo usamos en UI banned_until?: string | null
} }
// NEW: constantes auxiliares
const SCOPED_ROLES = ["director_facultad", "secretario_academico", "jefe_carrera"] as const const SCOPED_ROLES = ["director_facultad", "secretario_academico", "jefe_carrera"] as const
// NEW: agrega director_facultad; mantenemos planeacion por compat
const ROLES = [ const ROLES = [
"lci", "lci",
"vicerrectoria", "vicerrectoria",
"director_facultad", // NEW "director_facultad",
"secretario_academico", "secretario_academico",
"jefe_carrera", "jefe_carrera",
"planeacion", "planeacion",
] as const ] as const
export type Role = typeof ROLES[number] export type Role = typeof ROLES[number]
const ROLE_META: Record<Role, { const ROLE_META: Record<Role, { label: string; Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; className: string }> = {
label: string lci: { label: "Laboratorio de Cómputo de Ingeniería", Icon: Cpu, className: "bg-neutral-900 text-white" },
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>> vicerrectoria: { label: "Vicerrectoría Académica", Icon: Building2, className: "bg-indigo-600 text-white" },
className: string 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" },
lci: { jefe_carrera: { label: "Jefe de Carrera", Icon: GraduationCap, className: "bg-orange-600 text-white" },
label: "Laboratorio de Cómputo de Ingeniería", planeacion: { label: "Planeación Curricular", Icon: GanttChart, className: "bg-sky-600 text-white" },
Icon: Cpu,
className: "bg-neutral-900 text-white"
},
vicerrectoria: {
label: "Vicerrectoría Académica",
Icon: Building2,
className: "bg-indigo-600 text-white"
},
director_facultad: { // NEW
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 }) { function RolePill({ role }: { role: Role }) {
@@ -86,46 +56,56 @@ function RolePill({ role }: { role: Role }) {
if (!meta) return null if (!meta) return null
const { Icon, className, label } = meta const { Icon, className, label } = meta
return ( return (
<span <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}>
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" /> <Icon className="h-3 w-3 shrink-0" />
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
</span> </span>
) )
} }
/* -------------------- Query Keys & Fetcher -------------------- */
const usersKeys = {
root: ["usuarios"] as const,
list: () => [...usersKeys.root, "list"] as const,
}
async function fetchUsers(): Promise<AdminUser[]> {
// ⚠️ Dev only: service role en cliente
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
const { data } = await admin.auth.admin.listUsers()
return (data?.users ?? []) as AdminUser[]
}
const usersOptions = () =>
queryOptions({ queryKey: usersKeys.list(), queryFn: fetchUsers, staleTime: 60_000 })
/* -------------------- Ruta -------------------- */
export const Route = createFileRoute("/_authenticated/usuarios")({ export const Route = createFileRoute("/_authenticated/usuarios")({
component: RouteComponent, component: RouteComponent,
loader: async () => { loader: async ({ context: { queryClient } }) => {
// ⚠️ Dev only: service role en cliente await queryClient.ensureQueryData(usersOptions())
const supabsaeAdmin = new SupabaseClient( return null
import.meta.env.VITE_SUPABASE_URL, },
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY,
)
const { data: data_users } = await supabsaeAdmin.auth.admin.listUsers()
return { data: data_users.users as AdminUser[] }
}
}) })
/* -------------------- Página -------------------- */
function RouteComponent() { function RouteComponent() {
const auth = useSupabaseAuth() const auth = useSupabaseAuth()
const router = useRouter() const router = useRouter()
const { data } = Route.useLoaderData() const qc = useQueryClient()
const { data } = useSuspenseQuery(usersOptions())
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [editing, setEditing] = useState<AdminUser | null>(null) const [editing, setEditing] = useState<AdminUser | null>(null)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<{ const [form, setForm] = useState<{
role?: Role; role?: Role
claims_admin?: boolean; claims_admin?: boolean
nombre?: string; apellidos?: string; title?: string; clave?: string; avatar?: string; nombre?: string; apellidos?: string; title?: string; clave?: string; avatar?: string
facultad_id?: string | null; facultad_id?: string | null
carrera_id?: string | null; carrera_id?: string | null
}>({}) }>({})
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [createSaving, setCreateSaving] = useState(false)
const [showPwd, setShowPwd] = useState(false) const [showPwd, setShowPwd] = useState(false)
const [createForm, setCreateForm] = useState<{ const [createForm, setCreateForm] = useState<{
email: string email: string
@@ -138,123 +118,138 @@ function RouteComponent() {
}>({ email: "", password: "" }) }>({ email: "", password: "" })
function genPassword() { function genPassword() {
const s = Array.from(crypto.getRandomValues(new Uint32Array(4))) const s = Array.from(crypto.getRandomValues(new Uint32Array(4))).map((n) => n.toString(36)).join("")
.map(n => n.toString(36)).join("")
return s.slice(0, 14) return s.slice(0, 14)
} }
/* ---------- Mutations ---------- */
const invalidateAll = async () => {
await qc.invalidateQueries({ queryKey: usersKeys.root })
router.invalidate()
}
// NEW: helpers nombramientos const upsertNombramiento = useMutation({
async function upsertNombramiento(opts: { mutationFn: async (opts: {
user_id: string, user_id: string
puesto: "director_facultad" | "secretario_academico" | "jefe_carrera", puesto: "director_facultad" | "secretario_academico" | "jefe_carrera"
facultad_id?: string | null, facultad_id?: string | null
carrera_id?: string | null carrera_id?: string | null
}) { }) => {
// cierra vigentes del mismo scope y puesto // cierra vigentes
if (opts.puesto === "jefe_carrera") { if (opts.puesto === "jefe_carrera") {
if (!opts.carrera_id) throw new Error("Selecciona carrera") if (!opts.carrera_id) throw new Error("Selecciona carrera")
await supabase.from("nombramientos") await supabase
.from("nombramientos")
.update({ hasta: new Date().toISOString().slice(0, 10) }) .update({ hasta: new Date().toISOString().slice(0, 10) })
.eq("puesto", "jefe_carrera") .eq("puesto", "jefe_carrera")
.eq("carrera_id", opts.carrera_id) .eq("carrera_id", opts.carrera_id)
.is("hasta", null) .is("hasta", null)
} else { } else {
if (!opts.facultad_id) throw new Error("Selecciona facultad") if (!opts.facultad_id) throw new Error("Selecciona facultad")
await supabase.from("nombramientos") await supabase
.from("nombramientos")
.update({ hasta: new Date().toISOString().slice(0, 10) }) .update({ hasta: new Date().toISOString().slice(0, 10) })
.eq("puesto", opts.puesto) .eq("puesto", opts.puesto)
.eq("facultad_id", opts.facultad_id) .eq("facultad_id", opts.facultad_id)
.is("hasta", null) .is("hasta", null)
} }
// inserta vigente
const { error } = await supabase.from("nombramientos").insert({ const { error } = await supabase.from("nombramientos").insert({
user_id: opts.user_id, user_id: opts.user_id,
puesto: opts.puesto, puesto: opts.puesto,
facultad_id: opts.facultad_id ?? null, facultad_id: opts.facultad_id ?? null,
carrera_id: opts.carrera_id ?? null, carrera_id: opts.carrera_id ?? null,
desde: new Date().toISOString().slice(0, 10), desde: new Date().toISOString().slice(0, 10),
hasta: null hasta: null,
}) })
if (error) throw error if (error) throw error
} },
onError: (e: any) => toast.error(e?.message || "Error al registrar nombramiento"),
})
// NEW: ban/unban directo (deja que el trigger “rebalance” haga lo suyo) const toggleBan = useMutation({
async function toggleBan(u: AdminUser) { mutationFn: async (u: AdminUser) => {
try {
const banned = !!u.banned_until && new Date(u.banned_until) > new Date() const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
const payload = banned const payload = banned ? { banned_until: null } : { banned_until: new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString() }
? { 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 error if (error) throw new Error(error.message)
toast.success(banned ? "Usuario desbaneado" : "Usuario baneado") return !banned
router.invalidate() },
} catch (e: any) { onSuccess: async (isBanned) => {
console.error(e) toast.success(isBanned ? "Usuario baneado" : "Usuario desbaneado")
toast.error(e?.message || "Error al cambiar estado de baneo") await invalidateAll()
} },
} onError: (e: any) => toast.error(e?.message || "Error al cambiar estado de baneo"),
})
async function createUserNow() { const createUser = useMutation({
if (!createForm.email?.trim()) { toast.error("Correo requerido"); return } mutationFn: async (payload: typeof createForm) => {
try { const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
const adminClient = new SupabaseClient( const password = payload.password?.trim() || genPassword()
import.meta.env.VITE_SUPABASE_URL, const { error, data } = await admin.auth.admin.createUser({
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY email: payload.email.trim(),
)
setCreateSaving(true)
const password = createForm.password?.trim() || genPassword()
const { error, data } = await adminClient.auth.admin.createUser({
email: createForm.email.trim(),
password, password,
email_confirm: true, email_confirm: true,
user_metadata: { user_metadata: {
nombre: createForm.nombre ?? "", nombre: payload.nombre ?? "",
apellidos: createForm.apellidos ?? "", apellidos: payload.apellidos ?? "",
title: createForm.title ?? "", title: payload.title ?? "",
clave: createForm.clave ?? "", clave: payload.clave ?? "",
avatar: createForm.avatar ?? "" avatar: payload.avatar ?? "",
}, },
app_metadata: { app_metadata: {
role: createForm.role, role: payload.role,
claims_admin: !!createForm.claims_admin, claims_admin: !!payload.claims_admin,
facultad_id: createForm.facultad_id ?? null, facultad_id: payload.facultad_id ?? null,
carrera_id: createForm.carrera_id ?? null carrera_id: payload.carrera_id ?? null,
} },
}) })
if (error) throw error if (error) throw new Error(error.message)
// NEW: si es rol jerárquico => crea nombramiento
const uid = data.user?.id const uid = data.user?.id
if (uid && createForm.role && (SCOPED_ROLES as readonly string[]).includes(createForm.role)) { if (uid && payload.role && (SCOPED_ROLES as readonly string[]).includes(payload.role)) {
if (createForm.role === "director_facultad") { if (payload.role === "director_facultad") {
if (!createForm.facultad_id) throw new Error("Selecciona facultad") if (!payload.facultad_id) throw new Error("Selecciona facultad")
await upsertNombramiento({ user_id: uid, puesto: "director_facultad", facultad_id: createForm.facultad_id }) await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "director_facultad", facultad_id: payload.facultad_id })
} else if (createForm.role === "secretario_academico") { } else if (payload.role === "secretario_academico") {
if (!createForm.facultad_id) throw new Error("Selecciona facultad") if (!payload.facultad_id) throw new Error("Selecciona facultad")
await upsertNombramiento({ user_id: uid, puesto: "secretario_academico", facultad_id: createForm.facultad_id }) await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "secretario_academico", facultad_id: payload.facultad_id })
} else if (createForm.role === "jefe_carrera") { } else if (payload.role === "jefe_carrera") {
if (!createForm.facultad_id || !createForm.carrera_id) throw new Error("Selecciona facultad y carrera") if (!payload.facultad_id || !payload.carrera_id) throw new Error("Selecciona facultad y carrera")
await upsertNombramiento({ await upsertNombramiento.mutateAsync({ user_id: uid, puesto: "jefe_carrera", facultad_id: payload.facultad_id, carrera_id: payload.carrera_id })
user_id: uid, puesto: "jefe_carrera",
facultad_id: createForm.facultad_id, carrera_id: createForm.carrera_id
})
} }
} }
},
onSuccess: async () => {
toast.success("Usuario creado") toast.success("Usuario creado")
setCreateOpen(false) setCreateOpen(false)
setCreateForm({ email: "", password: "" }) setCreateForm({ email: "", password: "" })
router.invalidate() await invalidateAll()
} catch (e: any) { },
console.error(e) onError: (e: any) => toast.error(e?.message || "No se pudo crear el usuario"),
toast.error(e?.message || "No se pudo crear el usuario") })
} finally {
setCreateSaving(false) const saveUser = useMutation({
mutationFn: async ({ u, f }: { u: AdminUser; f: typeof form }) => {
// 1) Actualiza metadatos (tu Edge Function; placeholder aquí)
// await fetch('/functions/update-user', { method: 'POST', body: JSON.stringify({ id: u.id, ...f }) })
// Simula éxito:
// 2) Nombramiento si aplica
if (f.role && (SCOPED_ROLES as readonly string[]).includes(f.role)) {
if (f.role === "director_facultad") {
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "director_facultad", facultad_id: f.facultad_id! })
} else if (f.role === "secretario_academico") {
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "secretario_academico", facultad_id: f.facultad_id! })
} else if (f.role === "jefe_carrera") {
await upsertNombramiento.mutateAsync({ user_id: u.id, puesto: "jefe_carrera", facultad_id: f.facultad_id!, carrera_id: f.carrera_id! })
} }
} }
},
onSuccess: async () => {
toast.success("Cambios guardados")
setEditing(null)
await invalidateAll()
},
onError: (e: any) => toast.error(e?.message || "No se pudo guardar"),
})
if (!auth.claims?.claims_admin) { if (!auth.claims?.claims_admin) {
return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div> return <div className="p-6 text-sm text-red-600">No tienes permisos para administrar usuarios.</div>
@@ -263,12 +258,12 @@ function RouteComponent() {
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.app_metadata?.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.email, u.user_metadata?.nombre, u.user_metadata?.apellidos, label]
.filter(Boolean) .filter(Boolean)
.some(v => String(v).toLowerCase().includes(t)) .some((v) => String(v).toLowerCase().includes(t))
}) })
}, [q, data]) }, [q, data])
@@ -287,7 +282,6 @@ function RouteComponent() {
}) })
} }
// NEW: validación de scope por rol antes de guardar
function validateScopeForSave(): string | null { function validateScopeForSave(): string | null {
if (!editing) return "Sin usuario" if (!editing) return "Sin usuario"
if (form.role === "director_facultad" || form.role === "secretario_academico") { if (form.role === "director_facultad" || form.role === "secretario_academico") {
@@ -299,58 +293,14 @@ function RouteComponent() {
return null return null
} }
async function save() {
if (!editing) return
const scopeErr = validateScopeForSave()
if (scopeErr) { toast.error(scopeErr); return }
setSaving(true)
// 1) Actualiza metadatos via Edge Function existente (mantengo tu flujo)
const error = true;
if (error) {
setSaving(false)
console.error(error)
toast.error("No se pudo guardar")
return
}
try {
// 2) NEW: si es rol jerárquico => upsert nombramiento (dispara exclusividad + ban via triggers)
if (form.role && (SCOPED_ROLES as readonly string[]).includes(form.role)) {
if (form.role === "director_facultad") {
await upsertNombramiento({ user_id: editing.id, puesto: "director_facultad", facultad_id: form.facultad_id! })
} else if (form.role === "secretario_academico") {
await upsertNombramiento({ user_id: editing.id, puesto: "secretario_academico", facultad_id: form.facultad_id! })
} else if (form.role === "jefe_carrera") {
await upsertNombramiento({
user_id: editing.id, puesto: "jefe_carrera",
facultad_id: form.facultad_id!, carrera_id: form.carrera_id!
})
}
}
toast.success("Cambios guardados")
} catch (e: any) {
console.error(e)
toast.error(e?.message || "Error al registrar nombramiento")
} finally {
setSaving(false)
router.invalidate(); setEditing(null)
}
}
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
<Card> <Card>
<CardHeader className="grid gap-3 sm:grid-cols-2 sm:items-center"> <CardHeader className="grid gap-3 sm:grid-cols-2 sm:items-center">
<CardTitle>Usuarios</CardTitle> <CardTitle>Usuarios</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input placeholder="Buscar por nombre, email o rol…" value={q} onChange={(e) => setQ(e.target.value)} className="w-full" />
placeholder="Buscar por nombre, email o rol…" <Button variant="outline" size="icon" title="Recargar" onClick={invalidateAll}>
value={q}
onChange={(e) => setQ(e.target.value)}
className="w-full"
/>
<Button variant="outline" size="icon" title="Recargar" onClick={() => router.invalidate()}>
<RefreshCcw className="w-4 h-4" /> <RefreshCcw className="w-4 h-4" />
</Button> </Button>
<Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap"> <Button onClick={() => setCreateOpen(true)} className="whitespace-nowrap">
@@ -361,94 +311,55 @@ 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 m = u.user_metadata || {}
const a = u.app_metadata || {} const a = u.app_metadata || {}
const roleCode: Role | undefined = a.role const roleCode: Role | undefined = a.role
const banned = !!(u as any).banned_until && new Date((u as any).banned_until).getTime() > Date.now() // NEW 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 <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" />
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"
/>
<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"> <div className="font-medium truncate">{m.title ? `${m.title} ` : ""}{m.nombre ? `${m.nombre} ${m.apellidos ?? ""}` : (u.email ?? "—")}</div>
{m.title ? `${m.title} ` : ""}{m.nombre ? `${m.nombre} ${m.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 ? ( {a.claims_admin ? (
<Badge className="gap-1" variant="secondary"> <Badge className="gap-1" variant="secondary"><ShieldCheck className="w-3 h-3" /> Admin</Badge>
<ShieldCheck className="w-3 h-3" /> Admin
</Badge>
) : ( ) : (
<Badge className="gap-1" variant="outline"> <Badge className="gap-1" variant="outline"><ShieldAlert className="w-3 h-3" /> Usuario</Badge>
<ShieldAlert className="w-3 h-3" /> Usuario
</Badge>
)} )}
{/* NEW: estado ban */} <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 ? <BanIcon className="w-3 h-3" /> : <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">
{/* NEW: toggle ban/unban */} <Button variant="outline" size="sm" onClick={() => toggleBan.mutate(u)} title={banned ? "Restaurar acceso" : "Inhabilitar la cuenta"} className="hidden sm:inline-flex">
<Button <BanIcon className="w-4 h-4 mr-1" /> {banned ? "Restaurar acceso" : "Inhabilitar la cuenta"}
variant="outline"
size="sm"
onClick={() => toggleBan(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"}
</Button> </Button>
<Button <Button variant="ghost" size="sm" className="hidden sm:inline-flex shrink-0" onClick={() => openEdit(u)}>
variant="ghost"
size="sm"
className="hidden sm:inline-flex shrink-0"
onClick={() => openEdit(u)}
>
<Pencil className="w-4 h-4 mr-1" /> Editar <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"> <span className="inline-flex items-center gap-1"><Mail className="w-3 h-3" /> {u.email ?? "—"}</span>
<Mail className="w-3 h-3" /> {u.email ?? "—"} <span className="hidden xs:inline">Creado: {new Date(u.created_at).toLocaleDateString()}</span>
</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 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>
</div> </div>
</div> </div>
{/* Mobile actions */}
<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(u)} aria-label="Ban/Unban"> <Button variant="outline" size="icon" onClick={() => toggleBan.mutate(u)} aria-label="Ban/Unban"><BanIcon className="w-4 h-4" /></Button>
<BanIcon className="w-4 h-4" /> <Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar"><Pencil className="w-4 h-4" /></Button>
</Button>
<Button variant="ghost" size="icon" onClick={() => openEdit(u)} aria-label="Editar">
<Pencil className="w-4 h-4" />
</Button>
</div> </div>
</div> </div>
</div> </div>
) )
})} })}
{!filtered.length && ( {!filtered.length && <div className="text-sm text-neutral-500 text-center py-10">Sin usuarios</div>}
<div className="text-sm text-neutral-500 text-center py-10">Sin usuarios</div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -458,60 +369,31 @@ function RouteComponent() {
<DialogContent className="w-[min(92vw,720px)] sm:max-w-xl md:max-w-2xl"> <DialogContent className="w-[min(92vw,720px)] sm:max-w-xl md:max-w-2xl">
<DialogHeader><DialogTitle>Editar usuario</DialogTitle></DialogHeader> <DialogHeader><DialogTitle>Editar usuario</DialogTitle></DialogHeader>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1"> <div className="space-y-1"><Label>Nombre</Label><Input value={form.nombre ?? ""} onChange={(e) => setForm((s) => ({ ...s, nombre: e.target.value }))} /></div>
<Label>Nombre</Label> <div className="space-y-1"><Label>Apellidos</Label><Input value={form.apellidos ?? ""} onChange={(e) => setForm((s) => ({ ...s, apellidos: e.target.value }))} /></div>
<Input value={form.nombre ?? ""} onChange={(e) => setForm(s => ({ ...s, nombre: e.target.value }))} /> <div className="space-y-1"><Label>Título</Label><Input value={form.title ?? ""} onChange={(e) => setForm((s) => ({ ...s, title: e.target.value }))} /></div>
</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"> <div className="space-y-1"><Label>Avatar (URL)</Label><Input value={form.avatar ?? ""} onChange={(e) => setForm((s) => ({ ...s, avatar: e.target.value }))} /></div>
<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>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"> <div className="space-y-1">
<Label>Rol</Label> <Label>Rol</Label>
<Select <Select
value={form.role ?? ""} value={form.role ?? ""}
onValueChange={(v) => { onValueChange={(v) => {
setForm(s => { setForm((s) => {
const role = v as Role const role = v as Role
if (role === "jefe_carrera") { if (role === "jefe_carrera") return { ...s, role, facultad_id: s.facultad_id ?? "", carrera_id: s.carrera_id ?? "" }
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 }
}) })
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full"><SelectValue placeholder="Selecciona un rol" /></SelectTrigger>
<SelectValue placeholder="Selecciona un rol" /> <SelectContent position="popper" side="bottom" align="start" className="w-[--radix-select-trigger-width] max-w-[min(92vw,28rem)] max-h-72 overflow-auto">
</SelectTrigger> {ROLES.map((code) => {
<SelectContent
position="popper"
side="bottom"
align="start"
className="w-[--radix-select-trigger-width] max-w-[min(92vw,28rem)] max-h-72 overflow-auto"
>
{ROLES.map(code => {
const meta = ROLE_META[code]; const Icon = meta.Icon const meta = ROLE_META[code]; const Icon = meta.Icon
return ( return (
<SelectItem key={code} value={code} className="whitespace-normal text-sm leading-snug py-2"> <SelectItem key={code} value={code} className="whitespace-normal text-sm leading-snug py-2">
<span className="inline-flex items-center gap-2"> <span className="inline-flex items-center gap-2"><Icon className="w-4 h-4" /> {meta.label}</span>
<Icon className="w-4 h-4" /> {meta.label}
</span>
</SelectItem> </SelectItem>
) )
})} })}
@@ -519,43 +401,30 @@ function RouteComponent() {
</Select> </Select>
</div> </div>
{/* DIRECTOR/SECRETARIO: facultad */}
{(form.role === "secretario_academico" || form.role === "director_facultad") && ( {(form.role === "secretario_academico" || form.role === "director_facultad") && (
<div className="md:col-span-2 space-y-1"> <div className="md:col-span-2 space-y-1">
<Label>Facultad</Label> <Label>Facultad</Label>
<FacultadCombobox <FacultadCombobox value={form.facultad_id ?? ""} onChange={(id) => setForm((s) => ({ ...s, facultad_id: id, carrera_id: null }))} />
value={form.facultad_id ?? ""}
onChange={(id) => setForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
/>
<p className="text-[11px] text-neutral-500">Este rol requiere <strong>Facultad</strong>.</p> <p className="text-[11px] text-neutral-500">Este rol requiere <strong>Facultad</strong>.</p>
</div> </div>
)} )}
{/* JEFE DE CARRERA: ambos */}
{form.role === "jefe_carrera" && ( {form.role === "jefe_carrera" && (
<div className="grid gap-4 sm:grid-cols-2 md:col-span-2"> <div className="grid gap-4 sm:grid-cols-2 md:col-span-2">
<div className="space-y-1"> <div className="space-y-1">
<Label>Facultad</Label> <Label>Facultad</Label>
<FacultadCombobox <FacultadCombobox value={form.facultad_id ?? ""} onChange={(id) => setForm((s) => ({ ...s, facultad_id: id, carrera_id: "" }))} />
value={form.facultad_id ?? ""}
onChange={(id) => setForm(s => ({ ...s, facultad_id: id, carrera_id: "" }))}
/>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Carrera</Label> <Label>Carrera</Label>
<CarreraCombobox <CarreraCombobox facultadId={form.facultad_id ?? ""} value={form.carrera_id ?? ""} onChange={(id) => setForm((s) => ({ ...s, carrera_id: id }))} disabled={!form.facultad_id} />
facultadId={form.facultad_id ?? ""}
value={form.carrera_id ?? ""}
onChange={(id) => setForm(s => ({ ...s, carrera_id: id }))}
disabled={!form.facultad_id}
/>
</div> </div>
</div> </div>
)} )}
<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' }))}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="true">Administrador</SelectItem> <SelectItem value="true">Administrador</SelectItem>
@@ -566,7 +435,16 @@ function RouteComponent() {
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button> <Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : "Guardar"}</Button> <Button
onClick={() => {
const scopeErr = validateScopeForSave()
if (scopeErr || !editing) { toast.error(scopeErr || 'Sin usuario'); return }
saveUser.mutate({ u: editing, f: form })
}}
disabled={saveUser.isPending}
>
{saveUser.isPending ? "Guardando…" : "Guardar"}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -579,60 +457,31 @@ 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 md:col-span-2"> <div className="space-y-1 md:col-span-2">
<Label>Correo</Label> <Label>Correo</Label>
<Input <Input type="email" value={createForm.email} onChange={(e) => setCreateForm((s) => ({ ...s, email: e.target.value }))} placeholder="usuario@lasalle.mx" />
type="email"
value={createForm.email}
onChange={(e) => setCreateForm(s => ({ ...s, email: e.target.value }))}
placeholder="usuario@lasalle.mx"
/>
</div> </div>
<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 <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" />
type={showPwd ? "text" : "password"} <Button type="button" variant="outline" onClick={() => setCreateForm((s) => ({ ...s, password: genPassword() }))}>Generar</Button>
value={createForm.password} <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>
onChange={(e) => setCreateForm(s => ({ ...s, password: e.target.value }))}
placeholder="Se generará si la dejas vacía"
/>
<Button type="button" variant="outline" onClick={() => setCreateForm(s => ({ ...s, password: genPassword() }))}>
Generar
</Button>
<Button type="button" variant="outline" onClick={() => setShowPwd(v => !v)} aria-label="Mostrar u ocultar">
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</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"> <div className="space-y-1"><Label>Nombre</Label><Input value={createForm.nombre ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, nombre: e.target.value }))} /></div>
<Label>Nombre</Label> <div className="space-y-1"><Label>Apellidos</Label><Input value={createForm.apellidos ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, apellidos: e.target.value }))} /></div>
<Input value={createForm.nombre ?? ""} onChange={(e) => setCreateForm(s => ({ ...s, nombre: e.target.value }))} /> <div className="space-y-1"><Label>Título</Label><Input value={createForm.title ?? ""} onChange={(e) => setCreateForm((s) => ({ ...s, title: e.target.value }))} /></div>
</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"> <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>
<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>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"> <div className="space-y-1 md:col-span-2">
<Label>Rol</Label> <Label>Rol</Label>
<Select <Select
value={createForm.role ?? ""} value={createForm.role ?? ""}
onValueChange={(v) => { onValueChange={(v) => {
setCreateForm(s => { setCreateForm((s) => {
const role = v as Role const role = v as 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 }
@@ -642,7 +491,7 @@ function RouteComponent() {
> >
<SelectTrigger className="w-full"><SelectValue placeholder="Selecciona un rol" /></SelectTrigger> <SelectTrigger className="w-full"><SelectValue placeholder="Selecciona un rol" /></SelectTrigger>
<SelectContent className="max-h-72"> <SelectContent className="max-h-72">
{ROLES.map(code => { {ROLES.map((code) => {
const M = ROLE_META[code]; const I = M.Icon const M = ROLE_META[code]; const I = M.Icon
return ( return (
<SelectItem key={code} value={code} className="whitespace-normal text-sm leading-snug py-2"> <SelectItem key={code} value={code} className="whitespace-normal text-sm leading-snug py-2">
@@ -657,10 +506,7 @@ function RouteComponent() {
{(createForm.role === "secretario_academico" || createForm.role === "director_facultad") && ( {(createForm.role === "secretario_academico" || createForm.role === "director_facultad") && (
<div className="md:col-span-2 space-y-1"> <div className="md:col-span-2 space-y-1">
<Label>Facultad</Label> <Label>Facultad</Label>
<FacultadCombobox <FacultadCombobox value={createForm.facultad_id ?? ""} onChange={(id) => setCreateForm((s) => ({ ...s, facultad_id: id, carrera_id: null }))} />
value={createForm.facultad_id ?? ""}
onChange={(id) => setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: null }))}
/>
</div> </div>
)} )}
@@ -668,26 +514,18 @@ function RouteComponent() {
<div className="grid gap-4 md:col-span-2 sm:grid-cols-2"> <div className="grid gap-4 md:col-span-2 sm:grid-cols-2">
<div className="space-y-1"> <div className="space-y-1">
<Label>Facultad</Label> <Label>Facultad</Label>
<FacultadCombobox <FacultadCombobox value={createForm.facultad_id ?? ""} onChange={(id) => setCreateForm((s) => ({ ...s, facultad_id: id, carrera_id: "" }))} />
value={createForm.facultad_id ?? ""}
onChange={(id) => setCreateForm(s => ({ ...s, facultad_id: id, carrera_id: "" }))}
/>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Carrera</Label> <Label>Carrera</Label>
<CarreraCombobox <CarreraCombobox facultadId={createForm.facultad_id ?? ""} value={createForm.carrera_id ?? ""} onChange={(id) => setCreateForm((s) => ({ ...s, carrera_id: id }))} disabled={!createForm.facultad_id} />
facultadId={createForm.facultad_id ?? ""}
value={createForm.carrera_id ?? ""}
onChange={(id) => setCreateForm(s => ({ ...s, carrera_id: id }))}
disabled={!createForm.facultad_id}
/>
</div> </div>
</div> </div>
)} )}
<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" }))}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="true">Administrador</SelectItem> <SelectItem value="true">Administrador</SelectItem>
@@ -699,13 +537,12 @@ function RouteComponent() {
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button> <Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
<Button onClick={createUserNow} disabled={!createForm.email || createSaving}> <Button onClick={() => createUser.mutate(createForm)} disabled={!createForm.email || createUser.isPending}>
{createSaving ? "Creando…" : "Crear usuario"} {createUser.isPending ? "Creando…" : "Crear usuario"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
) )
} }