feat: add AI-generated study plan creation dialog and API integration
- Implemented CreatePlanDialog component for generating study plans using AI. - Integrated postAPI function for handling API requests. - Updated planes.tsx to include AI plan generation logic. - Modified usuarios.tsx to enable email confirmation for new users. - Added Switch component for UI consistency. - Created api.ts for centralized API handling. - Developed carreras.tsx for managing career data with filtering and CRUD operations. - Added CarreraFormDialog and CarreraDetailDialog for creating and editing career details. - Implemented CriterioFormDialog for adding criteria to careers.
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -14,6 +14,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@supabase/supabase-js": "^2.55.0",
|
"@supabase/supabase-js": "^2.55.0",
|
||||||
"@tailwindcss/vite": "^4.1.12",
|
"@tailwindcss/vite": "^4.1.12",
|
||||||
@@ -255,6 +256,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||||
|
|
||||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@supabase/supabase-js": "^2.55.0",
|
"@supabase/supabase-js": "^2.55.0",
|
||||||
"@tailwindcss/vite": "^4.1.12",
|
"@tailwindcss/vite": "^4.1.12",
|
||||||
|
|||||||
29
src/components/ui/switch.tsx
Normal file
29
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
34
src/lib/api.ts
Normal file
34
src/lib/api.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// api.ts
|
||||||
|
const API_BASE =
|
||||||
|
(import.meta.env.VITE_API_BASE?.replace(/\/$/, "")) ||
|
||||||
|
"http://localhost:3001"; // 👈 tu Bun.serve real
|
||||||
|
|
||||||
|
export async function postAPI<T=any>(path: string, body: any): Promise<T> {
|
||||||
|
const url = `${API_BASE}${path}`;
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
// Si necesitas cookies, agrega: credentials: "include",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ct = r.headers.get("content-type") || "";
|
||||||
|
const isHTML = ct.includes("text/html");
|
||||||
|
const text = await r.text();
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error(
|
||||||
|
isHTML
|
||||||
|
? "El servidor respondió HTML (probable 404 del dashboard/puerto 3000). Revisa API_BASE y el puerto."
|
||||||
|
: text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si el server devolvió JSON correctamente:
|
||||||
|
if (ct.includes("application/json")) return JSON.parse(text) as T;
|
||||||
|
|
||||||
|
// Último recurso: intenta parsear
|
||||||
|
try { return JSON.parse(text) as T } catch {
|
||||||
|
throw new Error("Respuesta no-JSON desde la API.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { Route as AuthenticatedUsuariosRouteImport } from './routes/_authenticat
|
|||||||
import { Route as AuthenticatedPlanesRouteImport } from './routes/_authenticated/planes'
|
import { Route as AuthenticatedPlanesRouteImport } from './routes/_authenticated/planes'
|
||||||
import { Route as AuthenticatedFacultadesRouteImport } from './routes/_authenticated/facultades'
|
import { Route as AuthenticatedFacultadesRouteImport } from './routes/_authenticated/facultades'
|
||||||
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
|
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
|
||||||
|
import { Route as AuthenticatedCarrerasRouteImport } from './routes/_authenticated/carreras'
|
||||||
import { Route as AuthenticatedAsignaturasRouteImport } from './routes/_authenticated/asignaturas'
|
import { Route as AuthenticatedAsignaturasRouteImport } from './routes/_authenticated/asignaturas'
|
||||||
import { Route as AuthenticatedArchivosRouteImport } from './routes/_authenticated/archivos'
|
import { Route as AuthenticatedArchivosRouteImport } from './routes/_authenticated/archivos'
|
||||||
import { Route as AuthenticatedPlanPlanIdRouteImport } from './routes/_authenticated/plan/$planId'
|
import { Route as AuthenticatedPlanPlanIdRouteImport } from './routes/_authenticated/plan/$planId'
|
||||||
@@ -57,6 +58,11 @@ const AuthenticatedDashboardRoute = AuthenticatedDashboardRouteImport.update({
|
|||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthenticatedCarrerasRoute = AuthenticatedCarrerasRouteImport.update({
|
||||||
|
id: '/carreras',
|
||||||
|
path: '/carreras',
|
||||||
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
|
} as any)
|
||||||
const AuthenticatedAsignaturasRoute =
|
const AuthenticatedAsignaturasRoute =
|
||||||
AuthenticatedAsignaturasRouteImport.update({
|
AuthenticatedAsignaturasRouteImport.update({
|
||||||
id: '/asignaturas',
|
id: '/asignaturas',
|
||||||
@@ -97,6 +103,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/archivos': typeof AuthenticatedArchivosRoute
|
'/archivos': typeof AuthenticatedArchivosRoute
|
||||||
'/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
|
'/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
|
||||||
|
'/carreras': typeof AuthenticatedCarrerasRoute
|
||||||
'/dashboard': typeof AuthenticatedDashboardRoute
|
'/dashboard': typeof AuthenticatedDashboardRoute
|
||||||
'/facultades': typeof AuthenticatedFacultadesRoute
|
'/facultades': typeof AuthenticatedFacultadesRoute
|
||||||
'/planes': typeof AuthenticatedPlanesRoute
|
'/planes': typeof AuthenticatedPlanesRoute
|
||||||
@@ -111,6 +118,7 @@ export interface FileRoutesByTo {
|
|||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/archivos': typeof AuthenticatedArchivosRoute
|
'/archivos': typeof AuthenticatedArchivosRoute
|
||||||
'/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
|
'/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
|
||||||
|
'/carreras': typeof AuthenticatedCarrerasRoute
|
||||||
'/dashboard': typeof AuthenticatedDashboardRoute
|
'/dashboard': typeof AuthenticatedDashboardRoute
|
||||||
'/facultades': typeof AuthenticatedFacultadesRoute
|
'/facultades': typeof AuthenticatedFacultadesRoute
|
||||||
'/planes': typeof AuthenticatedPlanesRoute
|
'/planes': typeof AuthenticatedPlanesRoute
|
||||||
@@ -127,6 +135,7 @@ export interface FileRoutesById {
|
|||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/_authenticated/archivos': typeof AuthenticatedArchivosRoute
|
'/_authenticated/archivos': typeof AuthenticatedArchivosRoute
|
||||||
'/_authenticated/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
|
'/_authenticated/asignaturas': typeof AuthenticatedAsignaturasRouteWithChildren
|
||||||
|
'/_authenticated/carreras': typeof AuthenticatedCarrerasRoute
|
||||||
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
|
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
|
||||||
'/_authenticated/facultades': typeof AuthenticatedFacultadesRoute
|
'/_authenticated/facultades': typeof AuthenticatedFacultadesRoute
|
||||||
'/_authenticated/planes': typeof AuthenticatedPlanesRoute
|
'/_authenticated/planes': typeof AuthenticatedPlanesRoute
|
||||||
@@ -143,6 +152,7 @@ export interface FileRouteTypes {
|
|||||||
| '/login'
|
| '/login'
|
||||||
| '/archivos'
|
| '/archivos'
|
||||||
| '/asignaturas'
|
| '/asignaturas'
|
||||||
|
| '/carreras'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/facultades'
|
| '/facultades'
|
||||||
| '/planes'
|
| '/planes'
|
||||||
@@ -157,6 +167,7 @@ export interface FileRouteTypes {
|
|||||||
| '/login'
|
| '/login'
|
||||||
| '/archivos'
|
| '/archivos'
|
||||||
| '/asignaturas'
|
| '/asignaturas'
|
||||||
|
| '/carreras'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/facultades'
|
| '/facultades'
|
||||||
| '/planes'
|
| '/planes'
|
||||||
@@ -172,6 +183,7 @@ export interface FileRouteTypes {
|
|||||||
| '/login'
|
| '/login'
|
||||||
| '/_authenticated/archivos'
|
| '/_authenticated/archivos'
|
||||||
| '/_authenticated/asignaturas'
|
| '/_authenticated/asignaturas'
|
||||||
|
| '/_authenticated/carreras'
|
||||||
| '/_authenticated/dashboard'
|
| '/_authenticated/dashboard'
|
||||||
| '/_authenticated/facultades'
|
| '/_authenticated/facultades'
|
||||||
| '/_authenticated/planes'
|
| '/_authenticated/planes'
|
||||||
@@ -239,6 +251,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedDashboardRouteImport
|
preLoaderRoute: typeof AuthenticatedDashboardRouteImport
|
||||||
parentRoute: typeof AuthenticatedRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
|
'/_authenticated/carreras': {
|
||||||
|
id: '/_authenticated/carreras'
|
||||||
|
path: '/carreras'
|
||||||
|
fullPath: '/carreras'
|
||||||
|
preLoaderRoute: typeof AuthenticatedCarrerasRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRoute
|
||||||
|
}
|
||||||
'/_authenticated/asignaturas': {
|
'/_authenticated/asignaturas': {
|
||||||
id: '/_authenticated/asignaturas'
|
id: '/_authenticated/asignaturas'
|
||||||
path: '/asignaturas'
|
path: '/asignaturas'
|
||||||
@@ -301,6 +320,7 @@ const AuthenticatedAsignaturasRouteWithChildren =
|
|||||||
interface AuthenticatedRouteChildren {
|
interface AuthenticatedRouteChildren {
|
||||||
AuthenticatedArchivosRoute: typeof AuthenticatedArchivosRoute
|
AuthenticatedArchivosRoute: typeof AuthenticatedArchivosRoute
|
||||||
AuthenticatedAsignaturasRoute: typeof AuthenticatedAsignaturasRouteWithChildren
|
AuthenticatedAsignaturasRoute: typeof AuthenticatedAsignaturasRouteWithChildren
|
||||||
|
AuthenticatedCarrerasRoute: typeof AuthenticatedCarrerasRoute
|
||||||
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
|
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
|
||||||
AuthenticatedFacultadesRoute: typeof AuthenticatedFacultadesRoute
|
AuthenticatedFacultadesRoute: typeof AuthenticatedFacultadesRoute
|
||||||
AuthenticatedPlanesRoute: typeof AuthenticatedPlanesRoute
|
AuthenticatedPlanesRoute: typeof AuthenticatedPlanesRoute
|
||||||
@@ -313,6 +333,7 @@ interface AuthenticatedRouteChildren {
|
|||||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||||
AuthenticatedArchivosRoute: AuthenticatedArchivosRoute,
|
AuthenticatedArchivosRoute: AuthenticatedArchivosRoute,
|
||||||
AuthenticatedAsignaturasRoute: AuthenticatedAsignaturasRouteWithChildren,
|
AuthenticatedAsignaturasRoute: AuthenticatedAsignaturasRouteWithChildren,
|
||||||
|
AuthenticatedCarrerasRoute: AuthenticatedCarrerasRoute,
|
||||||
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
|
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
|
||||||
AuthenticatedFacultadesRoute: AuthenticatedFacultadesRoute,
|
AuthenticatedFacultadesRoute: AuthenticatedFacultadesRoute,
|
||||||
AuthenticatedPlanesRoute: AuthenticatedPlanesRoute,
|
AuthenticatedPlanesRoute: AuthenticatedPlanesRoute,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
KeySquare,
|
KeySquare,
|
||||||
IdCard,
|
IdCard,
|
||||||
Users2Icon,
|
Users2Icon,
|
||||||
|
FileAxis3D,
|
||||||
|
FolderCheck,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useSupabaseAuth } from "@/auth/supabase"
|
import { useSupabaseAuth } from "@/auth/supabase"
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ const nav = [
|
|||||||
{ to: "/asignaturas", label: "Asignaturas", icon: FileText },
|
{ to: "/asignaturas", label: "Asignaturas", icon: FileText },
|
||||||
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
{ to: "/usuarios", label: "Usuarios", icon: Users2Icon },
|
{ to: "/usuarios", label: "Usuarios", icon: Users2Icon },
|
||||||
|
{ to: "/archivos", label: "Archivos de referencia", icon: FileAxis3D },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function getInitials(name?: string) {
|
function getInitials(name?: string) {
|
||||||
@@ -60,6 +63,7 @@ function useUserDisplay() {
|
|||||||
const fullName = [titulo, nombre, apellidos].filter(Boolean).join(" ")
|
const fullName = [titulo, nombre, apellidos].filter(Boolean).join(" ")
|
||||||
const shortName = [titulo, nombre, apellidos.split(" ")[0] ?? ""].filter(Boolean).join(" ")
|
const shortName = [titulo, nombre, apellidos.split(" ")[0] ?? ""].filter(Boolean).join(" ")
|
||||||
const role = claims?.role ?? ""
|
const role = claims?.role ?? ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fullName,
|
fullName,
|
||||||
shortName,
|
shortName,
|
||||||
@@ -90,7 +94,7 @@ function Layout() {
|
|||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="left" className="p-0">
|
<SheetContent side="left" className="p-0">
|
||||||
<Sidebar onNavigate={() => {}} />
|
<Sidebar onNavigate={() => { }} />
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
@@ -145,6 +149,9 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
const { claims } = useSupabaseAuth()
|
const { claims } = useSupabaseAuth()
|
||||||
const isAdmin = Boolean(claims?.claims_admin)
|
const isAdmin = Boolean(claims?.claims_admin)
|
||||||
|
|
||||||
|
const canSeeCarreras = ["jefe_carrera", 'vicerrectoria', 'secretario_academico', 'lci'].includes(claims?.role ?? '')
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<div className="flex items-center gap-2 p-4 md:hidden">
|
<div className="flex items-center gap-2 p-4 md:hidden">
|
||||||
@@ -171,6 +178,18 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{canSeeCarreras && (
|
||||||
|
<Link
|
||||||
|
to="/carreras"
|
||||||
|
activeOptions={{ exact: true }}
|
||||||
|
activeProps={{ className: "bg-primary/10 text-foreground" }}
|
||||||
|
className="group inline-flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-muted-foreground hover:bg-primary/10 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<FolderCheck className="h-4 w-4" />
|
||||||
|
<span className="truncate">Carreras</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Link
|
<Link
|
||||||
to="/facultades"
|
to="/facultades"
|
||||||
|
|||||||
@@ -1,9 +1,394 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
// routes/_authenticated/archivos.tsx
|
||||||
|
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { supabase } from "@/auth/supabase"
|
||||||
|
import * as Icons from "lucide-react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated/archivos')({
|
type RefRow = {
|
||||||
|
fine_tuning_referencias_id: string
|
||||||
|
titulo_archivo: string | null
|
||||||
|
descripcion: string | null
|
||||||
|
contenido_archivo: any | null
|
||||||
|
tipo_contenido: string | null
|
||||||
|
fecha_subida: string | null
|
||||||
|
procesado: boolean | null
|
||||||
|
tags: string[] | null
|
||||||
|
fuente_autoridad: string | null
|
||||||
|
interno: boolean | null
|
||||||
|
instrucciones: string
|
||||||
|
created_by: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/archivos")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
loader: async () => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("fine_tuning_referencias")
|
||||||
|
.select("*")
|
||||||
|
.order("fecha_subida", { ascending: false })
|
||||||
|
.limit(200)
|
||||||
|
if (error) throw error
|
||||||
|
return (data ?? []) as RefRow[]
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function RouteComponent() {
|
function chipTint(ok?: boolean | null) {
|
||||||
return <div>Hello "/_authenticated/archivos"!</div>
|
return ok
|
||||||
|
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||||
|
: "bg-amber-50 text-amber-800 border-amber-200"
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const router = useRouter()
|
||||||
|
const rows = Route.useLoaderData() as RefRow[]
|
||||||
|
|
||||||
|
const [q, setQ] = useState("")
|
||||||
|
const [estado, setEstado] = useState<"todos" | "proc" | "pend">("todos")
|
||||||
|
const [scope, setScope] = useState<"todos" | "internos" | "externos">("todos")
|
||||||
|
|
||||||
|
const [viewing, setViewing] = useState<RefRow | null>(null)
|
||||||
|
const [uploadOpen, setUploadOpen] = useState(false)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const t = q.trim().toLowerCase()
|
||||||
|
return rows.filter((r) => {
|
||||||
|
if (estado === "proc" && !r.procesado) return false
|
||||||
|
if (estado === "pend" && r.procesado) return false
|
||||||
|
if (scope === "internos" && !r.interno) return false
|
||||||
|
if (scope === "externos" && r.interno) return false
|
||||||
|
|
||||||
|
if (!t) return true
|
||||||
|
const hay =
|
||||||
|
[r.titulo_archivo, r.descripcion, r.fuente_autoridad, r.tipo_contenido, ...(r.tags ?? [])]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((v) => String(v).toLowerCase().includes(t))
|
||||||
|
return hay
|
||||||
|
})
|
||||||
|
}, [rows, q, estado, scope])
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
if (!confirm("¿Eliminar archivo de referencia?")) return
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("fine_tuning_referencias")
|
||||||
|
.delete()
|
||||||
|
.eq("fine_tuning_referencias_id", id)
|
||||||
|
if (error) return alert(error.message)
|
||||||
|
router.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<CardTitle>Archivos de referencia</CardTitle>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||||
|
<Input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Buscar por título, etiqueta, fuente…"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select value={estado} onValueChange={(v: any) => setEstado(v)}>
|
||||||
|
<SelectTrigger className="sm:w-[160px]">
|
||||||
|
<SelectValue placeholder="Estado" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="todos">Todos</SelectItem>
|
||||||
|
<SelectItem value="proc">Procesados</SelectItem>
|
||||||
|
<SelectItem value="pend">Pendientes</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={scope} onValueChange={(v: any) => setScope(v)}>
|
||||||
|
<SelectTrigger className="sm:w-[160px]">
|
||||||
|
<SelectValue placeholder="Ámbito" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="todos">Todos</SelectItem>
|
||||||
|
<SelectItem value="internos">Internos</SelectItem>
|
||||||
|
<SelectItem value="externos">Externos</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button onClick={() => setUploadOpen(true)}>
|
||||||
|
<Icons.Upload className="w-4 h-4 mr-2" /> Nuevo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{filtered.map((r) => (
|
||||||
|
<article
|
||||||
|
key={r.fine_tuning_referencias_id}
|
||||||
|
className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<header className="min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="font-semibold truncate">{r.titulo_archivo ?? "(Sin título)"}</h3>
|
||||||
|
<span className={`text-[10px] px-2 py-0.5 rounded-full border ${chipTint(r.procesado)}`}>
|
||||||
|
{r.procesado ? "Procesado" : "Pendiente"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-neutral-600 flex flex-wrap gap-2">
|
||||||
|
{r.tipo_contenido && <Badge variant="outline">{r.tipo_contenido}</Badge>}
|
||||||
|
{r.interno != null && (
|
||||||
|
<Badge variant="outline">{r.interno ? "Interno" : "Externo"}</Badge>
|
||||||
|
)}
|
||||||
|
{r.fecha_subida && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Icons.CalendarClock className="w-3 h-3" />
|
||||||
|
{new Date(r.fecha_subida).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{r.descripcion && (
|
||||||
|
<p className="text-sm text-neutral-700 line-clamp-3">{r.descripcion}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{r.tags && r.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{r.tags.map((t, i) => (
|
||||||
|
<span key={i} className="text-[10px] px-2 py-0.5 rounded-full border bg-white/60">
|
||||||
|
#{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-between gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setViewing(r)}>
|
||||||
|
<Icons.Eye className="w-4 h-4 mr-1" /> Ver
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => remove(r.fine_tuning_referencias_id)}>
|
||||||
|
<Icons.Trash2 className="w-4 h-4 mr-1" /> Eliminar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!filtered.length && (
|
||||||
|
<div className="text-center text-sm text-neutral-500 py-10">No hay archivos</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Detalle */}
|
||||||
|
<DetailDialog row={viewing} onClose={() => setViewing(null)} />
|
||||||
|
|
||||||
|
{/* Subida */}
|
||||||
|
<UploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onDone={() => router.invalidate()} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========= Detalle ========= */
|
||||||
|
function DetailDialog({ row, onClose }: { row: RefRow | null; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<Dialog open={!!row} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{row?.titulo_archivo ?? "(Sin título)"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{row?.descripcion || "Sin descripción"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{row && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-xs flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline">{row.tipo_contenido ?? "—"}</Badge>
|
||||||
|
<Badge variant="outline">{row.interno ? "Interno" : "Externo"}</Badge>
|
||||||
|
<Badge variant="outline">{row.procesado ? "Procesado" : "Pendiente"}</Badge>
|
||||||
|
{row.fuente_autoridad && <Badge variant="outline">{row.fuente_autoridad}</Badge>}
|
||||||
|
{row.fecha_subida && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Icons.CalendarClock className="w-3 h-3" />
|
||||||
|
{new Date(row.fecha_subida).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{row.tags && row.tags.length > 0 && (
|
||||||
|
<div className="text-xs text-neutral-600">
|
||||||
|
<span className="font-medium">Tags: </span>{row.tags.join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-neutral-600">Instrucciones</Label>
|
||||||
|
<div className="mt-1 rounded-xl border bg-white/60 p-3 text-sm whitespace-pre-wrap">
|
||||||
|
{row.instrucciones || "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-neutral-600">Contenido (JSON)</Label>
|
||||||
|
<pre className="mt-1 rounded-xl border bg-neutral-950 text-neutral-100 p-3 max-h-[360px] overflow-auto text-xs">
|
||||||
|
{JSON.stringify(row.contenido_archivo ?? {}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={onClose}>Cerrar</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========= Subida ========= */
|
||||||
|
function UploadDialog({
|
||||||
|
open, onOpenChange, onDone,
|
||||||
|
}: { open: boolean; onOpenChange: (o: boolean) => void; onDone: () => void }) {
|
||||||
|
const [file, setFile] = useState<File | null>(null)
|
||||||
|
const [instrucciones, setInstrucciones] = useState("")
|
||||||
|
const [tags, setTags] = useState("")
|
||||||
|
const [interno, setInterno] = useState(true)
|
||||||
|
const [fuente, setFuente] = useState("")
|
||||||
|
const [subiendo, setSubiendo] = useState(false)
|
||||||
|
|
||||||
|
async function toBase64(f: File): Promise<string> {
|
||||||
|
const buf = await f.arrayBuffer()
|
||||||
|
const bytes = new Uint8Array(buf)
|
||||||
|
let binary = ""
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
if (!file) { alert("Selecciona un archivo"); return }
|
||||||
|
if (!instrucciones.trim()) { alert("Escribe las instrucciones"); return }
|
||||||
|
|
||||||
|
setSubiendo(true)
|
||||||
|
try {
|
||||||
|
const fileBase64 = await toBase64(file)
|
||||||
|
// Enviamos al motor (inserta en la tabla si insert=true)
|
||||||
|
const res = await fetch("http://localhost:3001/api/upload/documento", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: instrucciones,
|
||||||
|
fileBase64,
|
||||||
|
insert: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text()
|
||||||
|
throw new Error(txt || "Error al subir")
|
||||||
|
}
|
||||||
|
// Ajustes extra (tags, interno, fuente) si el motor no los llenó
|
||||||
|
// Intentamos leer el id que regrese el servicio; si no, solo invalidamos.
|
||||||
|
let createdId: string | null = null
|
||||||
|
try {
|
||||||
|
const payload = await res.json()
|
||||||
|
createdId =
|
||||||
|
payload?.fine_tuning_referencias_id ||
|
||||||
|
payload?.id ||
|
||||||
|
payload?.data?.fine_tuning_referencias_id ||
|
||||||
|
null
|
||||||
|
} catch { /* noop */ }
|
||||||
|
|
||||||
|
if (createdId && (tags.trim() || fuente.trim() || typeof interno === "boolean")) {
|
||||||
|
await supabase
|
||||||
|
.from("fine_tuning_referencias")
|
||||||
|
.update({
|
||||||
|
tags: tags.trim() ? tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
|
||||||
|
fuente_autoridad: fuente.trim() || undefined,
|
||||||
|
interno,
|
||||||
|
})
|
||||||
|
.eq("fine_tuning_referencias_id", createdId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange(false)
|
||||||
|
onDone()
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.message ?? "Error al subir el documento")
|
||||||
|
} finally {
|
||||||
|
setSubiendo(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Nuevo archivo de referencia</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Sube un documento y escribe instrucciones para su procesamiento. Se guardará en la base y se marcará como
|
||||||
|
<em> procesado </em> cuando termine el flujo.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Archivo</Label>
|
||||||
|
<Input type="file" accept=".pdf,.doc,.docx,.txt,.md" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||||
|
{file && (
|
||||||
|
<div className="text-xs text-neutral-600">{file.name} · {(file.size / 1024).toFixed(1)} KB</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Instrucciones</Label>
|
||||||
|
<Textarea
|
||||||
|
value={instrucciones}
|
||||||
|
onChange={(e) => setInstrucciones(e.target.value)}
|
||||||
|
placeholder="Ej.: Extrae temario, resultados de aprendizaje y bibliografía; limpia ruido y normaliza formato."
|
||||||
|
className="min-h-[120px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Tags (separados por coma)</Label>
|
||||||
|
<Input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="normatividad, plan, lineamientos" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Fuente de autoridad</Label>
|
||||||
|
<Input value={fuente} onChange={(e) => setFuente(e.target.value)} placeholder="SEP, ANUIES…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Ámbito</Label>
|
||||||
|
<Select value={String(interno)} onValueChange={(v) => setInterno(v === "true")}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="true">Interno</SelectItem>
|
||||||
|
<SelectItem value="false">Externo</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={upload} disabled={subiendo || !file || !instrucciones.trim()}>
|
||||||
|
{subiendo ? "Subiendo…" : "Subir"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import { Input } from "@/components/ui/input"
|
|||||||
import {
|
import {
|
||||||
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
|
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
|
||||||
} from "@/components/ui/accordion"
|
} from "@/components/ui/accordion"
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
/* ================== Tipos ================== */
|
/* ================== Tipos ================== */
|
||||||
type Asignatura = {
|
type Asignatura = {
|
||||||
@@ -50,9 +55,7 @@ function typeStyle(tipo?: string | null) {
|
|||||||
return { chip: "bg-neutral-100 text-neutral-700 border-neutral-200", halo: "from-primary/10" }
|
return { chip: "bg-neutral-100 text-neutral-700 border-neutral-200", halo: "from-primary/10" }
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ icon: Icon, label, value }:{
|
function Stat({ icon: Icon, label, value }: { icon: any; label: string; value: string | number }) {
|
||||||
icon: any; label: string; value: string | number
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 flex items-center gap-3">
|
<div className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-3 flex items-center gap-3">
|
||||||
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
|
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
|
||||||
@@ -66,9 +69,7 @@ function Stat({ icon: Icon, label, value }:{
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Section({ id, title, icon: Icon, children }:{
|
function Section({ id, title, icon: Icon, children }: { id: string; title: string; icon: any; children: React.ReactNode }) {
|
||||||
id: string; title: string; icon: any; children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<section id={id} className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 scroll-mt-24">
|
<section id={id} className="rounded-2xl border bg-white/70 dark:bg-neutral-900/60 p-4 scroll-mt-24">
|
||||||
<header className="flex items-center gap-2 mb-2">
|
<header className="flex items-center gap-2 mb-2">
|
||||||
@@ -83,13 +84,15 @@ function Section({ id, title, icon: Icon, children }:{
|
|||||||
/* ================== Página ================== */
|
/* ================== Página ================== */
|
||||||
function Page() {
|
function Page() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { a, plan } = Route.useLoaderData() as { a: Asignatura; plan: PlanMini | null }
|
const { a: aFromLoader, plan } = Route.useLoaderData() as { a: Asignatura; plan: PlanMini | null }
|
||||||
|
const [a, setA] = useState<Asignatura>(aFromLoader)
|
||||||
|
|
||||||
const horasT = a.horas_teoricas ?? 0
|
const horasT = a.horas_teoricas ?? 0
|
||||||
const horasP = a.horas_practicas ?? 0
|
const horasP = a.horas_practicas ?? 0
|
||||||
const horas = horasT + horasP
|
const horas = horasT + horasP
|
||||||
const style = typeStyle(a.tipo)
|
const style = typeStyle(a.tipo)
|
||||||
|
|
||||||
// ordenar unidades de forma “natural”
|
// ordenar unidades
|
||||||
const unidades = useMemo(() => {
|
const unidades = useMemo(() => {
|
||||||
const entries = Object.entries(a.contenidos ?? {})
|
const entries = Object.entries(a.contenidos ?? {})
|
||||||
const norm = (s: string) => {
|
const norm = (s: string) => {
|
||||||
@@ -99,9 +102,8 @@ function Page() {
|
|||||||
return entries
|
return entries
|
||||||
.map(([k, v]) => ({ key: k, order: norm(k)[0], title: norm(k)[1], temas: Object.entries(v) }))
|
.map(([k, v]) => ({ key: k, order: norm(k)[0], title: norm(k)[1], temas: Object.entries(v) }))
|
||||||
.sort((A, B) => (A.order === B.order ? A.title.localeCompare(B.title) : A.order - B.order))
|
.sort((A, B) => (A.order === B.order ? A.title.localeCompare(B.title) : A.order - B.order))
|
||||||
.map(u => ({ ...u, temas: u.temas.sort(([a],[b]) => Number(a) - Number(b)) }))
|
.map(u => ({ ...u, temas: u.temas.sort(([x], [y]) => Number(x) - Number(y)) }))
|
||||||
}, [a.contenidos])
|
}, [a.contenidos])
|
||||||
|
|
||||||
const temasCount = useMemo(() => unidades.reduce((acc, u) => acc + u.temas.length, 0), [unidades])
|
const temasCount = useMemo(() => unidades.reduce((acc, u) => acc + u.temas.length, 0), [unidades])
|
||||||
|
|
||||||
// buscar dentro del syllabus
|
// buscar dentro del syllabus
|
||||||
@@ -115,7 +117,7 @@ function Page() {
|
|||||||
})).filter(u => u.temas.length > 0)
|
})).filter(u => u.temas.length > 0)
|
||||||
}, [query, unidades])
|
}, [query, unidades])
|
||||||
|
|
||||||
// atajos y compartir
|
// atajos
|
||||||
const searchRef = useRef<HTMLInputElement | null>(null)
|
const searchRef = useRef<HTMLInputElement | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -130,12 +132,8 @@ function Page() {
|
|||||||
const url = window.location.href
|
const url = window.location.href
|
||||||
try {
|
try {
|
||||||
if (navigator.share) await navigator.share({ title: a.nombre, url })
|
if (navigator.share) await navigator.share({ title: a.nombre, url })
|
||||||
else {
|
else { await navigator.clipboard.writeText(url); alert("Enlace copiado") }
|
||||||
await navigator.clipboard.writeText(url)
|
} catch { }
|
||||||
// feedback visual mínimo
|
|
||||||
alert("Enlace copiado")
|
|
||||||
}
|
|
||||||
} catch { /* noop */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -185,6 +183,8 @@ function Page() {
|
|||||||
<Button variant="outline" size="sm" onClick={share}>
|
<Button variant="outline" size="sm" onClick={share}>
|
||||||
<Icons.Share2 className="h-4 w-4 mr-2" /> Compartir
|
<Icons.Share2 className="h-4 w-4 mr-2" /> Compartir
|
||||||
</Button>
|
</Button>
|
||||||
|
<EditAsignaturaButton asignatura={a} onUpdate={setA} />
|
||||||
|
<MejorarAIButton asignaturaId={a.id} onApply={(nuevo) => setA(nuevo)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -225,37 +225,94 @@ function Page() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{query && (
|
{query && (
|
||||||
<Button variant="ghost" onClick={() => setQuery("")}>
|
<Button variant="ghost" onClick={() => setQuery("")}>Limpiar</Button>
|
||||||
Limpiar
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
// --- helpers de normalización ---
|
||||||
|
const titleOf = (u: any): string => {
|
||||||
|
// Si viene como arreglo [{titulo, seccion, subtemas}], u.temas tendrá pares [ 'titulo' , '…' ]
|
||||||
|
const t = (u.temas || []).find(([k]: any[]) => String(k).toLowerCase() === "titulo")?.[1]
|
||||||
|
if (typeof t === "string" && t.trim()) return t
|
||||||
|
// Fallback: si la clave de la unidad es numérica, usa "Unidad N" o el título ya calculado
|
||||||
|
return /^\s*\d+/.test(String(u.key))
|
||||||
|
? (u.title && u.title !== u.key ? u.title : `Unidad ${u.key ? u.key : 1}`)
|
||||||
|
: (u.title || String(u.key))
|
||||||
|
}
|
||||||
|
|
||||||
|
const temasOf = (u: any): string[] => {
|
||||||
|
const pairs: any[] = Array.isArray(u.temas) ? u.temas : []
|
||||||
|
// 1) Estructura con subtemas
|
||||||
|
const sub = pairs.find(([k]) => String(k).toLowerCase() === "subtemas")?.[1]
|
||||||
|
if (Array.isArray(sub)) {
|
||||||
|
// subtemas: ["t1", "t2", ...]
|
||||||
|
return sub.map(String)
|
||||||
|
}
|
||||||
|
if (sub && typeof sub === "object") {
|
||||||
|
// subtemas: { "1": "t1", "2": "t2", ... }
|
||||||
|
return Object.entries(sub)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.map(([, v]) => String(v))
|
||||||
|
}
|
||||||
|
// 2) Estructura plana numerada { "1": "t1", "2": "t2", ... }
|
||||||
|
const numerados = pairs
|
||||||
|
.filter(([k, v]) => /^\d+$/.test(String(k)) && (typeof v === "string" || typeof v === "number"))
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.map(([, v]) => String(v))
|
||||||
|
if (numerados.length) return numerados
|
||||||
|
// 3) Fallback: toma valores string excepto metadatos
|
||||||
|
return pairs
|
||||||
|
.filter(([k, v]) => !["titulo", "seccion"].includes(String(k).toLowerCase()) && typeof v === "string")
|
||||||
|
.map(([, v]) => String(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
const visible = (filteredUnidades.length ? filteredUnidades : unidades)
|
||||||
|
.map((u: any) => {
|
||||||
|
const list = temasOf(u)
|
||||||
|
const title = titleOf(u)
|
||||||
|
const match = !q || list.some(t => t.toLowerCase().includes(q)) || title.toLowerCase().includes(q)
|
||||||
|
return { ...u, __title: title, __temas: list, __match: match }
|
||||||
|
})
|
||||||
|
.filter((u: any) => u.__match)
|
||||||
|
|
||||||
|
return (
|
||||||
<Accordion type="multiple" className="mt-2">
|
<Accordion type="multiple" className="mt-2">
|
||||||
{filteredUnidades.map((u, i) => (
|
{visible.map((u: any, i: number) => (
|
||||||
<AccordionItem key={u.key} value={`u-${i}`} className="border rounded-xl mb-2 overflow-hidden">
|
<AccordionItem key={`${u.key}-${i}`} value={`u-${i}`} className="border rounded-xl mb-2 overflow-hidden">
|
||||||
<AccordionTrigger className="px-4 py-2 hover:no-underline text-left">
|
<AccordionTrigger className="px-4 py-2 hover:no-underline text-left">
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{/^\s*\d+/.test(u.key) ? `Unidad ${u.key}` : u.title}
|
{/^\s*\d+/.test(String(u.key))
|
||||||
|
? `Unidad ${u.key ? u.key : 1}${u.__title ? `: ${u.__title}` : ""}`
|
||||||
|
: u.__title}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-neutral-500">{u.temas.length} tema(s)</span>
|
<span className="text-[11px] text-neutral-500">{u.__temas.length} tema(s)</span>
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="px-5 pb-3">
|
<AccordionContent className="px-5 pb-3">
|
||||||
<ul className="list-disc ml-5 text-[13px] text-neutral-700 space-y-1">
|
<ul className="list-disc ml-5 text-[13px] text-neutral-700 space-y-1">
|
||||||
{u.temas.map(([k, t]) => <li key={k} className="break-words">{t}</li>)}
|
{u.__temas.map((t: string, idx: number) => (
|
||||||
|
<li key={idx} className="break-words">{t}</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
))}
|
))}
|
||||||
{filteredUnidades.length === 0 && (
|
|
||||||
<div className="text-sm text-neutral-500 py-6 text-center">No hay temas que coincidan.</div>
|
{!visible.length && (
|
||||||
|
<div className="text-sm text-neutral-500 py-6 text-center">
|
||||||
|
No hay temas que coincidan.
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Bibliografía */}
|
{/* Bibliografía */}
|
||||||
{a.bibliografia && a.bibliografia.length > 0 && (
|
{a.bibliografia && a.bibliografia.length > 0 && (
|
||||||
<Section id="bibliografia" title="Bibliografía" icon={Icons.LibraryBig}>
|
<Section id="bibliografia" title="Bibliografía" icon={Icons.LibraryBig}>
|
||||||
@@ -335,7 +392,7 @@ function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Bits Sidebar ===== */
|
/* ===== Bits Sidebar ===== */
|
||||||
function MiniKV({ label, value }:{ label: string; value: string | number }) {
|
function MiniKV({ label, value }: { label: string; value: string | number }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-white/60 p-2">
|
<div className="rounded-xl border bg-white/60 p-2">
|
||||||
<div className="text-[11px] text-neutral-500">{label}</div>
|
<div className="text-[11px] text-neutral-500">{label}</div>
|
||||||
@@ -343,10 +400,160 @@ function MiniKV({ label, value }:{ label: string; value: string | number }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
function Anchor({ href, label }:{ href: string; label: string }) {
|
function Anchor({ href, label }: { href: string; label: string }) {
|
||||||
return (
|
return (
|
||||||
<a href={href} className="flex items-center gap-2 text-neutral-700 hover:underline">
|
<a href={href} className="flex items-center gap-2 text-neutral-700 hover:underline">
|
||||||
<Icons.Dot className="h-5 w-5 -ml-1" /> {label}
|
<Icons.Dot className="h-5 w-5 -ml-1" /> {label}
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ======= Modales ======= */
|
||||||
|
function EditAsignaturaButton({ asignatura, onUpdate }: {
|
||||||
|
asignatura: Asignatura; onUpdate: (a: Asignatura) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [form, setForm] = useState<Partial<Asignatura>>({})
|
||||||
|
|
||||||
|
const openAndFill = () => { setForm(asignatura); setOpen(true) }
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true)
|
||||||
|
const payload = {
|
||||||
|
nombre: form.nombre ?? asignatura.nombre,
|
||||||
|
clave: form.clave ?? asignatura.clave,
|
||||||
|
tipo: form.tipo ?? asignatura.tipo,
|
||||||
|
semestre: form.semestre ?? asignatura.semestre,
|
||||||
|
creditos: form.creditos ?? asignatura.creditos,
|
||||||
|
horas_teoricas: form.horas_teoricas ?? asignatura.horas_teoricas,
|
||||||
|
horas_practicas: form.horas_practicas ?? asignatura.horas_practicas,
|
||||||
|
}
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("asignaturas")
|
||||||
|
.update(payload)
|
||||||
|
.eq("id", asignatura.id)
|
||||||
|
.select()
|
||||||
|
.maybeSingle()
|
||||||
|
setSaving(false)
|
||||||
|
if (!error && data) { onUpdate(data as Asignatura); setOpen(false) }
|
||||||
|
else alert(error?.message ?? "Error al guardar")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" size="sm" onClick={openAndFill}>
|
||||||
|
<Icons.Pencil className="h-4 w-4 mr-2" /> Editar
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Editar asignatura</DialogTitle>
|
||||||
|
<DialogDescription>Actualiza campos básicos.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Nombre">
|
||||||
|
<Input value={form.nombre ?? ""} onChange={e => setForm(s => ({ ...s, nombre: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Clave">
|
||||||
|
<Input value={form.clave ?? ""} onChange={e => setForm(s => ({ ...s, clave: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Tipo">
|
||||||
|
<Input value={form.tipo ?? ""} onChange={e => setForm(s => ({ ...s, tipo: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Semestre">
|
||||||
|
<Input value={String(form.semestre ?? "")} onChange={e => setForm(s => ({ ...s, semestre: Number(e.target.value) || null }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Créditos">
|
||||||
|
<Input value={String(form.creditos ?? "")} onChange={e => setForm(s => ({ ...s, creditos: Number(e.target.value) || null }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Horas teóricas">
|
||||||
|
<Input value={String(form.horas_teoricas ?? "")} onChange={e => setForm(s => ({ ...s, horas_teoricas: Number(e.target.value) || null }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Horas prácticas">
|
||||||
|
<Input value={String(form.horas_practicas ?? "")} onChange={e => setForm(s => ({ ...s, horas_practicas: Number(e.target.value) || null }))} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : "Guardar"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MejorarAIButton({ asignaturaId, onApply }: {
|
||||||
|
asignaturaId: string; onApply: (a: Asignatura) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [prompt, setPrompt] = useState("")
|
||||||
|
const [insert, setInsert] = useState(true)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("http://localhost:3001/api/mejorar/asignatura", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ asignatura_id: asignaturaId, prompt, insert }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text()
|
||||||
|
throw new Error(txt || "Error IA")
|
||||||
|
}
|
||||||
|
const nuevo = await res.json()
|
||||||
|
onApply(nuevo as Asignatura)
|
||||||
|
setOpen(false)
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.message ?? "Error al mejorar la asignatura")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button size="sm" onClick={() => setOpen(true)}>
|
||||||
|
<Icons.Sparkles className="h-4 w-4 mr-2" /> Mejorar con IA
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Mejorar asignatura con IA</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Describe el ajuste que deseas (p. ej. “refuerza contenidos prácticos y añade bibliografía reciente”).
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
className="min-h-[140px]"
|
||||||
|
placeholder="Escribe tu prompt…"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={insert} onChange={(e) => setInsert(e.target.checked)} />
|
||||||
|
Guardar cambios
|
||||||
|
</label>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={apply} disabled={!prompt.trim() || loading}>{loading ? "Aplicando…" : "Aplicar ajuste"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-neutral-600">{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
545
src/routes/_authenticated/carreras.tsx
Normal file
545
src/routes/_authenticated/carreras.tsx
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
// routes/_authenticated/carreras.tsx
|
||||||
|
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { supabase } from "@/auth/supabase"
|
||||||
|
import * as Icons from "lucide-react"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
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"
|
||||||
|
|
||||||
|
/* -------------------- Tipos -------------------- */
|
||||||
|
type FacultadLite = { id: string; nombre: string; color?: string | null; icon?: string | null }
|
||||||
|
type CarreraRow = {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
semestres: number
|
||||||
|
activo: boolean
|
||||||
|
facultad_id: string | null
|
||||||
|
facultades?: FacultadLite | null
|
||||||
|
}
|
||||||
|
type LoaderData = { carreras: CarreraRow[]; facultades: FacultadLite[] }
|
||||||
|
|
||||||
|
/* -------------------- Ruta -------------------- */
|
||||||
|
export const Route = createFileRoute("/_authenticated/carreras")({
|
||||||
|
component: RouteComponent,
|
||||||
|
loader: async (): Promise<LoaderData> => {
|
||||||
|
const [{ data: carreras, error: e1 }, { data: facultades, error: e2 }] = await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from("carreras")
|
||||||
|
.select(`id, nombre, semestres, activo, facultad_id, facultades:facultades ( id, nombre, color, icon )`)
|
||||||
|
.order("nombre", { ascending: true }),
|
||||||
|
supabase.from("facultades").select("id, nombre, color, icon").order("nombre", { ascending: true }),
|
||||||
|
])
|
||||||
|
if (e1) throw e1
|
||||||
|
if (e2) throw e2
|
||||||
|
return {
|
||||||
|
carreras: (carreras ?? []) as unknown as CarreraRow[],
|
||||||
|
facultades: (facultades ?? []) as FacultadLite[],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/* -------------------- Helpers UI -------------------- */
|
||||||
|
const tint = (hex?: string | null, a = .18) => {
|
||||||
|
if (!hex) return `rgba(37,99,235,${a})`
|
||||||
|
const h = hex.replace("#", "")
|
||||||
|
const v = h.length === 3 ? h.split("").map(c => c + c).join("") : h
|
||||||
|
const n = parseInt(v, 16)
|
||||||
|
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255
|
||||||
|
return `rgba(${r},${g},${b},${a})`
|
||||||
|
}
|
||||||
|
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"}`}>
|
||||||
|
{active ? "Activa" : "Inactiva"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
/* -------------------- Página -------------------- */
|
||||||
|
function RouteComponent() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { carreras, facultades } = Route.useLoaderData() as LoaderData
|
||||||
|
|
||||||
|
const [q, setQ] = useState("")
|
||||||
|
const [fac, setFac] = useState<string>("todas")
|
||||||
|
const [state, setState] = useState<"todas" | "activas" | "inactivas">("todas")
|
||||||
|
|
||||||
|
const [detail, setDetail] = useState<CarreraRow | null>(null)
|
||||||
|
const [editCarrera, setEditCarrera] = useState<CarreraRow | null>(null)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const term = q.trim().toLowerCase()
|
||||||
|
return carreras.filter((c) => {
|
||||||
|
if (fac !== "todas" && c.facultad_id !== fac) return false
|
||||||
|
if (state === "activas" && !c.activo) return false
|
||||||
|
if (state === "inactivas" && c.activo) return false
|
||||||
|
if (!term) return true
|
||||||
|
return [c.nombre, c.facultades?.nombre].filter(Boolean)
|
||||||
|
.some(v => String(v).toLowerCase().includes(term))
|
||||||
|
})
|
||||||
|
}, [q, fac, state, carreras])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<CardTitle>Carreras</CardTitle>
|
||||||
|
<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">
|
||||||
|
<Icons.Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||||
|
<Input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Buscar por nombre o facultad…"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select value={fac} onValueChange={(v) => setFac(v)}>
|
||||||
|
<SelectTrigger className="md:w-[220px]"><SelectValue placeholder="Facultad" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="todas">Todas las facultades</SelectItem>
|
||||||
|
{facultades.map(f => <SelectItem key={f.id} value={f.id}>{f.nombre}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={state} onValueChange={(v: any) => setState(v)}>
|
||||||
|
<SelectTrigger className="md:w-[160px]"><SelectValue placeholder="Estado" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="todas">Todas</SelectItem>
|
||||||
|
<SelectItem value="activas">Activas</SelectItem>
|
||||||
|
<SelectItem value="inactivas">Inactivas</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button variant="outline" size="icon" onClick={() => router.invalidate()} title="Recargar">
|
||||||
|
<Icons.RefreshCcw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Icons.Plus className="h-4 w-4 mr-2" /> Nueva carrera
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{filtered.map(c => {
|
||||||
|
const fac = c.facultades
|
||||||
|
const border = tint(fac?.color, .28)
|
||||||
|
const chip = tint(fac?.color, .10)
|
||||||
|
const IconComp = (fac?.icon && (Icons as any)[fac.icon]) || Icons.Building2
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={c.id}
|
||||||
|
className="group relative overflow-hidden rounded-3xl bg-white shadow-sm ring-1 transition-all hover:shadow-md hover:-translate-y-0.5"
|
||||||
|
style={{ borderColor: border, background: `linear-gradient(180deg, ${chip}, transparent)` }}
|
||||||
|
>
|
||||||
|
<div className="p-5 h-44 flex flex-col justify-between">
|
||||||
|
<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"
|
||||||
|
style={{ borderColor: border }}>
|
||||||
|
<IconComp className="w-6 h-6" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold truncate">{c.nombre}</div>
|
||||||
|
<div className="text-xs text-neutral-600 truncate">
|
||||||
|
{fac?.nombre ?? "—"} · {c.semestres} semestres
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<StatusPill active={c.activo} />
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDetail(c)}>
|
||||||
|
<Icons.Eye className="w-4 h-4 mr-1" /> Ver
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setEditCarrera(c)}>
|
||||||
|
<Icons.Pencil className="w-4 h-4 mr-1" /> Editar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!filtered.length && (
|
||||||
|
<div className="text-center text-sm text-neutral-500 py-10">No hay resultados</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Crear / Editar */}
|
||||||
|
<CarreraFormDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
facultades={facultades}
|
||||||
|
mode="create"
|
||||||
|
onSaved={() => router.invalidate()}
|
||||||
|
/>
|
||||||
|
<CarreraFormDialog
|
||||||
|
open={!!editCarrera}
|
||||||
|
onOpenChange={(o) => !o && setEditCarrera(null)}
|
||||||
|
facultades={facultades}
|
||||||
|
mode="edit"
|
||||||
|
carrera={editCarrera ?? undefined}
|
||||||
|
onSaved={() => { setEditCarrera(null); router.invalidate() }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Detalle + añadir criterio */}
|
||||||
|
<CarreraDetailDialog carrera={detail} onOpenChange={setDetail} onChanged={() => router.invalidate()} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------- Form crear/editar -------------------- */
|
||||||
|
function CarreraFormDialog({
|
||||||
|
open, onOpenChange, mode, carrera, facultades, onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (o: boolean) => void
|
||||||
|
mode: "create" | "edit"
|
||||||
|
carrera?: CarreraRow
|
||||||
|
facultades: FacultadLite[]
|
||||||
|
onSaved?: () => void
|
||||||
|
}) {
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [nombre, setNombre] = useState(carrera?.nombre ?? "")
|
||||||
|
const [semestres, setSemestres] = useState<number>(carrera?.semestres ?? 9)
|
||||||
|
const [activo, setActivo] = useState<boolean>(carrera?.activo ?? true)
|
||||||
|
const [facultadId, setFacultadId] = useState<string | "none">(carrera?.facultad_id ?? "none")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "edit" && carrera) {
|
||||||
|
setNombre(carrera.nombre)
|
||||||
|
setSemestres(carrera.semestres)
|
||||||
|
setActivo(carrera.activo)
|
||||||
|
setFacultadId(carrera.facultad_id ?? "none")
|
||||||
|
} else if (mode === "create") {
|
||||||
|
setNombre("")
|
||||||
|
setSemestres(9)
|
||||||
|
setActivo(true)
|
||||||
|
setFacultadId("none")
|
||||||
|
}
|
||||||
|
}, [mode, carrera, open])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!nombre.trim()) { alert("Escribe un nombre"); return }
|
||||||
|
setSaving(true)
|
||||||
|
const payload = {
|
||||||
|
nombre: nombre.trim(),
|
||||||
|
semestres: Number(semestres) || 9,
|
||||||
|
activo,
|
||||||
|
facultad_id: facultadId === "none" ? null : facultadId,
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = mode === "create"
|
||||||
|
? supabase.from("carreras").insert([payload]).select("id").single()
|
||||||
|
: supabase.from("carreras").update(payload).eq("id", carrera!.id).select("id").single()
|
||||||
|
|
||||||
|
const { error } = await action
|
||||||
|
setSaving(false)
|
||||||
|
if (error) { alert(error.message); return }
|
||||||
|
onOpenChange(false)
|
||||||
|
onSaved?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{mode === "create" ? "Nueva carrera" : "Editar carrera"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{mode === "create" ? "Crea una nueva carrera en la base de datos." : "Actualiza los datos de la carrera."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Nombre</Label>
|
||||||
|
<Input value={nombre} onChange={(e) => setNombre(e.target.value)} placeholder="Ing. en Software" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Semestres</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={semestres}
|
||||||
|
onChange={(e) => setSemestres(parseInt(e.target.value || "9", 10))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Estado</Label>
|
||||||
|
<div className="flex h-10 items-center gap-2 rounded-md border px-3">
|
||||||
|
<Switch checked={activo} onCheckedChange={setActivo} />
|
||||||
|
<span className="text-sm">{activo ? "Activa" : "Inactiva"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Facultad</Label>
|
||||||
|
<Select value={facultadId} onValueChange={(v) => setFacultadId(v as any)}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Selecciona una facultad (opcional)" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">Sin facultad</SelectItem>
|
||||||
|
{facultades.map(f => <SelectItem key={f.id} value={f.id}>{f.nombre}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : (mode === "create" ? "Crear" : "Guardar")}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------- Detalle (criterios) -------------------- */
|
||||||
|
function CarreraDetailDialog({
|
||||||
|
carrera,
|
||||||
|
onOpenChange,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
carrera: CarreraRow | null
|
||||||
|
onOpenChange: (c: CarreraRow | null) => void
|
||||||
|
onChanged?: () => void
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [criterios, setCriterios] = useState<Array<{
|
||||||
|
id: number
|
||||||
|
nombre: string
|
||||||
|
descripcion: string | null
|
||||||
|
tipo: string | null
|
||||||
|
obligatorio: boolean | null
|
||||||
|
referencia_documento: string | null
|
||||||
|
fecha_creacion: string | null
|
||||||
|
}>>([])
|
||||||
|
const [q, setQ] = useState("")
|
||||||
|
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 t = q.trim().toLowerCase()
|
||||||
|
if (!t) return criterios
|
||||||
|
return criterios.filter(c =>
|
||||||
|
[c.nombre, c.descripcion, c.tipo, c.referencia_documento]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some(v => String(v).toLowerCase().includes(t)),
|
||||||
|
)
|
||||||
|
}, [q, criterios])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!carrera} onOpenChange={(o) => !o && onOpenChange(null)}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{carrera?.nombre}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{carrera?.facultades?.nombre ?? "—"} · {carrera?.semestres} semestres{" "}
|
||||||
|
{typeof carrera?.activo === "boolean" && (
|
||||||
|
<Badge variant="outline" className="ml-2">{carrera?.activo ? "Activa" : "Inactiva"}</Badge>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<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" />
|
||||||
|
<Input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Buscar criterio por nombre, tipo o referencia…"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setNewCritOpen(true)}>
|
||||||
|
<Icons.Plus className="h-4 w-4 mr-2" /> Añadir criterio
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-sm text-neutral-500">Cargando criterios…</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-neutral-600">
|
||||||
|
{filtered.length} criterio(s){q ? " (filtrado)" : ""}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="text-sm text-neutral-500 py-6 text-center">No hay criterios</div>
|
||||||
|
) : (
|
||||||
|
<Accordion type="multiple" className="mt-1">
|
||||||
|
{filtered.map((c) => (
|
||||||
|
<AccordionItem key={c.id} value={`c-${c.id}`} className="border rounded-xl mb-2 overflow-hidden">
|
||||||
|
<AccordionTrigger className="px-4 py-2 hover:no-underline text-left">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<span className="font-medium">{c.nombre}</span>
|
||||||
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
|
{c.tipo && <Badge variant="outline">{c.tipo}</Badge>}
|
||||||
|
<Badge variant="outline">{c.obligatorio ? "Obligatorio" : "Opcional"}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-5 pb-3">
|
||||||
|
{c.descripcion && <p className="text-sm text-neutral-800 leading-relaxed mb-2">{c.descripcion}</p>}
|
||||||
|
<div className="text-xs text-neutral-600 flex flex-wrap gap-3">
|
||||||
|
{c.referencia_documento && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Icons.Link className="h-3 w-3" />
|
||||||
|
<a className="underline" href={c.referencia_documento} target="_blank" rel="noreferrer">
|
||||||
|
Referencia
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{c.fecha_creacion && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Icons.CalendarClock className="h-3 w-3" />
|
||||||
|
{new Date(c.fecha_creacion).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => onOpenChange(null)}>Cerrar</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
|
||||||
|
{/* Crear criterio */}
|
||||||
|
<CriterioFormDialog
|
||||||
|
open={newCritOpen}
|
||||||
|
onOpenChange={setNewCritOpen}
|
||||||
|
carreraId={carrera?.id ?? ""}
|
||||||
|
onSaved={() => { setNewCritOpen(false); load(); onChanged?.() }}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------- Form crear criterio -------------------- */
|
||||||
|
function CriterioFormDialog({
|
||||||
|
open, onOpenChange, carreraId, onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (o: boolean) => void
|
||||||
|
carreraId: string
|
||||||
|
onSaved?: () => void
|
||||||
|
}) {
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [nombre, setNombre] = useState("")
|
||||||
|
const [tipo, setTipo] = useState<string>("")
|
||||||
|
const [descripcion, setDescripcion] = useState("")
|
||||||
|
const [obligatorio, setObligatorio] = useState(true)
|
||||||
|
const [referencia, setReferencia] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setNombre(""); setTipo(""); setDescripcion(""); setObligatorio(true); setReferencia("")
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!carreraId) return
|
||||||
|
if (!nombre.trim()) { alert("Escribe un nombre"); return }
|
||||||
|
setSaving(true)
|
||||||
|
const { error } = await supabase.from("criterios_carrera").insert([{
|
||||||
|
nombre: nombre.trim(),
|
||||||
|
tipo: tipo || null,
|
||||||
|
descripcion: descripcion || null,
|
||||||
|
obligatorio,
|
||||||
|
referencia_documento: referencia || null,
|
||||||
|
carrera_id: carreraId,
|
||||||
|
}])
|
||||||
|
setSaving(false)
|
||||||
|
if (error) { alert(error.message); return }
|
||||||
|
onSaved?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Nuevo criterio</DialogTitle>
|
||||||
|
<DialogDescription>Agrega un criterio para esta carrera.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Nombre</Label>
|
||||||
|
<Input value={nombre} onChange={(e) => setNombre(e.target.value)} placeholder="Infraestructura de laboratorios" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Tipo</Label>
|
||||||
|
<Input value={tipo} onChange={(e) => setTipo(e.target.value)} placeholder="Académico / Operativo / Otro" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Descripción</Label>
|
||||||
|
<Input value={descripcion} onChange={(e) => setDescripcion(e.target.value)} placeholder="Detalle o alcance del criterio" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 items-center">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>¿Obligatorio?</Label>
|
||||||
|
<div className="flex h-10 items-center gap-2 rounded-md border px-3">
|
||||||
|
<Switch checked={obligatorio} onCheckedChange={setObligatorio} />
|
||||||
|
<span className="text-sm">{obligatorio ? "Sí" : "No"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Referencia (URL)</Label>
|
||||||
|
<Input value={referencia} onChange={(e) => setReferencia(e.target.value)} placeholder="https://…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={save} disabled={saving}>{saving ? "Guardando…" : "Crear"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
||||||
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 { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@@ -12,6 +12,7 @@ import { Textarea } from '@/components/ui/textarea'
|
|||||||
import gsap from 'gsap'
|
import gsap from 'gsap'
|
||||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
import { AcademicSections } from '@/components/planes/academic-sections'
|
import { AcademicSections } from '@/components/planes/academic-sections'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@radix-ui/react-tabs'
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger)
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ function GradientMesh({ color }: { color?: string | null }) {
|
|||||||
|
|
||||||
/* ============== PAGE ============== */
|
/* ============== PAGE ============== */
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
|
const router = useRouter()
|
||||||
const { plan, asignaturasCount, asignaturasPreview } = Route.useLoaderData() as LoaderData
|
const { plan, asignaturasCount, asignaturasPreview } = Route.useLoaderData() as LoaderData
|
||||||
const auth = useSupabaseAuth()
|
const auth = useSupabaseAuth()
|
||||||
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
|
const showFacultad = auth.claims?.role === 'lci' || auth.claims?.role === 'vicerrectoria'
|
||||||
@@ -252,25 +254,28 @@ function RouteComponent() {
|
|||||||
<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>
|
||||||
|
|
||||||
{/* Abre el modal enmascarado */}
|
<div className="flex items-center gap-2">
|
||||||
|
<AddAsignaturaButton planId={plan.id} onAdded={() => router.invalidate()} />
|
||||||
<Link
|
<Link
|
||||||
to="/asignaturas/$planId"
|
to="/asignaturas/$planId"
|
||||||
|
search={{ q: "", planId: plan.id, carreraId: '', f: '', facultadId: '' }}
|
||||||
params={{ planId: plan.id }}
|
params={{ planId: plan.id }}
|
||||||
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
|
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
|
||||||
>
|
>
|
||||||
<Icons.BookOpen className="w-4 h-4" /> Ver todas
|
<Icons.BookOpen className="w-4 h-4" /> Ver todas
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
|
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
{asignaturasPreview.length === 0 && (
|
{asignaturasPreview.length === 0 && (
|
||||||
<div className="text-sm text-neutral-500">Sin asignaturas</div>
|
<div className="text-sm text-neutral-500">Sin asignaturas</div>
|
||||||
)}
|
)}
|
||||||
{asignaturasPreview.map(a => (
|
{asignaturasPreview.map(a => (
|
||||||
<Link
|
<Link
|
||||||
key={a.id}
|
to="/asignatura/$asignaturaId"
|
||||||
to="/asignaturas/$planId"
|
params={{ asignaturaId: a.id }}
|
||||||
params={{ planId: plan.id }}
|
|
||||||
className="rounded-full border px-3 py-1 text-xs bg-white/70 hover:bg-white transition"
|
className="rounded-full border px-3 py-1 text-xs bg-white/70 hover:bg-white transition"
|
||||||
title={a.nombre}
|
title={a.nombre}
|
||||||
>
|
>
|
||||||
@@ -455,3 +460,172 @@ function PageSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AddAsignaturaButton({
|
||||||
|
planId, onAdded,
|
||||||
|
}: { planId: string; onAdded?: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [mode, setMode] = useState<"manual" | "ia">("manual")
|
||||||
|
|
||||||
|
// --- Manual ---
|
||||||
|
const [f, setF] = useState({
|
||||||
|
nombre: "", clave: "", tipo: "", semestre: "", creditos: "",
|
||||||
|
horas_teoricas: "", horas_practicas: "", objetivos: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- IA ---
|
||||||
|
const [iaPrompt, setIaPrompt] = useState("")
|
||||||
|
const [iaSemestre, setIaSemestre] = useState("")
|
||||||
|
|
||||||
|
const toNull = (s: string) => s.trim() ? s : null
|
||||||
|
const toNum = (s: string) => s.trim() ? Number(s) || null : null
|
||||||
|
|
||||||
|
const canManual = f.nombre.trim().length > 0
|
||||||
|
const canIA = iaPrompt.trim().length > 0
|
||||||
|
const canSubmit = mode === "manual" ? canManual : canIA
|
||||||
|
|
||||||
|
async function createManual() {
|
||||||
|
if (!canManual) return
|
||||||
|
setSaving(true)
|
||||||
|
const payload = {
|
||||||
|
plan_id: planId,
|
||||||
|
nombre: f.nombre.trim(),
|
||||||
|
clave: toNull(f.clave),
|
||||||
|
tipo: toNull(f.tipo),
|
||||||
|
semestre: toNum(f.semestre),
|
||||||
|
creditos: toNum(f.creditos),
|
||||||
|
horas_teoricas: toNum(f.horas_teoricas),
|
||||||
|
horas_practicas: toNum(f.horas_practicas),
|
||||||
|
objetivos: toNull(f.objetivos),
|
||||||
|
contenidos: {}, bibliografia: [], criterios_evaluacion: null,
|
||||||
|
}
|
||||||
|
const { error } = await supabase.from("asignaturas").insert([payload])
|
||||||
|
setSaving(false)
|
||||||
|
if (error) { alert(error.message); return }
|
||||||
|
setOpen(false); onAdded?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWithAI() {
|
||||||
|
if (!canIA) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("http://localhost:3001/api/generar/asignatura", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
planEstudiosId: planId,
|
||||||
|
prompt: iaPrompt,
|
||||||
|
semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined,
|
||||||
|
insert: true, // que la API inserte en DB
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
setOpen(false); onAdded?.()
|
||||||
|
} catch (e:any) {
|
||||||
|
alert(e?.message ?? "Error al generar la asignatura")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = () => (mode === "manual" ? createManual() : createWithAI())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setOpen(true)}>
|
||||||
|
<Icons.Plus className="w-4 h-4 mr-2" /> Nueva asignatura
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="w-[min(92vw,760px)]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Nueva asignatura</DialogTitle>
|
||||||
|
<DialogDescription>Elige cómo crearla: manual o generada por IA.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Conmutador elegante */}
|
||||||
|
<Tabs value={mode} onValueChange={v => setMode(v as "manual" | "ia")} className="w-full">
|
||||||
|
<TabsList
|
||||||
|
className="grid w-full grid-cols-2 rounded-xl border bg-neutral-50 p-1"
|
||||||
|
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
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
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
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* --- Pestaña: Manual --- */}
|
||||||
|
<TabsContent value="manual" className="mt-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Nombre">
|
||||||
|
<Input value={f.nombre} onChange={e=>setF(s=>({...s, nombre:e.target.value}))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Clave">
|
||||||
|
<Input value={f.clave} onChange={e=>setF(s=>({...s, clave:e.target.value}))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Tipo">
|
||||||
|
<Input value={f.tipo} onChange={e=>setF(s=>({...s, tipo:e.target.value}))} placeholder="Obligatoria / Optativa / Taller…" />
|
||||||
|
</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>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* --- Pestaña: IA --- */}
|
||||||
|
<TabsContent value="ia" className="mt-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-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="Periodo (opcional)">
|
||||||
|
<Input value={iaSemestre} onChange={e=>setIaSemestre(e.target.value)} placeholder="1–10" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||||
|
<Button onClick={submit} disabled={saving || !canSubmit}>
|
||||||
|
{saving
|
||||||
|
? (mode === "manual" ? "Guardando…" : "Generando…")
|
||||||
|
: (mode === "manual" ? "Crear" : "Generar e insertar")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
import { CarreraCombobox, FacultadCombobox } from "@/components/users/procedencia-combobox"
|
||||||
|
import { postAPI } from "@/lib/api"
|
||||||
|
|
||||||
|
|
||||||
export type PlanDeEstudios = {
|
export type PlanDeEstudios = {
|
||||||
@@ -79,6 +80,112 @@ function InfoChip({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CreatePlanDialog({
|
||||||
|
open, onOpenChange
|
||||||
|
}: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const auth = useSupabaseAuth()
|
||||||
|
const role = auth.claims?.role
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [facultadId, setFacultadId] = useState(auth.claims?.facultad_id ?? "")
|
||||||
|
const [carreraId, setCarreraId] = useState(auth.claims?.carrera_id ?? "")
|
||||||
|
const [nivel, setNivel] = useState("")
|
||||||
|
const [prompt, setPrompt] = useState(
|
||||||
|
"Genera un plan de estudios claro y realista: "
|
||||||
|
)
|
||||||
|
|
||||||
|
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
|
||||||
|
const lockCarrera = role === "jefe_carrera"
|
||||||
|
|
||||||
|
async function crearConIA() {
|
||||||
|
setErr(null)
|
||||||
|
if (!carreraId) { setErr("Selecciona una carrera."); return }
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
// 1) Generar (e insertar) plan vía tu API
|
||||||
|
const res = await postAPI("/api/generar/plan", {
|
||||||
|
carreraId,
|
||||||
|
prompt,
|
||||||
|
insert: true, // 👈 hace que tu backend inserte en supabase
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2) Si el backend devuelve el ID, vamos directo; si no, recargamos listado
|
||||||
|
const newId = res?.id || res?.plan?.id || res?.data?.id
|
||||||
|
if (newId) {
|
||||||
|
onOpenChange(false)
|
||||||
|
router.invalidate()
|
||||||
|
router.navigate({ to: "/plan/$planId", params: { planId: newId } })
|
||||||
|
} else {
|
||||||
|
onOpenChange(false)
|
||||||
|
router.invalidate()
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(typeof e?.message === "string" ? e.message : "Error al generar el plan.")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-[min(92vw,760px)]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Nuevo plan de estudios (IA)</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="md:col-span-2 space-y-1">
|
||||||
|
<Label>Prompt</Label>
|
||||||
|
<Textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
className="min-h-[120px]"
|
||||||
|
placeholder="Describe cómo debe ser el plan…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Nivel (opcional)</Label>
|
||||||
|
<Input value={nivel} onChange={(e) => setNivel(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Facultad</Label>
|
||||||
|
<FacultadCombobox
|
||||||
|
value={facultadId}
|
||||||
|
onChange={(id) => { setFacultadId(id); setCarreraId("") }}
|
||||||
|
disabled={lockFacultad}
|
||||||
|
placeholder="Elige una facultad…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 space-y-1">
|
||||||
|
<Label>Carrera *</Label>
|
||||||
|
<CarreraCombobox
|
||||||
|
facultadId={facultadId}
|
||||||
|
value={carreraId}
|
||||||
|
onChange={setCarreraId}
|
||||||
|
disabled={!facultadId || lockCarrera}
|
||||||
|
placeholder={facultadId ? "Elige una carrera…" : "Selecciona una facultad primero"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <div className="text-sm text-red-600">{err}</div>}
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-col-reverse sm:flex-row gap-2">
|
||||||
|
<Button variant="outline" className="w-full sm:w-auto" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
||||||
|
<Button className="w-full sm:w-auto" onClick={crearConIA} disabled={saving}>
|
||||||
|
{saving ? "Generando…" : "Generar y crear"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const auth = useSupabaseAuth()
|
const auth = useSupabaseAuth()
|
||||||
@@ -196,189 +303,8 @@ function RouteComponent() {
|
|||||||
<CreatePlanDialog
|
<CreatePlanDialog
|
||||||
open={openCreate}
|
open={openCreate}
|
||||||
onOpenChange={setOpenCreate}
|
onOpenChange={setOpenCreate}
|
||||||
onCreated={(id) => {
|
|
||||||
setOpenCreate(false)
|
|
||||||
router.invalidate()
|
|
||||||
router.navigate({ to: "/plan/$planId", params: { planId: id } })
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function CreatePlanDialog({
|
|
||||||
open, onOpenChange, onCreated,
|
|
||||||
}: {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (v: boolean) => void
|
|
||||||
onCreated: (newId: string) => void
|
|
||||||
}) {
|
|
||||||
const auth = useSupabaseAuth()
|
|
||||||
const role = auth.claims?.role
|
|
||||||
const defaultFac = auth.claims?.facultad_id ?? ""
|
|
||||||
const defaultCar = auth.claims?.carrera_id ?? ""
|
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [form, setForm] = useState<{
|
|
||||||
nombre: string
|
|
||||||
nivel: string
|
|
||||||
duracion: string
|
|
||||||
total_creditos: number | null
|
|
||||||
facultad_id: string
|
|
||||||
carrera_id: string
|
|
||||||
objetivo_general?: string
|
|
||||||
}>({
|
|
||||||
nombre: "",
|
|
||||||
nivel: "",
|
|
||||||
duracion: "",
|
|
||||||
total_creditos: null,
|
|
||||||
facultad_id: defaultFac,
|
|
||||||
carrera_id: defaultCar,
|
|
||||||
objetivo_general: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reglas por rol:
|
|
||||||
const lockFacultad = role === "secretario_academico" || role === "jefe_carrera"
|
|
||||||
const lockCarrera = role === "jefe_carrera"
|
|
||||||
const needsFacultad = role === "secretario_academico" || role === "jefe_carrera" || role === "vicerrectoria" || role === "lci"
|
|
||||||
const needsCarrera = role !== "planeacion" // en general todos crean sobre una carrera
|
|
||||||
|
|
||||||
async function createPlan() {
|
|
||||||
setError(null)
|
|
||||||
if (!form.nombre.trim()) return setError("El nombre es obligatorio.")
|
|
||||||
if (needsCarrera && !form.carrera_id) return setError("Selecciona una carrera.")
|
|
||||||
|
|
||||||
setSaving(true)
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("plan_estudios")
|
|
||||||
.insert({
|
|
||||||
nombre: form.nombre.trim(),
|
|
||||||
nivel: form.nivel || null,
|
|
||||||
duracion: form.duracion || null,
|
|
||||||
total_creditos: form.total_creditos ?? null,
|
|
||||||
objetivo_general: form.objetivo_general || null,
|
|
||||||
carrera_id: form.carrera_id,
|
|
||||||
estado: "activo",
|
|
||||||
})
|
|
||||||
.select("id")
|
|
||||||
.single()
|
|
||||||
|
|
||||||
setSaving(false)
|
|
||||||
if (error) {
|
|
||||||
setError(error.message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onCreated(data!.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="w-[min(92vw,720px)]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Nuevo plan de estudios</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="space-y-1 md:col-span-2">
|
|
||||||
<Label>Nombre *</Label>
|
|
||||||
<Input
|
|
||||||
value={form.nombre}
|
|
||||||
onChange={(e) => setForm(s => ({ ...s, nombre: e.target.value }))}
|
|
||||||
placeholder="Ej. Licenciatura en Ciberseguridad"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Nivel</Label>
|
|
||||||
<Input
|
|
||||||
value={form.nivel}
|
|
||||||
onChange={(e) => setForm(s => ({ ...s, nivel: e.target.value }))}
|
|
||||||
placeholder="Licenciatura / Maestría…"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Duración</Label>
|
|
||||||
<Input
|
|
||||||
value={form.duracion}
|
|
||||||
onChange={(e) => setForm(s => ({ ...s, duracion: e.target.value }))}
|
|
||||||
placeholder="9 semestres / 3 años…"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Créditos totales</Label>
|
|
||||||
<Input
|
|
||||||
inputMode="numeric"
|
|
||||||
value={form.total_creditos ?? ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value.trim()
|
|
||||||
setForm(s => ({ ...s, total_creditos: v === "" ? null : Number(v) || 0 }))
|
|
||||||
}}
|
|
||||||
placeholder="270"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1 md:col-span-2">
|
|
||||||
<Label>Objetivo general</Label>
|
|
||||||
<Textarea
|
|
||||||
value={form.objetivo_general ?? ""}
|
|
||||||
onChange={(e) => setForm(s => ({ ...s, objetivo_general: e.target.value }))}
|
|
||||||
placeholder="Describe el objetivo general del plan…"
|
|
||||||
className="min-h-[90px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Procedencia */}
|
|
||||||
{needsFacultad && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Facultad</Label>
|
|
||||||
<FacultadCombobox
|
|
||||||
value={form.facultad_id}
|
|
||||||
onChange={(id) =>
|
|
||||||
setForm(s => ({
|
|
||||||
...s,
|
|
||||||
facultad_id: id,
|
|
||||||
carrera_id: lockCarrera ? s.carrera_id : "", // limpia carrera si no está bloqueada
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={lockFacultad}
|
|
||||||
/>
|
|
||||||
{lockFacultad && (
|
|
||||||
<p className="text-[11px] text-neutral-500">Fijado por tu rol.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{needsCarrera && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>Carrera *</Label>
|
|
||||||
<CarreraCombobox
|
|
||||||
facultadId={form.facultad_id}
|
|
||||||
value={form.carrera_id}
|
|
||||||
onChange={(id) => setForm(s => ({ ...s, carrera_id: id }))}
|
|
||||||
disabled={lockCarrera || !form.facultad_id}
|
|
||||||
placeholder={form.facultad_id ? "Selecciona carrera…" : "Selecciona una facultad primero"}
|
|
||||||
/>
|
|
||||||
{lockCarrera && (
|
|
||||||
<p className="text-[11px] text-neutral-500">Fijada por tu rol.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="text-sm text-red-600">{error}</div>}
|
|
||||||
|
|
||||||
<DialogFooter className="flex flex-col-reverse sm:flex-row gap-2">
|
|
||||||
<Button variant="outline" className="w-full sm:w-auto" onClick={() => onOpenChange(false)}>Cancelar</Button>
|
|
||||||
<Button className="w-full sm:w-auto" onClick={createPlan} disabled={saving}>
|
|
||||||
{saving ? "Creando…" : "Crear plan"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ function RouteComponent() {
|
|||||||
const { error } = await admin.auth.admin.createUser({
|
const { error } = await admin.auth.admin.createUser({
|
||||||
email: createForm.email.trim(),
|
email: createForm.email.trim(),
|
||||||
password,
|
password,
|
||||||
email_confirm: false,
|
email_confirm: true,
|
||||||
user_metadata: {
|
user_metadata: {
|
||||||
nombre: createForm.nombre ?? "",
|
nombre: createForm.nombre ?? "",
|
||||||
apellidos: createForm.apellidos ?? "",
|
apellidos: createForm.apellidos ?? "",
|
||||||
|
|||||||
Reference in New Issue
Block a user