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:
15
bun.lock
15
bun.lock
@@ -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=="],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,185 +1,219 @@
|
|||||||
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 ---------- */
|
/* =====================================================
|
||||||
|
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<PlanTextFields> {
|
||||||
|
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] {
|
function hexToRgb(hex?: string | null): [number, number, number] {
|
||||||
if (!hex) return [37, 99, 235]
|
if (!hex) return [37, 99, 235]
|
||||||
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)
|
||||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
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})`
|
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 }) {
|
function ExpandableText({ text, mono = false }: { text?: string | string[] | null; mono?: boolean }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
if (!text || (Array.isArray(text) && text.length === 0)) {
|
if (!text || (Array.isArray(text) && text.length === 0)) {
|
||||||
return <span className="text-neutral-400">—</span>
|
return <span className="text-neutral-400">—</span>
|
||||||
}
|
}
|
||||||
const content = Array.isArray(text) ? text.join("\n• ") : text
|
const content = Array.isArray(text) ? text.join("\n• ") : text
|
||||||
const rendered = Array.isArray(text) ? `• ${content}` : content
|
const rendered = Array.isArray(text) ? `• ${content}` : content
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={`${mono ? "font-mono whitespace-pre-wrap" : ""} text-sm ${open ? "" : "line-clamp-10"}`}>
|
<div className={`${mono ? "font-mono whitespace-pre-wrap" : ""} text-sm ${open ? "" : "line-clamp-10"}`}>{rendered}</div>
|
||||||
{rendered}
|
{String(rendered).length > 220 && (
|
||||||
</div>
|
<button onClick={() => setOpen((v) => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline">
|
||||||
{String(rendered).length > 220 && (
|
{open ? "Ver menos" : "Ver más"}
|
||||||
<button onClick={() => setOpen(v => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline">
|
</button>
|
||||||
{open ? "Ver menos" : "Ver más"}
|
)}
|
||||||
</button>
|
</div>
|
||||||
)}
|
)
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- panel con aurora mesh ---------- */
|
/* =====================================================
|
||||||
function SectionPanel({
|
Section panel
|
||||||
title, icon: Icon, color, children, id,
|
===================================================== */
|
||||||
}: { title: string; icon: any; color?: string | null; children: React.ReactNode; id: string }) {
|
function SectionPanel({ title, icon: Icon, color, children, id }: { title: string; icon: any; color?: string | null; children: React.ReactNode; id: string }) {
|
||||||
const rgb = hexToRgb(color)
|
const rgb = hexToRgb(color)
|
||||||
return (
|
return (
|
||||||
<section id={id} className="relative rounded-3xl border overflow-hidden shadow-sm bg-white/70 dark:bg-neutral-900/60">
|
<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="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
|
<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%)` }} />
|
||||||
className="absolute -top-20 -left-20 w-[28rem] h-[28rem] rounded-full blur-3xl"
|
</div>
|
||||||
style={{ background: `radial-gradient(circle, ${rgba(rgb, .20)}, transparent 60%)` }}
|
<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) }}>
|
||||||
<div
|
<Icon className="w-4 h-4" />
|
||||||
className="absolute -bottom-24 -right-16 w-[24rem] h-[24rem] rounded-full blur-[60px]"
|
</span>
|
||||||
style={{ background: `radial-gradient(circle, ${rgba(rgb, .14)}, transparent 60%)` }}
|
<h3 className="font-semibold">{title}</h3>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div className="relative z-10 p-5">{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<div
|
/* =====================================================
|
||||||
className="relative z-10 px-4 py-3 flex items-center gap-2 border-b"
|
AcademicSections (con React Query)
|
||||||
style={{ background: `linear-gradient(180deg, ${rgba(rgb, .10)}, transparent)` }}
|
===================================================== */
|
||||||
>
|
export function AcademicSections({ planId, color }: { planId: string; color?: string | null }) {
|
||||||
<span
|
const qc = useQueryClient()
|
||||||
className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2 bg-white/80"
|
const { data: plan } = useSuspenseQuery(planTextOptions(planId))
|
||||||
style={{ borderColor: rgba(rgb, .25) }}
|
|
||||||
|
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(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<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-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 (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-5 md:grid-cols-2">
|
||||||
|
{sections.map((s) => {
|
||||||
|
const text = plan[s.key] ?? null
|
||||||
|
return (
|
||||||
|
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
|
||||||
|
<ExpandableText text={text} mono={s.mono} />
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!text || (Array.isArray(text) && text.length === 0)}
|
||||||
|
onClick={() => {
|
||||||
|
const toCopy = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||||
|
if (toCopy) navigator.clipboard.writeText(toCopy)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
Copiar
|
||||||
</span>
|
</Button>
|
||||||
<h3 className="font-semibold">{title}</h3>
|
<Button
|
||||||
</div>
|
variant="ghost"
|
||||||
<div className="relative z-10 p-5">{children}</div>
|
size="sm"
|
||||||
</section>
|
onClick={() => {
|
||||||
)
|
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
||||||
}
|
setEditing({ key: s.key, title: s.title })
|
||||||
|
setDraft(current)
|
||||||
/* ---------- Secciones integradas (sin tabs) ---------- */
|
}}
|
||||||
type PlanTextFields = {
|
>
|
||||||
objetivo_general?: string | string[] | null
|
Editar
|
||||||
sistema_evaluacion?: string | string[] | null
|
</Button>
|
||||||
perfil_ingreso?: string | string[] | null
|
</div>
|
||||||
perfil_egreso?: string | string[] | null
|
</SectionPanel>
|
||||||
competencias_genericas?: string | string[] | null
|
)
|
||||||
competencias_especificas?: string | string[] | null
|
})}
|
||||||
indicadores_desempeno?: string | string[] | null
|
</div>
|
||||||
pertinencia?: string | string[] | null
|
|
||||||
prompt?: string | null
|
{/* Diálogo de edición */}
|
||||||
}
|
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
export function AcademicSections({
|
<DialogHeader>
|
||||||
planId, plan, color,
|
<DialogTitle>{editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}</DialogTitle>
|
||||||
}: { planId: string; plan: PlanTextFields; color?: string | null }) {
|
</DialogHeader>
|
||||||
const [local, setLocal] = useState<PlanTextFields>({ ...plan })
|
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`} placeholder="Escribe aquí…" />
|
||||||
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
|
<DialogFooter>
|
||||||
const [draft, setDraft] = useState("")
|
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
||||||
const [saving, setSaving] = useState(false)
|
<Button
|
||||||
|
onClick={() => {
|
||||||
const sections = useMemo(() => [
|
if (!editing) return
|
||||||
{ id: "sec-obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false },
|
updateField.mutate({ key: editing.key, value: draft })
|
||||||
{ id: "sec-eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false },
|
setEditing(null)
|
||||||
{ 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 },
|
disabled={updateField.isPending}
|
||||||
{ 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 },
|
{updateField.isPending ? "Guardando…" : "Guardar"}
|
||||||
{ id: "sec-ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false },
|
</Button>
|
||||||
{ id: "sec-per", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false },
|
</DialogFooter>
|
||||||
{ id: "sec-prm", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true },
|
</DialogContent>
|
||||||
], [])
|
</Dialog>
|
||||||
|
</>
|
||||||
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 */}
|
|
||||||
<div className="grid gap-5 md:grid-cols-2">
|
|
||||||
{sections.map(s => {
|
|
||||||
const text = local[s.key] ?? null
|
|
||||||
return (
|
|
||||||
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
|
|
||||||
<ExpandableText text={text} mono={s.mono} />
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!text || (Array.isArray(text) && text.length === 0)}
|
|
||||||
onClick={() => {
|
|
||||||
const toCopy = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
|
||||||
if (toCopy) navigator.clipboard.writeText(toCopy)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Copiar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
|
|
||||||
setEditing({ key: s.key, title: s.title })
|
|
||||||
setDraft(current)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Editar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SectionPanel>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Diálogo de edición */}
|
|
||||||
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{editing ? `Editar: ${sections.find(x => x.key === editing.key)?.title}` : ""}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<Textarea
|
|
||||||
value={draft}
|
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
|
||||||
className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`}
|
|
||||||
placeholder="Escribe aquí…"
|
|
||||||
/>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
|
|
||||||
<Button onClick={handleSave} disabled={saving}>{saving ? "Guardando…" : "Guardar"}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/main.tsx
13
src/main.tsx
@@ -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>
|
||||||
<InnerApp />
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|
||||||
|
<InnerApp />
|
||||||
|
</QueryClientProvider>,
|
||||||
</SupabaseAuthProvider>
|
</SupabaseAuthProvider>
|
||||||
</StrictMode>,
|
|
||||||
|
</StrictMode >,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,11 +35,99 @@ 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' | ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================== Query Keys & Options ================== */
|
||||||
|
const asignaturasKeys = {
|
||||||
|
root: ['asignaturas'] as const,
|
||||||
|
list: (search: SearchState) => [...asignaturasKeys.root, { search }] as const,
|
||||||
|
}
|
||||||
|
const planesKeys = {
|
||||||
|
root: ['planes'] as const,
|
||||||
|
all: () => [...planesKeys.root, 'all'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlanIdsByScope(search: Pick<SearchState, 'planId'|'carreraId'|'facultadId'>): Promise<string[] | null> {
|
||||||
|
const { planId, carreraId, facultadId } = search
|
||||||
|
if (planId) return [planId]
|
||||||
|
if (carreraId) {
|
||||||
|
const { data, error } = await supabase.from('plan_estudios').select('id').eq('carrera_id', carreraId)
|
||||||
|
if (error) throw error
|
||||||
|
return (data ?? []).map(p => p.id)
|
||||||
|
}
|
||||||
|
if (facultadId) {
|
||||||
|
const { data: carreras, error: carErr } = await supabase.from('carreras').select('id').eq('facultad_id', facultadId)
|
||||||
|
if (carErr) throw carErr
|
||||||
|
const cIds = (carreras ?? []).map(c => c.id)
|
||||||
|
if (!cIds.length) return []
|
||||||
|
const { data: planesFac, error: plaErr } = await supabase
|
||||||
|
.from('plan_estudios')
|
||||||
|
.select('id')
|
||||||
|
.in('carrera_id', cIds)
|
||||||
|
if (plaErr) throw plaErr
|
||||||
|
return (planesFac ?? []).map(p => p.id)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAsignaturas(search: SearchState): Promise<Asignatura[]> {
|
||||||
|
const planIds = await fetchPlanIdsByScope(search)
|
||||||
|
if (planIds && planIds.length === 0) return []
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.select(`
|
||||||
|
id, nombre, clave, tipo, semestre, creditos, horas_teoricas, horas_practicas,
|
||||||
|
objetivos, contenidos, bibliografia, criterios_evaluacion, fecha_creacion, plan_id,
|
||||||
|
plan:plan_estudios (
|
||||||
|
id, nombre,
|
||||||
|
carrera:carreras (
|
||||||
|
id, nombre,
|
||||||
|
facultad:facultades ( id, nombre, color, icon )
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.order('semestre', { ascending: true })
|
||||||
|
.order('nombre', { ascending: true })
|
||||||
|
if (planIds) query = query.in('plan_id', planIds)
|
||||||
|
const { data, error } = await query
|
||||||
|
if (error) throw error
|
||||||
|
return (data ?? []) as unknown as Asignatura[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlanes(): Promise<PlanMini[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('plan_estudios')
|
||||||
|
.select(`
|
||||||
|
id, nombre,
|
||||||
|
carrera:carreras(
|
||||||
|
id, nombre,
|
||||||
|
facultad:facultades(id, nombre, color, icon)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.order('nombre', { ascending: true })
|
||||||
|
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 ================== */
|
/* ================== Ruta ================== */
|
||||||
export const Route = createFileRoute('/_authenticated/asignaturas')({
|
export const Route = createFileRoute('/_authenticated/asignaturas')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
@@ -53,94 +141,26 @@ export const Route = createFileRoute('/_authenticated/asignaturas')({
|
|||||||
f: (search.f as 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | '') ?? '',
|
f: (search.f as 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | '') ?? '',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
loader: async (ctx): Promise<LoaderData> => {
|
loader: async ({ context: { queryClient }, location }) => {
|
||||||
const search = (ctx.location?.search ?? {}) as {
|
const search = (location?.search ?? {}) as SearchState
|
||||||
q?: string
|
// Pre-hydrate ambas queries con QueryClient (sin llamadas "sueltas" aquí)
|
||||||
planId?: string
|
await Promise.all([
|
||||||
carreraId?: string
|
queryClient.ensureQueryData(asignaturasOptions(search)),
|
||||||
facultadId?: string
|
queryClient.ensureQueryData(planesOptions()),
|
||||||
f?: 'sinBibliografia' | 'sinCriterios' | 'sinContenidos' | ''
|
])
|
||||||
}
|
return null
|
||||||
|
|
||||||
const { planId, carreraId, facultadId } = search
|
|
||||||
let planIds: string[] | null = null
|
|
||||||
|
|
||||||
if (planId) {
|
|
||||||
planIds = [planId]
|
|
||||||
} else if (carreraId) {
|
|
||||||
const { data: planesCar, error } = await supabase
|
|
||||||
.from('plan_estudios')
|
|
||||||
.select('id')
|
|
||||||
.eq('carrera_id', carreraId)
|
|
||||||
if (error) throw error
|
|
||||||
planIds = (planesCar ?? []).map(p => p.id)
|
|
||||||
} else if (facultadId) {
|
|
||||||
const { data: carreras, error: carErr } = await supabase
|
|
||||||
.from('carreras')
|
|
||||||
.select('id')
|
|
||||||
.eq('facultad_id', facultadId)
|
|
||||||
if (carErr) throw carErr
|
|
||||||
const cIds = (carreras ?? []).map(c => c.id)
|
|
||||||
if (!cIds.length) {
|
|
||||||
return { asignaturas: [], planes: [] }
|
|
||||||
}
|
|
||||||
const { data: planesFac, error: plaErr } = await supabase
|
|
||||||
.from('plan_estudios')
|
|
||||||
.select('id, nombre, carrera:carreras(id, nombre, facultad:facultades(id, nombre, color, icon))')
|
|
||||||
.in('carrera_id', cIds)
|
|
||||||
if (plaErr) throw plaErr
|
|
||||||
planIds = (planesFac ?? []).map(p => p.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (planIds && planIds.length === 0) {
|
|
||||||
return { asignaturas: [], planes: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Traer asignaturas
|
|
||||||
let query = supabase
|
|
||||||
.from('asignaturas')
|
|
||||||
.select(`
|
|
||||||
id, nombre, clave, tipo, semestre, creditos, horas_teoricas, horas_practicas,
|
|
||||||
objetivos, contenidos, bibliografia, criterios_evaluacion, fecha_creacion, plan_id,
|
|
||||||
plan:plan_estudios (
|
|
||||||
id, nombre,
|
|
||||||
carrera:carreras (
|
|
||||||
id, nombre,
|
|
||||||
facultad:facultades ( id, nombre, color, icon )
|
|
||||||
)
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
.order('semestre', { ascending: true })
|
|
||||||
.order('nombre', { ascending: true })
|
|
||||||
if (planIds) query = query.in('plan_id', planIds)
|
|
||||||
const { data, error: aErr } = await query
|
|
||||||
if (aErr) throw aErr
|
|
||||||
|
|
||||||
// Traer planes (para selector destino)
|
|
||||||
const { data: planesAll, error: pErr } = await supabase
|
|
||||||
.from('plan_estudios')
|
|
||||||
.select(`
|
|
||||||
id, nombre,
|
|
||||||
carrera:carreras(
|
|
||||||
id, nombre,
|
|
||||||
facultad:facultades(id, nombre, color, icon)
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
.order('nombre', { ascending: true })
|
|
||||||
if (pErr) throw pErr
|
|
||||||
|
|
||||||
return {
|
|
||||||
asignaturas: (data ?? []) as unknown as Asignatura[],
|
|
||||||
planes: (planesAll ?? []) as unknown as PlanMini[],
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ================== 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)
|
||||||
|
|||||||
@@ -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 =
|
||||||
? supabase.from("carreras").insert([payload]).select("id").single()
|
mode === "create"
|
||||||
: supabase.from("carreras").update(payload).eq("id", carrera!.id).select("id").single()
|
? supabase.from("carreras").insert([payload]).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(),
|
{
|
||||||
tipo: tipo || null,
|
nombre: nombre.trim(),
|
||||||
descripcion: descripcion || null,
|
tipo: tipo || null,
|
||||||
obligatorio,
|
descripcion: descripcion || null,
|
||||||
referencia_documento: referencia || null,
|
obligatorio,
|
||||||
carrera_id: carreraId,
|
referencia_documento: referencia || null,
|
||||||
}])
|
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>
|
||||||
|
|||||||
@@ -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,78 +36,86 @@ 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 ========= */
|
||||||
|
const dashboardKeys = {
|
||||||
|
root: ['dashboard'] as const,
|
||||||
|
summary: () => [...dashboardKeys.root, 'summary'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDashboard(): Promise<LoaderData> {
|
||||||
|
const [facRes, carRes, planesRes, asigRes] = await Promise.all([
|
||||||
|
supabase.from('facultades').select('*', { count: 'exact', head: true }),
|
||||||
|
supabase.from('carreras').select('*', { count: 'exact', head: true }),
|
||||||
|
supabase
|
||||||
|
.from('plan_estudios')
|
||||||
|
.select(
|
||||||
|
'id, nombre, fecha_creacion, objetivo_general, perfil_ingreso, perfil_egreso, sistema_evaluacion, total_creditos'
|
||||||
|
),
|
||||||
|
supabase
|
||||||
|
.from('asignaturas')
|
||||||
|
.select('id, nombre, fecha_creacion, contenidos, criterios_evaluacion, bibliografia'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const planes = (planesRes.data ?? []) as Plan[]
|
||||||
|
const asignaturas = (asigRes.data ?? []) as Asignatura[]
|
||||||
|
|
||||||
|
// Calidad de planes
|
||||||
|
const needed: (keyof Plan)[] = [
|
||||||
|
'objetivo_general',
|
||||||
|
'perfil_ingreso',
|
||||||
|
'perfil_egreso',
|
||||||
|
'sistema_evaluacion',
|
||||||
|
'total_creditos',
|
||||||
|
]
|
||||||
|
const completos = planes.filter((p) => needed.every((k) => p[k] !== null && String(p[k] ?? '').trim() !== '')).length
|
||||||
|
const calidadPlanesPct = planes.length ? Math.round((completos / planes.length) * 100) : 0
|
||||||
|
|
||||||
|
// Salud de asignaturas
|
||||||
|
const sinBibliografia = asignaturas.filter(
|
||||||
|
(a) => !a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0)
|
||||||
|
).length
|
||||||
|
const sinCriterios = asignaturas.filter((a) => !a.criterios_evaluacion?.trim()).length
|
||||||
|
const sinContenidos = asignaturas.filter(
|
||||||
|
(a) => !a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0)
|
||||||
|
).length
|
||||||
|
|
||||||
|
// Actividad reciente (últimos 8 ítems)
|
||||||
|
const recientes = [
|
||||||
|
...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 })),
|
||||||
|
]
|
||||||
|
.sort((a, b) => new Date(b.fecha ?? 0).getTime() - new Date(a.fecha ?? 0).getTime())
|
||||||
|
.slice(0, 8)
|
||||||
|
|
||||||
|
return {
|
||||||
|
kpis: {
|
||||||
|
facultades: facRes.count ?? 0,
|
||||||
|
carreras: carRes.count ?? 0,
|
||||||
|
planes: planes.length,
|
||||||
|
asignaturas: asignaturas.length,
|
||||||
|
},
|
||||||
|
calidadPlanesPct,
|
||||||
|
saludAsignaturas: { sinBibliografia, sinCriterios, sinContenidos },
|
||||||
|
recientes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardOptions = () =>
|
||||||
|
queryOptions({ queryKey: dashboardKeys.summary(), queryFn: fetchDashboard, staleTime: 30_000 })
|
||||||
|
|
||||||
|
/* ========= Ruta ========= */
|
||||||
export const Route = createFileRoute('/_authenticated/dashboard')({
|
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
pendingComponent: DashboardSkeleton,
|
pendingComponent: DashboardSkeleton,
|
||||||
loader: async (): Promise<LoaderData> => {
|
loader: async ({ context: { queryClient } }) => {
|
||||||
// KPI counts
|
await queryClient.ensureQueryData(dashboardOptions())
|
||||||
const [{ count: facCount }, { count: carCount }, { data: planesRaw }, { data: asignRaw }] =
|
return null
|
||||||
await Promise.all([
|
},
|
||||||
supabase.from('facultades').select('*', { count: 'exact', head: true }),
|
|
||||||
supabase.from('carreras').select('*', { count: 'exact', head: true }),
|
|
||||||
supabase
|
|
||||||
.from('plan_estudios')
|
|
||||||
.select(
|
|
||||||
'id, nombre, fecha_creacion, objetivo_general, perfil_ingreso, perfil_egreso, sistema_evaluacion, total_creditos'
|
|
||||||
),
|
|
||||||
supabase
|
|
||||||
.from('asignaturas')
|
|
||||||
.select('id, nombre, fecha_creacion, contenidos, criterios_evaluacion, bibliografia')
|
|
||||||
])
|
|
||||||
|
|
||||||
const planes = (planesRaw ?? []) as Plan[]
|
|
||||||
const asignaturas = (asignRaw ?? []) as Asignatura[]
|
|
||||||
|
|
||||||
// Calidad de planes
|
|
||||||
const needed: (keyof Plan)[] = [
|
|
||||||
'objetivo_general',
|
|
||||||
'perfil_ingreso',
|
|
||||||
'perfil_egreso',
|
|
||||||
'sistema_evaluacion',
|
|
||||||
'total_creditos'
|
|
||||||
]
|
|
||||||
const completos = planes.filter(p =>
|
|
||||||
needed.every(k => p[k] !== null && String(p[k] ?? '').toString().trim() !== '')
|
|
||||||
).length
|
|
||||||
const calidadPlanesPct = planes.length ? Math.round((completos / planes.length) * 100) : 0
|
|
||||||
|
|
||||||
// Salud de asignaturas
|
|
||||||
const sinBibliografia = asignaturas.filter(
|
|
||||||
a => !a.bibliografia || (Array.isArray(a.bibliografia) && a.bibliografia.length === 0)
|
|
||||||
).length
|
|
||||||
const sinCriterios = asignaturas.filter(a => !a.criterios_evaluacion?.trim()).length
|
|
||||||
const sinContenidos = asignaturas.filter(
|
|
||||||
a => !a.contenidos || (Array.isArray(a.contenidos) && a.contenidos.length === 0)
|
|
||||||
).length
|
|
||||||
|
|
||||||
// Actividad reciente (últimos 8 ítems)
|
|
||||||
const recientes = [
|
|
||||||
...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 }))
|
|
||||||
]
|
|
||||||
.sort((a, b) => new Date(b.fecha ?? 0).getTime() - new Date(a.fecha ?? 0).getTime())
|
|
||||||
.slice(0, 8)
|
|
||||||
|
|
||||||
return {
|
|
||||||
kpis: {
|
|
||||||
facultades: facCount ?? 0,
|
|
||||||
carreras: carCount ?? 0,
|
|
||||||
planes: planes.length,
|
|
||||||
asignaturas: asignaturas.length
|
|
||||||
},
|
|
||||||
calidadPlanesPct,
|
|
||||||
saludAsignaturas: { sinBibliografia, sinCriterios, sinContenidos },
|
|
||||||
recientes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ========= 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,10 +344,9 @@ 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>
|
||||||
<span className="text-lg font-semibold tabular-nums">{value}</span>
|
<span className="text-lg font-semibold tabular-nums">{value}</span>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------- Query Keys & Fetchers -------------------- */
|
||||||
|
const facultadKeys = {
|
||||||
|
root: ['facultades'] as const,
|
||||||
|
all: () => [...facultadKeys.root, 'all'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFacultades(): Promise<Facultad[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('facultades')
|
||||||
|
.select('id, nombre, icon, color')
|
||||||
|
.order('nombre')
|
||||||
|
if (error) throw error
|
||||||
|
return (data ?? []) as Facultad[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const facultadesOptions = () =>
|
||||||
|
queryOptions({ queryKey: facultadKeys.all(), queryFn: fetchFacultades, staleTime: 60_000 })
|
||||||
|
|
||||||
|
/* -------------------- Ruta -------------------- */
|
||||||
export const Route = createFileRoute('/_authenticated/facultades')({
|
export const Route = createFileRoute('/_authenticated/facultades')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
loader: async () => {
|
loader: async ({ context: { queryClient } }) => {
|
||||||
const { data, error } = await supabase
|
await queryClient.ensureQueryData(facultadesOptions())
|
||||||
.from('facultades')
|
return null
|
||||||
.select('id, nombre, icon, color')
|
|
||||||
.order('nombre')
|
|
||||||
if (error) {
|
|
||||||
console.error(error)
|
|
||||||
return { facultades: [] as Facultad[] }
|
|
||||||
}
|
|
||||||
return { facultades: (data ?? []) as Facultad[] }
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[] }
|
|
||||||
|
type LoaderData = { planId: string }
|
||||||
|
|
||||||
|
/* ---------- Query option builders ---------- */
|
||||||
|
function planByIdOptions(planId: string) {
|
||||||
|
return {
|
||||||
|
queryKey: planKeys.byId(planId),
|
||||||
|
queryFn: async (): Promise<PlanFull> => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("plan_estudios")
|
||||||
|
.select(`
|
||||||
|
id, nombre, nivel, objetivo_general, perfil_ingreso, perfil_egreso, duracion, total_creditos,
|
||||||
|
competencias_genericas, competencias_especificas, sistema_evaluacion, indicadores_desempeno,
|
||||||
|
pertinencia, prompt, estado, fecha_creacion,
|
||||||
|
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
|
||||||
|
`)
|
||||||
|
.eq("id", planId)
|
||||||
|
.maybeSingle()
|
||||||
|
if (error || !data) throw error ?? new Error("Plan no encontrado")
|
||||||
|
return data as unknown as PlanFull
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
function asignaturasCountOptions(planId: string) {
|
||||||
|
return {
|
||||||
|
queryKey: asignaturaKeys.count(planId),
|
||||||
|
queryFn: async (): Promise<number> => {
|
||||||
|
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 ============== */
|
/* ============== ROUTE ============== */
|
||||||
export const Route = createFileRoute('/_authenticated/plan/$planId')({
|
export const Route = createFileRoute("/_authenticated/plan/$planId")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
pendingComponent: PageSkeleton,
|
pendingComponent: PageSkeleton,
|
||||||
loader: async ({ params }): Promise<LoaderData> => {
|
loader: async ({ params, context: { queryClient } }): Promise<LoaderData> => {
|
||||||
const { data: plan, error } = await supabase
|
const { planId } = params
|
||||||
.from('plan_estudios')
|
// Prefetch/ensure all queries needed for the page
|
||||||
.select(`
|
await Promise.all([
|
||||||
id, nombre, nivel, objetivo_general, perfil_ingreso, perfil_egreso, duracion, total_creditos,
|
queryClient.ensureQueryData(planByIdOptions(planId)),
|
||||||
competencias_genericas, competencias_especificas, sistema_evaluacion, indicadores_desempeno,
|
queryClient.ensureQueryData(asignaturasCountOptions(planId)),
|
||||||
pertinencia, prompt, estado, fecha_creacion,
|
queryClient.ensureQueryData(asignaturasPreviewOptions(planId)),
|
||||||
carreras ( id, nombre, facultades:facultades ( id, nombre, color, icon ) )
|
])
|
||||||
`)
|
return { planId }
|
||||||
.eq('id', params.planId)
|
|
||||||
.single()
|
|
||||||
if (error || !plan) throw error ?? new Error('Plan no encontrado')
|
|
||||||
|
|
||||||
const { count } = await supabase
|
|
||||||
.from('asignaturas')
|
|
||||||
.select('*', { count: 'exact', head: true })
|
|
||||||
.eq('plan_id', params.planId)
|
|
||||||
|
|
||||||
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 {
|
|
||||||
plan: plan as unknown as PlanFull,
|
|
||||||
asignaturasCount: count ?? 0,
|
|
||||||
asignaturasPreview: (asignaturasPreview ?? []) as AsignaturaLite[],
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -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="1–10" /></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="1–10" />
|
|
||||||
</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="1–10" /></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="1–10" />
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 ---------- */
|
||||||
// NEW: helpers nombramientos
|
const invalidateAll = async () => {
|
||||||
async function upsertNombramiento(opts: {
|
await qc.invalidateQueries({ queryKey: usersKeys.root })
|
||||||
user_id: string,
|
router.invalidate()
|
||||||
puesto: "director_facultad" | "secretario_academico" | "jefe_carrera",
|
|
||||||
facultad_id?: string | null,
|
|
||||||
carrera_id?: string | null
|
|
||||||
}) {
|
|
||||||
// cierra vigentes del mismo scope y puesto
|
|
||||||
if (opts.puesto === "jefe_carrera") {
|
|
||||||
if (!opts.carrera_id) throw new Error("Selecciona carrera")
|
|
||||||
await supabase.from("nombramientos")
|
|
||||||
.update({ hasta: new Date().toISOString().slice(0, 10) })
|
|
||||||
.eq("puesto", "jefe_carrera")
|
|
||||||
.eq("carrera_id", opts.carrera_id)
|
|
||||||
.is("hasta", null)
|
|
||||||
} else {
|
|
||||||
if (!opts.facultad_id) throw new Error("Selecciona facultad")
|
|
||||||
await supabase.from("nombramientos")
|
|
||||||
.update({ hasta: new Date().toISOString().slice(0, 10) })
|
|
||||||
.eq("puesto", opts.puesto)
|
|
||||||
.eq("facultad_id", opts.facultad_id)
|
|
||||||
.is("hasta", null)
|
|
||||||
}
|
|
||||||
// inserta vigente
|
|
||||||
const { error } = await supabase.from("nombramientos").insert({
|
|
||||||
user_id: opts.user_id,
|
|
||||||
puesto: opts.puesto,
|
|
||||||
facultad_id: opts.facultad_id ?? null,
|
|
||||||
carrera_id: opts.carrera_id ?? null,
|
|
||||||
desde: new Date().toISOString().slice(0, 10),
|
|
||||||
hasta: null
|
|
||||||
})
|
|
||||||
if (error) throw error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: ban/unban directo (deja que el trigger “rebalance” haga lo suyo)
|
const upsertNombramiento = useMutation({
|
||||||
async function toggleBan(u: AdminUser) {
|
mutationFn: async (opts: {
|
||||||
try {
|
user_id: string
|
||||||
const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
|
puesto: "director_facultad" | "secretario_academico" | "jefe_carrera"
|
||||||
const payload = banned
|
facultad_id?: string | null
|
||||||
? { banned_until: null }
|
carrera_id?: string | 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)
|
// cierra vigentes
|
||||||
|
if (opts.puesto === "jefe_carrera") {
|
||||||
|
if (!opts.carrera_id) throw new Error("Selecciona carrera")
|
||||||
|
await supabase
|
||||||
|
.from("nombramientos")
|
||||||
|
.update({ hasta: new Date().toISOString().slice(0, 10) })
|
||||||
|
.eq("puesto", "jefe_carrera")
|
||||||
|
.eq("carrera_id", opts.carrera_id)
|
||||||
|
.is("hasta", null)
|
||||||
|
} else {
|
||||||
|
if (!opts.facultad_id) throw new Error("Selecciona facultad")
|
||||||
|
await supabase
|
||||||
|
.from("nombramientos")
|
||||||
|
.update({ hasta: new Date().toISOString().slice(0, 10) })
|
||||||
|
.eq("puesto", opts.puesto)
|
||||||
|
.eq("facultad_id", opts.facultad_id)
|
||||||
|
.is("hasta", null)
|
||||||
|
}
|
||||||
|
const { error } = await supabase.from("nombramientos").insert({
|
||||||
|
user_id: opts.user_id,
|
||||||
|
puesto: opts.puesto,
|
||||||
|
facultad_id: opts.facultad_id ?? null,
|
||||||
|
carrera_id: opts.carrera_id ?? null,
|
||||||
|
desde: new Date().toISOString().slice(0, 10),
|
||||||
|
hasta: null,
|
||||||
|
})
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
toast.success(banned ? "Usuario desbaneado" : "Usuario baneado")
|
},
|
||||||
router.invalidate()
|
onError: (e: any) => toast.error(e?.message || "Error al registrar nombramiento"),
|
||||||
} catch (e: any) {
|
})
|
||||||
console.error(e)
|
|
||||||
toast.error(e?.message || "Error al cambiar estado de baneo")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createUserNow() {
|
const toggleBan = useMutation({
|
||||||
if (!createForm.email?.trim()) { toast.error("Correo requerido"); return }
|
mutationFn: async (u: AdminUser) => {
|
||||||
try {
|
const banned = !!u.banned_until && new Date(u.banned_until) > new Date()
|
||||||
const adminClient = new SupabaseClient(
|
const payload = banned ? { banned_until: null } : { banned_until: new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString() }
|
||||||
import.meta.env.VITE_SUPABASE_URL,
|
const { error } = await supabase.auth.admin.updateUserById(u.id, payload as any)
|
||||||
import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY
|
if (error) throw new Error(error.message)
|
||||||
)
|
return !banned
|
||||||
|
},
|
||||||
|
onSuccess: async (isBanned) => {
|
||||||
|
toast.success(isBanned ? "Usuario baneado" : "Usuario desbaneado")
|
||||||
|
await invalidateAll()
|
||||||
|
},
|
||||||
|
onError: (e: any) => toast.error(e?.message || "Error al cambiar estado de baneo"),
|
||||||
|
})
|
||||||
|
|
||||||
setCreateSaving(true)
|
const createUser = useMutation({
|
||||||
const password = createForm.password?.trim() || genPassword()
|
mutationFn: async (payload: typeof createForm) => {
|
||||||
const { error, data } = await adminClient.auth.admin.createUser({
|
const admin = new SupabaseClient(import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_SERVICE_ROLE_KEY)
|
||||||
email: createForm.email.trim(),
|
const password = payload.password?.trim() || genPassword()
|
||||||
|
const { error, data } = await admin.auth.admin.createUser({
|
||||||
|
email: payload.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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user