From 3bc4498e4fe93e6b011214cbc9ad48eb34a4e5c1 Mon Sep 17 00:00:00 2001 From: Alejandro Rosales Date: Wed, 27 Aug 2025 16:15:42 -0600 Subject: [PATCH] Refactor user management in usuarios.tsx: integrate react-query for data fetching and mutations, streamline role handling, and enhance user ban/unban functionality. --- bun.lock | 15 +- package.json | 3 +- src/components/planes/academic-sections.tsx | 370 ++++++------ src/main.tsx | 13 +- src/routes/__root.tsx | 9 +- src/routes/_authenticated/asignaturas.tsx | 200 ++++--- src/routes/_authenticated/carreras.tsx | 345 +++++++---- src/routes/_authenticated/dashboard.tsx | 238 ++++---- src/routes/_authenticated/facultades.tsx | 183 +++--- src/routes/_authenticated/plan/$planId.tsx | 617 +++++++++----------- src/routes/_authenticated/usuarios.tsx | 599 +++++++------------ 11 files changed, 1279 insertions(+), 1313 deletions(-) diff --git a/bun.lock b/bun.lock index 2a4dd8a..18b91f7 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,8 @@ "@supabase/supabase-js": "^2.55.0", "@tailwindcss/vite": "^4.1.12", "@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/router-plugin": "^1.121.2", "@types/canvas-confetti": "^1.9.0", @@ -392,15 +393,19 @@ "@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-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-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=="], @@ -892,8 +897,12 @@ "@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-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=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], diff --git a/package.json b/package.json index 38a26b0..37ce939 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "@supabase/supabase-js": "^2.55.0", "@tailwindcss/vite": "^4.1.12", "@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/router-plugin": "^1.121.2", "@types/canvas-confetti": "^1.9.0", diff --git a/src/components/planes/academic-sections.tsx b/src/components/planes/academic-sections.tsx index 23ec8b5..9109a70 100644 --- a/src/components/planes/academic-sections.tsx +++ b/src/components/planes/academic-sections.tsx @@ -1,185 +1,219 @@ import * as Icons from "lucide-react" import { useMemo, useState } from "react" +import { useSuspenseQuery, useMutation, useQueryClient, queryOptions } from "@tanstack/react-query" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Textarea } from "@/components/ui/textarea" import { supabase } from "@/auth/supabase" +import { toast } from "sonner" -/* ---------- helpers de color ---------- */ +/* ===================================================== + Query keys & fetcher +===================================================== */ +export const planKeys = { + root: ["plan"] as const, + byId: (id: string) => [...planKeys.root, id] as const, +} + +export type PlanTextFields = { + objetivo_general?: string | string[] | null + sistema_evaluacion?: string | string[] | null + perfil_ingreso?: string | string[] | null + perfil_egreso?: string | string[] | null + competencias_genericas?: string | string[] | null + competencias_especificas?: string | string[] | null + indicadores_desempeno?: string | string[] | null + pertinencia?: string | string[] | null + prompt?: string | null +} + +async function fetchPlanText(planId: string): Promise { + const { data, error } = await supabase + .from("plan_estudios") + .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] + 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})` -/* ---------- texto expandible (acepta string o string[]) ---------- */ +/* ===================================================== + 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 - } - const content = Array.isArray(text) ? text.join("\n• ") : text - const rendered = Array.isArray(text) ? `• ${content}` : content - return ( -
-
- {rendered} -
- {String(rendered).length > 220 && ( - - )} -
- ) + const [open, setOpen] = useState(false) + if (!text || (Array.isArray(text) && text.length === 0)) { + return + } + const content = Array.isArray(text) ? text.join("\n• ") : text + const rendered = Array.isArray(text) ? `• ${content}` : content + return ( +
+
{rendered}
+ {String(rendered).length > 220 && ( + + )} +
+ ) } -/* ---------- panel con aurora mesh ---------- */ -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 ( -
- {/* aurora mesh sutil */} -
-
-
-
+/* ===================================================== + 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 ( +
+
+
+
+
+
+ + + +

{title}

+
+
{children}
+
+ ) +} -
- (null) + const [draft, setDraft] = useState("") + + // --- mutation con actualización optimista --- + const updateField = useMutation({ + mutationFn: async ({ key, value }: { key: keyof PlanTextFields; value: string | string[] | null }) => { + const payload: Record = { [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(planKeys.byId(planId)) + qc.setQueryData(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-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-egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false }, + { id: "sec-cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false }, + { id: "sec-ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" 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-prm", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true }, + ], + [] + ) + + return ( + <> +
+ {sections.map((s) => { + const text = plan[s.key] ?? null + return ( + + +
+
-
{children}
-
- ) -} - -/* ---------- Secciones integradas (sin tabs) ---------- */ -type PlanTextFields = { - objetivo_general?: string | string[] | null - sistema_evaluacion?: string | string[] | null - perfil_ingreso?: string | string[] | null - perfil_egreso?: string | string[] | null - competencias_genericas?: string | string[] | null - competencias_especificas?: string | string[] | null - indicadores_desempeno?: string | string[] | null - pertinencia?: string | string[] | null - prompt?: string | null -} - -export function AcademicSections({ - planId, plan, color, -}: { planId: string; plan: PlanTextFields; color?: string | null }) { - const [local, setLocal] = useState({ ...plan }) - const [editing, setEditing] = useState(null) - const [draft, setDraft] = useState("") - const [saving, setSaving] = useState(false) - - const sections = useMemo(() => [ - { 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-ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false }, - { id: "sec-egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false }, - { id: "sec-cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false }, - { id: "sec-ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" 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-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 ( - <> - - {/* Todas las tarjetas visibles */} -
- {sections.map(s => { - const text = local[s.key] ?? null - return ( - - -
- - -
-
- ) - })} -
- - {/* Diálogo de edición */} - { if (!o) setEditing(null) }}> - - - - {editing ? `Editar: ${sections.find(x => x.key === editing.key)?.title}` : ""} - - -