9 Commits

Author SHA1 Message Date
9584cd0c04 Se cierran incidencias #10, #21, #24, #25; se añade generación manual de planes
close #10:
Al crear un plan de manera manual o con IA y redirigirse a planes/{$planId}/datos, sale el confetti.

close #21:
Los archivos que se adjuntan en el wizard ya no se pueden subir mas que una vez.

close #24:
El input de número de ciclos ahora solo permite enteros positivos mayores a 0.

close #25:
Se quitó el botón de generar borrador.
Al adjuntar el primer archivo al wizard, se hace scroll hasta el dropzone.
Los archivos añadidos se listan desde el más reciente al más antiguo.
Se indica claramente el número de archivos adjuntos y el número máximo de archivos que se pueden adjuntar.
2026-01-27 12:01:05 -06:00
80d875167a close #40 2026-01-27 12:00:31 -06:00
3a8b0cc75f Merge pull request 'Implementa actualización de planes utilizando Supabase en lugar de la función Edge' (#38) from feature/actualizar-planes into main
Reviewed-on: #38
2026-01-26 17:44:22 +00:00
35e96bf52c Implementa actualización de planes utilizando Supabase en lugar de la función Edge 2026-01-26 11:43:19 -06:00
695e069a9f Se corrigen incidencias 2026-01-23 10:57:54 -06:00
ffed64dbcd Merge branch 'main' of https://github.lci.ulsa.mx/Guillermo.Arrieta/acad-ia-2 2026-01-22 15:48:49 -06:00
e1751ef694 Se corrigen incidencias 8 y 13 2026-01-22 15:46:04 -06:00
7a7f07b20a Se corrige ediciones del modal y rutas de la pagina con id 2026-01-22 09:31:03 -06:00
bf209aa843 Se agrega id de ruta en las tabs y se corrigen redirecciones 2026-01-22 08:01:15 -06:00
15 changed files with 807 additions and 214 deletions

View File

@@ -21,6 +21,7 @@
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",

View File

@@ -1,4 +1,3 @@
import { useEffect, useState } from 'react'
import {
Plus,
Search,
@@ -8,25 +7,8 @@ import {
Edit3,
Save,
} from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useEffect, useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
@@ -37,10 +19,29 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { useSubjectBibliografia } from '@/data/hooks/useSubjects'
//import { toast } from 'sonner';
//import { mockLibraryResources } from '@/data/mockMateriaData';
import { cn } from '@/lib/utils'
// import { toast } from 'sonner';
// import { mockLibraryResources } from '@/data/mockMateriaData';
export const mockLibraryResources = [
{
@@ -84,19 +85,23 @@ export interface BibliografiaEntry {
}
interface BibliografiaTabProps {
bibliografia: BibliografiaEntry[]
onSave: (bibliografia: BibliografiaEntry[]) => void
id: string
bibliografia: Array<BibliografiaEntry>
onSave: (bibliografia: Array<BibliografiaEntry>) => void
isSaving: boolean
}
export function BibliographyItem({
bibliografia,
id,
onSave,
isSaving,
}: BibliografiaTabProps) {
console.log(id)
const { data: bibliografia2, isLoading: loadinmateria } =
useSubjectBibliografia('9d4dda6a-488f-428a-8a07-38081592a641')
const [entries, setEntries] = useState<BibliografiaEntry[]>(bibliografia)
useSubjectBibliografia(id)
const [entries, setEntries] = useState<Array<BibliografiaEntry>>(bibliografia)
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
const [deleteId, setDeleteId] = useState<string | null>(null)
@@ -128,7 +133,7 @@ export function BibliographyItem({
}
setEntries([...entries, newEntry])
setIsAddDialogOpen(false)
//toast.success('Referencia manual añadida');
// toast.success('Referencia manual añadida');
}
const handleAddFromLibrary = (
@@ -145,7 +150,7 @@ export function BibliographyItem({
}
setEntries([...entries, newEntry])
setIsLibraryDialogOpen(false)
//toast.success('Añadido desde biblioteca');
// toast.success('Añadido desde biblioteca');
}
const handleUpdateCita = (id: string, cita: string) => {

View File

@@ -1,4 +1,5 @@
import { useState, useMemo } from 'react'
import { format, parseISO } from 'date-fns'
import { es } from 'date-fns/locale'
import {
History,
FileText,
@@ -6,31 +7,30 @@ import {
BookMarked,
Sparkles,
FileCheck,
User,
Filter,
Calendar,
Loader2,
Eye,
} from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useState, useMemo } from 'react'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { format, parseISO } from 'date-fns'
import { es } from 'date-fns/locale'
import { useSubjectHistorial } from '@/data/hooks/useSubjects'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useSubjectHistorial } from '@/data/hooks/useSubjects'
import { cn } from '@/lib/utils'
const tipoConfig: Record<string, { label: string; icon: any; color: string }> =
{
@@ -53,11 +53,9 @@ const tipoConfig: Record<string, { label: string; icon: any; color: string }> =
},
}
export function HistorialTab() {
export function HistorialTab({ asignaturaId }) {
// 1. Obtenemos los datos directamente dentro del componente
const { data: rawData, isLoading } = useSubjectHistorial(
'9d4dda6a-488f-428a-8a07-38081592a641',
)
const { data: rawData, isLoading } = useSubjectHistorial(asignaturaId)
const [filtros, setFiltros] = useState<Set<string>>(
new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']),
@@ -164,7 +162,7 @@ export function HistorialTab() {
groups[dateKey].push(cambio)
return groups
},
{} as Record<string, any[]>,
{} as Record<string, Array<any>>,
)
const sortedDates = Object.keys(groupedHistorial).sort((a, b) =>

View File

@@ -1,4 +1,9 @@
import { Link, useRouterState } from '@tanstack/react-router'
import {
createFileRoute,
Link,
useParams,
useRouterState,
} from '@tanstack/react-router'
import { ArrowLeft, GraduationCap, Pencil, Sparkles } from 'lucide-react'
import { useCallback, useState, useEffect } from 'react'
@@ -31,6 +36,7 @@ export interface BibliografiaEntry {
fuenteBiblioteca?: any
}
export interface BibliografiaTabProps {
id: string
bibliografia: Array<BibliografiaEntry>
onSave: (bibliografia: Array<BibliografiaEntry>) => void
isSaving: boolean
@@ -53,38 +59,34 @@ function EditableHeaderField({
onSave: (val: string) => void
className?: string
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
;(e.currentTarget as HTMLElement).blur() // Quita el foco
}
}
const handleBlur = (e: React.FocusEvent<HTMLElement>) => {
const newValue = e.currentTarget.textContent || ''
if (newValue !== value.toString()) {
onSave(newValue)
}
}
return (
<span
contentEditable
suppressContentEditableWarning
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className={`cursor-text rounded px-1 transition-all outline-none focus:ring-2 focus:ring-blue-400 ${className}`}
>
{value}
</span>
<input
type="text"
value={String(value)}
onChange={(e) => onSave(e.target.value)}
onBlur={(e) => onSave(e.target.value)}
className={` w-[${String(value).length || 1}ch] max-w-[6ch] border-none bg-transparent text-center outline-none focus:ring-2 focus:ring-blue-400 ${className ?? ''} `}
/>
)
}
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId',
)({
component: MateriaDetailPage,
})
export default function MateriaDetailPage() {
const routerState = useRouterState()
const state = routerState.location.state as any
const { data: asignaturasApi, isLoading: loadingAsig } = useSubject(
state?.realId,
)
const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { planId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId',
})
const { data: asignaturasApi, isLoading: loadingAsig } =
useSubject(asignaturaId)
// 1. Asegúrate de tener estos estados en tu componente principal
const [messages, setMessages] = useState<Array<IAMessage>>([])
const [datosGenerales, setDatosGenerales] = useState({})
@@ -102,10 +104,10 @@ export default function MateriaDetailPage() {
useEffect(() => {
if (asignaturasApi) {
setHeaderData({
codigo: asignaturasApi?.codigo ?? '',
nombre: asignaturasApi?.nombre ?? '',
creditos: asignaturasApi?.creditos ?? '',
ciclo: asignaturasApi?.numero_ciclo ?? 0,
codigo: asignaturasApi.codigo ?? '',
nombre: asignaturasApi.nombre,
creditos: asignaturasApi.creditos,
ciclo: asignaturasApi.numero_ciclo ?? 0,
})
}
}, [asignaturasApi])
@@ -179,7 +181,8 @@ export default function MateriaDetailPage() {
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<div className="mx-auto max-w-7xl px-6 py-10">
<Link
to="/planes"
to="/planes/$planId"
params={{ planId }}
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white"
>
<ArrowLeft className="h-4 w-4" /> Volver al plan
@@ -224,12 +227,14 @@ export default function MateriaDetailPage() {
<div className="flex flex-col items-end gap-2 text-right">
{/* CRÉDITOS EDITABLES */}
<Badge variant="secondary" className="gap-1">
<EditableHeaderField
value={headerData.creditos}
onSave={(val) =>
handleUpdateHeader('creditos', parseInt(val) || 0)
}
/>
<span className="inline-flex max-w-fit">
<EditableHeaderField
value={headerData.creditos}
onSave={(val) =>
handleUpdateHeader('creditos', parseInt(val) || 0)
}
/>
</span>
<span>créditos</span>
</Badge>
@@ -280,6 +285,7 @@ export default function MateriaDetailPage() {
<TabsContent value="bibliografia">
<BibliographyItem
bibliografia={bibliografia}
id={asignaturaId}
onSave={handleSaveBibliografia}
isSaving={isSaving}
/>
@@ -313,7 +319,7 @@ export default function MateriaDetailPage() {
</TabsContent>
<TabsContent value="historial">
<HistorialTab />
<HistorialTab asignaturaId={asignaturaId} />
</TabsContent>
</Tabs>
</div>

View File

@@ -0,0 +1,250 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,27 @@
// document.api.ts
const DOCUMENT_PDF_URL =
'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434'
interface GeneratePdfParams {
plan_estudio_id: string
}
export async function fetchPlanPdf({
plan_estudio_id,
}: GeneratePdfParams): Promise<Blob> {
const response = await fetch(DOCUMENT_PDF_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ plan_estudio_id }),
})
if (!response.ok) {
throw new Error('Error al generar el PDF')
}
// n8n devuelve el archivo → lo tratamos como blob
return await response.blob()
}

View File

@@ -25,7 +25,7 @@ const EDGE = {
plans_import_from_files: 'plans_import_from_files',
plans_update_fields: 'plans_update_fields',
// plans_update_fields: 'plans_update_fields',
plans_update_map: 'plans_update_map',
plans_transition_state: 'plans_transition_state',
@@ -349,7 +349,26 @@ export async function plans_update_fields(
planId: UUID,
patch: PlansUpdateFieldsPatch,
): Promise<PlanEstudio> {
return invokeEdge<PlanEstudio>(EDGE.plans_update_fields, { planId, patch })
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('planes_estudio')
.update(patch)
.eq('id', planId)
.select(
`
*,
carreras (*, facultades(*)),
estructuras_plan (*),
estados_plan (*)
`,
)
.single()
throwIfError(error)
return requireData(data, 'No se pudo actualizar el plan.')
// Alternativa Edge Function:
// return invokeEdge<PlanEstudio>(EDGE.plans_update_fields, { planId, patch })
}
/** Operaciones del mapa curricular (mover/reordenar) */

View File

@@ -169,6 +169,10 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/planes': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute
<<<<<<< HEAD
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
=======
>>>>>>> 4950f7efbf664bbd31ac8a673fe594af5baf07f6
'/planes/$planId': typeof PlanesPlanIdIndexRoute
'/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
@@ -321,7 +325,15 @@ declare module '@tanstack/react-router' {
'/planes/$planId/': {
id: '/planes/$planId/'
path: '/planes/$planId'
<<<<<<< HEAD
<<<<<<< HEAD
fullPath: '/planes/$planId/'
=======
fullPath: '/planes/$planId'
>>>>>>> 4950f7efbf664bbd31ac8a673fe594af5baf07f6
=======
fullPath: '/planes/$planId/'
>>>>>>> cbe4e54 (Se cierran incidencias #10, #21, #24, #25; se añade generación manual de planes)
preLoaderRoute: typeof PlanesPlanIdIndexRouteImport
parentRoute: typeof rootRouteImport
}

View File

@@ -26,7 +26,7 @@ const formatLabel = (key: string) => {
function DatosGeneralesPage() {
const { planId } = Route.useParams()
const { data } = usePlan(planId)
const { data, isLoading } = usePlan(planId)
const navigate = useNavigate()
// Inicializamos campos como un arreglo vacío
const [campos, setCampos] = useState<Array<DatosGeneralesField>>([])
@@ -89,7 +89,7 @@ function DatosGeneralesPage() {
navigate({
to: '/planes/$planId/iaplan',
params: {
planId: '1', // o dinámico
planId: planId, // o dinámico
},
state: {
prefill: descripcion,

View File

@@ -1,29 +1,57 @@
import { createFileRoute } from '@tanstack/react-router'
import {
FileText,
Download,
RefreshCcw,
ExternalLink,
CheckCircle2,
Clock,
FileJson
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { createFileRoute, useParams } from '@tanstack/react-router'
import {
FileText,
Download,
RefreshCcw,
ExternalLink,
CheckCircle2,
Clock,
FileJson,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { fetchPlanPdf } from '@/data/api/document.api'
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
component: RouteComponent,
})
function RouteComponent() {
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
const handleDownloadPdf = async () => {
console.log('entre aqui ')
try {
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
})
const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
link.href = url
link.download = 'plan_estudios.pdf'
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
} catch (error) {
console.error(error)
alert('No se pudo generar el PDF')
}
}
return (
<div className="flex flex-col gap-6 p-6 bg-slate-50/30 min-h-screen">
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
{/* HEADER DE ACCIONES */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<h1 className="text-xl font-bold text-slate-800">Documento del Plan</h1>
<p className="text-sm text-muted-foreground">Vista previa y descarga del documento oficial</p>
<h1 className="text-xl font-bold text-slate-800">
Documento del Plan
</h1>
<p className="text-muted-foreground text-sm">
Vista previa y descarga del documento oficial
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="gap-2">
@@ -32,80 +60,99 @@ function RouteComponent() {
<Button variant="outline" size="sm" className="gap-2">
<Download size={16} /> Descargar Word
</Button>
<Button size="sm" className="gap-2 bg-teal-700 hover:bg-teal-800">
<Button
size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={handleDownloadPdf}
>
<Download size={16} /> Descargar PDF
</Button>
</div>
</div>
{/* TARJETAS DE ESTADO */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatusCard
icon={<CheckCircle2 className="text-green-500" />}
label="Estado"
value="Generado"
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<StatusCard
icon={<CheckCircle2 className="text-green-500" />}
label="Estado"
value="Generado"
/>
<StatusCard
icon={<Clock className="text-blue-500" />}
label="Última generación"
value="28 Ene 2024, 11:30"
<StatusCard
icon={<Clock className="text-blue-500" />}
label="Última generación"
value="28 Ene 2024, 11:30"
/>
<StatusCard
icon={<FileJson className="text-orange-500" />}
label="Versión"
value="v1.2"
<StatusCard
icon={<FileJson className="text-orange-500" />}
label="Versión"
value="v1.2"
/>
</div>
{/* CONTENEDOR DEL DOCUMENTO (Visor) */}
<Card className="border-slate-200 shadow-sm overflow-hidden">
<div className="bg-slate-100/50 p-2 border-b flex justify-between items-center px-4">
<div className="flex items-center gap-2 text-xs text-slate-500 font-medium">
<Card className="overflow-hidden border-slate-200 shadow-sm">
<div className="flex items-center justify-between border-b bg-slate-100/50 p-2 px-4">
<div className="flex items-center gap-2 text-xs font-medium text-slate-500">
<FileText size={14} />
Plan_Estudios_ISC_2024.pdf
</div>
<Button variant="ghost" size="sm" className="text-xs gap-1 h-7">
<Button variant="ghost" size="sm" className="h-7 gap-1 text-xs">
Abrir en nueva pestaña <ExternalLink size={12} />
</Button>
</div>
<CardContent className="p-0 bg-slate-200/50 flex justify-center py-8 min-h-[800px]">
<CardContent className="flex min-h-[800px] justify-center bg-slate-200/50 p-0 py-8">
{/* SIMULACIÓN DE HOJA DE PAPEL */}
<div className="bg-white w-full max-w-[800px] shadow-2xl p-12 md:p-16 min-h-[1000px] border relative">
<div className="relative min-h-[1000px] w-full max-w-[800px] border bg-white p-12 shadow-2xl md:p-16">
{/* Contenido del Plan */}
<div className="text-center mb-12">
<p className="text-xs uppercase tracking-widest text-slate-400 font-bold mb-1">Universidad Tecnológica</p>
<h2 className="text-2xl font-bold text-slate-800">Plan de Estudios 2024</h2>
<h3 className="text-lg text-teal-700 font-semibold">Ingeniería en Sistemas Computacionales</h3>
<p className="text-xs text-slate-500 mt-1">Facultad de Ingeniería</p>
<div className="mb-12 text-center">
<p className="mb-1 text-xs font-bold tracking-widest text-slate-400 uppercase">
Universidad Tecnológica
</p>
<h2 className="text-2xl font-bold text-slate-800">
Plan de Estudios 2024
</h2>
<h3 className="text-lg font-semibold text-teal-700">
Ingeniería en Sistemas Computacionales
</h3>
<p className="mt-1 text-xs text-slate-500">
Facultad de Ingeniería
</p>
</div>
<div className="space-y-8 text-slate-700">
<section>
<h4 className="font-bold text-sm mb-2">1. Objetivo General</h4>
<p className="text-sm leading-relaxed text-justify">
Formar profesionales altamente capacitados en el desarrollo de soluciones tecnológicas innovadoras, con sólidos conocimientos en programación, bases de datos, redes y seguridad informática.
<h4 className="mb-2 text-sm font-bold">1. Objetivo General</h4>
<p className="text-justify text-sm leading-relaxed">
Formar profesionales altamente capacitados en el desarrollo de
soluciones tecnológicas innovadoras, con sólidos conocimientos
en programación, bases de datos, redes y seguridad
informática.
</p>
</section>
<section>
<h4 className="font-bold text-sm mb-2">2. Perfil de Ingreso</h4>
<p className="text-sm leading-relaxed text-justify">
Egresados de educación media superior con conocimientos básicos de matemáticas, razonamiento lógico y habilidades de comunicación. Interés por la tecnología y la resolución de problemas.
<h4 className="mb-2 text-sm font-bold">2. Perfil de Ingreso</h4>
<p className="text-justify text-sm leading-relaxed">
Egresados de educación media superior con conocimientos
básicos de matemáticas, razonamiento lógico y habilidades de
comunicación. Interés por la tecnología y la resolución de
problemas.
</p>
</section>
<section>
<h4 className="font-bold text-sm mb-2">3. Perfil de Egreso</h4>
<p className="text-sm leading-relaxed text-justify">
Profesional capaz de diseñar, desarrollar e implementar sistemas de software de calidad, administrar infraestructuras de red y liderar proyectos tecnológicos multidisciplinarios.
<h4 className="mb-2 text-sm font-bold">3. Perfil de Egreso</h4>
<p className="text-justify text-sm leading-relaxed">
Profesional capaz de diseñar, desarrollar e implementar
sistemas de software de calidad, administrar infraestructuras
de red y liderar proyectos tecnológicos multidisciplinarios.
</p>
</section>
</div>
{/* Marca de agua o decoración lateral (opcional) */}
<div className="absolute top-0 left-0 w-1 h-full bg-slate-100" />
<div className="absolute top-0 left-0 h-full w-1 bg-slate-100" />
</div>
</CardContent>
</Card>
@@ -114,18 +161,26 @@ function RouteComponent() {
}
// Componente pequeño para las tarjetas de estado superior
function StatusCard({ icon, label, value }: { icon: React.ReactNode, label: string, value: string }) {
function StatusCard({
icon,
label,
value,
}: {
icon: React.ReactNode
label: string
value: string
}) {
return (
<Card className="bg-white border-slate-200">
<CardContent className="p-4 flex items-center gap-4">
<div className="p-2 rounded-full bg-slate-50 border">
{icon}
</div>
<Card className="border-slate-200 bg-white">
<CardContent className="flex items-center gap-4 p-4">
<div className="rounded-full border bg-slate-50 p-2">{icon}</div>
<div>
<p className="text-[10px] uppercase font-bold text-slate-400 tracking-tight">{label}</p>
<p className="text-[10px] font-bold tracking-tight text-slate-400 uppercase">
{label}
</p>
<p className="text-sm font-semibold text-slate-700">{value}</p>
</div>
</CardContent>
</Card>
)
}
}

View File

@@ -177,7 +177,29 @@ function MapaCurricularPage() {
const manejarAgregarLinea = (nombre: string) => {
const nombreNormalizado = nombre.trim()
// Validar si es Área Común (insensible a mayúsculas/minúsculas)
// 1. Validar que no esté vacío
if (!nombreNormalizado) return
// 2. Validar duplicados (Insensible a mayúsculas/minúsculas y acentos)
const nombreParaComparar = nombreNormalizado
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
const yaExiste = lineas.some((l) => {
const lineaNombreBase = l.nombre
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
return lineaNombreBase === nombreParaComparar
})
if (yaExiste) {
alert(`La línea "${nombreNormalizado}" ya existe.`)
return
}
// 3. Validar Área Común (usando tu lógica previa)
const esAreaComun =
nombreNormalizado.toLowerCase() === 'área común' ||
nombreNormalizado.toLowerCase() === 'area comun'
@@ -187,10 +209,12 @@ function MapaCurricularPage() {
return
}
// 4. Agregar la línea si todo está bien
const nueva = {
id: crypto.randomUUID(),
nombre: nombreNormalizado,
orden: lineas.length + 1,
color: '#1976d2',
}
setLineas([...lineas, nueva])
@@ -198,6 +222,7 @@ function MapaCurricularPage() {
if (esAreaComun) {
setHasAreaComun(true)
}
setNombreNuevaLinea('') // Limpiar input
}
@@ -599,19 +624,46 @@ function MapaCurricularPage() {
<label className="text-xs font-bold text-slate-500 uppercase">
Créditos
</label>
<Input type="number" value={editingData.creditos} />
<Input
type="number"
value={editingData.creditos}
onChange={(e) =>
setEditingData({
...editingData,
creditos: Number(e.target.value),
})
}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
HD (Horas Docente)
</label>
<Input type="number" value={editingData.hd} />
<Input
type="number"
value={editingData.hd}
onChange={(e) =>
setEditingData({
...editingData,
hd: Number(e.target.value),
})
}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
HI (Horas Indep.)
</label>
<Input type="number" value={editingData.hi} />
<Input
type="number"
value={editingData.hi}
onChange={(e) =>
setEditingData({
...editingData,
hi: Number(e.target.value),
})
}
/>
</div>
</div>
@@ -621,11 +673,22 @@ function MapaCurricularPage() {
<label className="text-xs font-bold text-slate-500 uppercase">
Ciclo
</label>
<Select value={editingData.ciclo?.toString() || 'null'}>
<Select
value={editingData.ciclo?.toString() || 'unassigned'}
onValueChange={(val) =>
setEditingData({
...editingData,
ciclo: val === 'unassigned' ? null : Number(val),
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">
-- Sin Asignar --
</SelectItem>
{ciclosArray.map((n) => (
<SelectItem key={n} value={n.toString()}>
Ciclo {n}
@@ -634,15 +697,27 @@ function MapaCurricularPage() {
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase">
Línea Curricular
</label>
<Select value={editingData.lineaCurricularId || 'null'}>
<Select
value={editingData.lineaCurricularId || 'unassigned'}
onValueChange={(val) =>
setEditingData({
...editingData,
lineaCurricularId: val === 'unassigned' ? null : val,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">
-- Sin Asignar --
</SelectItem>
{lineas.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.nombre}
@@ -689,7 +764,12 @@ function MapaCurricularPage() {
<label className="text-xs font-bold text-slate-500 uppercase">
Tipo
</label>
<Select value={editingData.tipo}>
<Select
value={editingData.tipo}
onValueChange={(val: 'obligatoria' | 'optativa') =>
setEditingData({ ...editingData, tipo: val })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>

View File

@@ -233,6 +233,7 @@ function MateriasPage() {
},
state: {
realId: materia.id, // 👈 ID largo oculto
asignaturaId: materia.id,
} as any,
})
}

View File

@@ -5,12 +5,20 @@ import {
Clock,
Hash,
CalendarDays,
Rocket,
BookOpen,
CheckCircle2,
Save,
} from 'lucide-react'
import { useState, useEffect } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Skeleton } from '@/components/ui/skeleton'
import { usePlan } from '@/data/hooks/usePlans'
export const Route = createFileRoute('/planes/$planId/_detalle')({
component: RouteComponent,
@@ -18,10 +26,55 @@ export const Route = createFileRoute('/planes/$planId/_detalle')({
function RouteComponent() {
const { planId } = Route.useParams()
const { data, isLoading } = usePlan(planId)
// Estados locales para manejar la edición "en vivo" antes de persistir
const [nombrePlan, setNombrePlan] = useState('')
const [nivelPlan, setNivelPlan] = useState('')
const [isDirty, setIsDirty] = useState(false)
useEffect(() => {
if (data) {
setNombrePlan(data.nombre || '')
setNivelPlan(data.nivel || '')
}
}, [data])
const niveles = [
'Licenciatura',
'Maestría',
'Doctorado',
'Diplomado',
'Especialidad',
]
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault() // Evita el salto de línea
e.currentTarget.blur() // Quita el foco, lo que dispara el onBlur y "guarda" en el estado
}
}
const handleSave = () => {
console.log('Guardando en DB...', { nombrePlan, nivelPlan })
// Aquí iría tu mutation
setIsDirty(false)
}
return (
<div className="min-h-screen bg-white">
{/* 1. Header Superior con Sombra (Volver a planes) */}
{/* Botón Flotante de Guardar */}
{isDirty && (
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-8 bottom-8 z-50 duration-300">
<Button
onClick={handleSave}
className="gap-2 rounded-full bg-teal-600 px-6 shadow-xl hover:bg-teal-700"
>
<Save size={16} /> Guardar cambios del Plan
</Button>
</div>
)}
{/* 1. Header Superior */}
<div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm">
<div className="px-6 py-2">
<Link
@@ -33,50 +86,93 @@ function RouteComponent() {
</div>
</div>
{/* 2. Contenido Principal con Padding */}
<div className="mx-auto max-w-[1600px] space-y-8 p-8">
{/* Header del Plan y Badges */}
<div className="flex flex-col items-start justify-between gap-4 md:flex-row">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Plan de Estudios 2024
</h1>
<p className="mt-1 text-lg font-medium text-slate-500">
Ingeniería en Sistemas Computacionales
</p>
{/* Header del Plan */}
{isLoading ? (
/* ===== SKELETON ===== */
<div className="mx-auto max-w-[1600px] p-8">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => (
<DatosGeneralesSkeleton key={i} />
))}
</div>
</div>
) : (
<>
<div className="flex flex-col items-start justify-between gap-4 md:flex-row">
<div>
<h1 className="flex items-baseline gap-2 text-3xl font-bold tracking-tight text-slate-900">
<span>{nivelPlan} en</span>
<span
role="textbox"
tabIndex={0}
contentEditable
suppressContentEditableWarning
spellCheck={false} // Quita el subrayado rojo de error ortográfico
onKeyDown={handleKeyDown}
onBlur={(e) =>
setNombrePlan(e.currentTarget.textContent || '')
}
className="cursor-text border-b border-transparent decoration-transparent transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500"
style={{
WebkitTextDecoration: 'none',
textDecoration: 'none',
}} // Doble seguridad contra subrayados
>
{nombrePlan}
</span>
</h1>
<p className="mt-1 text-lg font-medium text-slate-500">
{data?.carreras?.facultades?.nombre}{' '}
{data?.carreras?.nombre_corto}
</p>
</div>
{/* Badges de la derecha */}
<div className="flex gap-2">
<Badge
variant="secondary"
className="gap-1 border-blue-100 bg-blue-50 px-3 text-blue-700"
>
<Rocket size={12} /> Ingeniería
</Badge>
<Badge
variant="secondary"
className="gap-1 border-orange-100 bg-orange-50 px-3 text-orange-700"
>
<BookOpen size={12} /> Licenciatura
</Badge>
<Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
<CheckCircle2 size={12} /> En Revisión
</Badge>
</div>
</div>
<div className="flex gap-2">
{/* <Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
<CheckCircle2 size={12} /> {data?.estados_plan?.etiqueta}
</Badge> */}
<Badge
className={`gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100`}
>
{data?.estados_plan?.etiqueta}
</Badge>
</div>
</div>
</>
)}
{/* 3. Cards de Información con Context Menu */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<DropdownMenu>
<DropdownMenuTrigger>
<InfoCard
icon={<GraduationCap className="text-slate-400" />}
label="Nivel"
value={nivelPlan}
isEditable
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
{niveles.map((n) => (
<DropdownMenuItem
key={n}
onClick={() => {
setNivelPlan(n)
setIsDirty(true)
}}
>
{n}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* 3. Cards de Información (Nivel, Duración, etc.) */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<InfoCard
icon={<GraduationCap className="text-slate-400" />}
label="Nivel"
value="Superior"
/>
<InfoCard
icon={<Clock className="text-slate-400" />}
label="Duración"
value="9 Semestres"
value={`${data?.numero_ciclos || 0} Ciclos`}
/>
<InfoCard
icon={<Hash className="text-slate-400" />}
@@ -86,7 +182,7 @@ function RouteComponent() {
<InfoCard
icon={<CalendarDays className="text-slate-400" />}
label="Creación"
value="14 ene 2024"
value={data?.creado_en?.split('T')[0]} // Cortamos la fecha para que no sea tan larga
/>
</div>
@@ -117,7 +213,6 @@ function RouteComponent() {
</nav>
</div>
{/* 5. Contenido del Tab */}
<main className="animate-in fade-in pt-2 duration-500">
<Outlet />
</main>
@@ -126,24 +221,37 @@ function RouteComponent() {
)
}
// Sub-componente para las tarjetas de información
function InfoCard({
icon,
label,
value,
isEditable,
}: {
icon: React.ReactNode
label: string
value: string
value: string | number | undefined
isEditable?: boolean
}) {
return (
<div className="flex items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm">
<div className="rounded-lg border bg-white p-2 shadow-sm">{icon}</div>
<div>
<p className="mb-1 text-[10px] leading-none font-bold tracking-wider text-slate-400 uppercase">
<div
className={`flex h-[72px] w-full items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm transition-all ${
isEditable
? 'cursor-pointer hover:border-teal-200 hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500/40'
: ''
}`}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-white shadow-sm">
{icon}
</div>
<div className="min-w-0 flex-1">
{' '}
{/* min-w-0 es vital para que el truncate funcione en flex */}
<p className="mb-0.5 truncate text-[10px] font-bold tracking-wider text-slate-400 uppercase">
{label}
</p>
<p className="text-sm font-semibold text-slate-700">{value}</p>
<p className="truncate text-sm font-semibold text-slate-700">
{value || '---'}
</p>
</div>
</div>
)
@@ -163,11 +271,29 @@ function Tab({
to={to}
params={params}
className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800"
activeProps={{
className: 'border-teal-600 text-teal-700 font-bold',
}}
activeProps={{ className: 'border-teal-600 text-teal-700 font-bold' }}
>
{children}
</Link>
)
}
function DatosGeneralesSkeleton() {
return (
<div className="rounded-xl border bg-white">
{/* Header */}
<div className="flex items-center justify-between border-b px-5 py-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-8 w-16" />
</div>
{/* Content */}
<div className="space-y-3 p-5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-10/12" />
<Skeleton className="h-4 w-9/12" />
</div>
</div>
)
}

View File

@@ -3,7 +3,7 @@ import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/planes/$planId/')({
beforeLoad: ({ params }) => {
throw redirect({
to: '/planes/$planId/datos',
to: '/planes/$planId/materias',
params,
})
},