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-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12", "@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-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",

View File

@@ -1,4 +1,3 @@
import { useEffect, useState } from 'react'
import { import {
Plus, Plus,
Search, Search,
@@ -8,25 +7,8 @@ import {
Edit3, Edit3,
Save, Save,
} from 'lucide-react' } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card' import { useEffect, useState } from 'react'
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 { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -37,10 +19,29 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } 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 { useSubjectBibliografia } from '@/data/hooks/useSubjects'
//import { toast } from 'sonner'; import { cn } from '@/lib/utils'
//import { mockLibraryResources } from '@/data/mockMateriaData'; // import { toast } from 'sonner';
// import { mockLibraryResources } from '@/data/mockMateriaData';
export const mockLibraryResources = [ export const mockLibraryResources = [
{ {
@@ -84,19 +85,23 @@ export interface BibliografiaEntry {
} }
interface BibliografiaTabProps { interface BibliografiaTabProps {
bibliografia: BibliografiaEntry[] id: string
onSave: (bibliografia: BibliografiaEntry[]) => void bibliografia: Array<BibliografiaEntry>
onSave: (bibliografia: Array<BibliografiaEntry>) => void
isSaving: boolean isSaving: boolean
} }
export function BibliographyItem({ export function BibliographyItem({
bibliografia, bibliografia,
id,
onSave, onSave,
isSaving, isSaving,
}: BibliografiaTabProps) { }: BibliografiaTabProps) {
console.log(id)
const { data: bibliografia2, isLoading: loadinmateria } = const { data: bibliografia2, isLoading: loadinmateria } =
useSubjectBibliografia('9d4dda6a-488f-428a-8a07-38081592a641') useSubjectBibliografia(id)
const [entries, setEntries] = useState<BibliografiaEntry[]>(bibliografia) const [entries, setEntries] = useState<Array<BibliografiaEntry>>(bibliografia)
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false) const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = useState<string | null>(null)
@@ -128,7 +133,7 @@ export function BibliographyItem({
} }
setEntries([...entries, newEntry]) setEntries([...entries, newEntry])
setIsAddDialogOpen(false) setIsAddDialogOpen(false)
//toast.success('Referencia manual añadida'); // toast.success('Referencia manual añadida');
} }
const handleAddFromLibrary = ( const handleAddFromLibrary = (
@@ -145,7 +150,7 @@ export function BibliographyItem({
} }
setEntries([...entries, newEntry]) setEntries([...entries, newEntry])
setIsLibraryDialogOpen(false) setIsLibraryDialogOpen(false)
//toast.success('Añadido desde biblioteca'); // toast.success('Añadido desde biblioteca');
} }
const handleUpdateCita = (id: string, cita: string) => { 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 { import {
History, History,
FileText, FileText,
@@ -6,31 +7,30 @@ import {
BookMarked, BookMarked,
Sparkles, Sparkles,
FileCheck, FileCheck,
User,
Filter, Filter,
Calendar, Calendar,
Loader2, Loader2,
Eye, Eye,
} from 'lucide-react' } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card' import { useState, useMemo } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { import { Button } from '@/components/ui/button'
DropdownMenu, import { Card, CardContent } from '@/components/ui/card'
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 { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } 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 }> = 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 // 1. Obtenemos los datos directamente dentro del componente
const { data: rawData, isLoading } = useSubjectHistorial( const { data: rawData, isLoading } = useSubjectHistorial(asignaturaId)
'9d4dda6a-488f-428a-8a07-38081592a641',
)
const [filtros, setFiltros] = useState<Set<string>>( const [filtros, setFiltros] = useState<Set<string>>(
new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']), new Set(['datos', 'contenido', 'bibliografia', 'ia', 'documento']),
@@ -164,7 +162,7 @@ export function HistorialTab() {
groups[dateKey].push(cambio) groups[dateKey].push(cambio)
return groups return groups
}, },
{} as Record<string, any[]>, {} as Record<string, Array<any>>,
) )
const sortedDates = Object.keys(groupedHistorial).sort((a, b) => 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 { ArrowLeft, GraduationCap, Pencil, Sparkles } from 'lucide-react'
import { useCallback, useState, useEffect } from 'react' import { useCallback, useState, useEffect } from 'react'
@@ -31,6 +36,7 @@ export interface BibliografiaEntry {
fuenteBiblioteca?: any fuenteBiblioteca?: any
} }
export interface BibliografiaTabProps { export interface BibliografiaTabProps {
id: string
bibliografia: Array<BibliografiaEntry> bibliografia: Array<BibliografiaEntry>
onSave: (bibliografia: Array<BibliografiaEntry>) => void onSave: (bibliografia: Array<BibliografiaEntry>) => void
isSaving: boolean isSaving: boolean
@@ -53,38 +59,34 @@ function EditableHeaderField({
onSave: (val: string) => void onSave: (val: string) => void
className?: string 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 ( return (
<span <input
contentEditable type="text"
suppressContentEditableWarning value={String(value)}
onKeyDown={handleKeyDown} onChange={(e) => onSave(e.target.value)}
onBlur={handleBlur} onBlur={(e) => onSave(e.target.value)}
className={`cursor-text rounded px-1 transition-all outline-none focus:ring-2 focus:ring-blue-400 ${className}`} 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 ?? ''} `}
> />
{value}
</span>
) )
} }
export const Route = createFileRoute(
'/planes/$planId/asignaturas/$asignaturaId',
)({
component: MateriaDetailPage,
})
export default function MateriaDetailPage() { export default function MateriaDetailPage() {
const routerState = useRouterState() const routerState = useRouterState()
const state = routerState.location.state as any const state = routerState.location.state as any
const { data: asignaturasApi, isLoading: loadingAsig } = useSubject( const { asignaturaId } = useParams({
state?.realId, 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 // 1. Asegúrate de tener estos estados en tu componente principal
const [messages, setMessages] = useState<Array<IAMessage>>([]) const [messages, setMessages] = useState<Array<IAMessage>>([])
const [datosGenerales, setDatosGenerales] = useState({}) const [datosGenerales, setDatosGenerales] = useState({})
@@ -102,10 +104,10 @@ export default function MateriaDetailPage() {
useEffect(() => { useEffect(() => {
if (asignaturasApi) { if (asignaturasApi) {
setHeaderData({ setHeaderData({
codigo: asignaturasApi?.codigo ?? '', codigo: asignaturasApi.codigo ?? '',
nombre: asignaturasApi?.nombre ?? '', nombre: asignaturasApi.nombre,
creditos: asignaturasApi?.creditos ?? '', creditos: asignaturasApi.creditos,
ciclo: asignaturasApi?.numero_ciclo ?? 0, ciclo: asignaturasApi.numero_ciclo ?? 0,
}) })
} }
}, [asignaturasApi]) }, [asignaturasApi])
@@ -179,7 +181,8 @@ export default function MateriaDetailPage() {
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white"> <section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
<div className="mx-auto max-w-7xl px-6 py-10"> <div className="mx-auto max-w-7xl px-6 py-10">
<Link <Link
to="/planes" to="/planes/$planId"
params={{ planId }}
className="mb-4 flex items-center gap-2 text-sm text-blue-200 hover:text-white" 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 <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"> <div className="flex flex-col items-end gap-2 text-right">
{/* CRÉDITOS EDITABLES */} {/* CRÉDITOS EDITABLES */}
<Badge variant="secondary" className="gap-1"> <Badge variant="secondary" className="gap-1">
<EditableHeaderField <span className="inline-flex max-w-fit">
value={headerData.creditos} <EditableHeaderField
onSave={(val) => value={headerData.creditos}
handleUpdateHeader('creditos', parseInt(val) || 0) onSave={(val) =>
} handleUpdateHeader('creditos', parseInt(val) || 0)
/> }
/>
</span>
<span>créditos</span> <span>créditos</span>
</Badge> </Badge>
@@ -280,6 +285,7 @@ export default function MateriaDetailPage() {
<TabsContent value="bibliografia"> <TabsContent value="bibliografia">
<BibliographyItem <BibliographyItem
bibliografia={bibliografia} bibliografia={bibliografia}
id={asignaturaId}
onSave={handleSaveBibliografia} onSave={handleSaveBibliografia}
isSaving={isSaving} isSaving={isSaving}
/> />
@@ -313,7 +319,7 @@ export default function MateriaDetailPage() {
</TabsContent> </TabsContent>
<TabsContent value="historial"> <TabsContent value="historial">
<HistorialTab /> <HistorialTab asignaturaId={asignaturaId} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </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_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_update_map: 'plans_update_map',
plans_transition_state: 'plans_transition_state', plans_transition_state: 'plans_transition_state',
@@ -349,7 +349,26 @@ export async function plans_update_fields(
planId: UUID, planId: UUID,
patch: PlansUpdateFieldsPatch, patch: PlansUpdateFieldsPatch,
): Promise<PlanEstudio> { ): 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) */ /** Operaciones del mapa curricular (mover/reordenar) */

View File

@@ -169,6 +169,10 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/planes': typeof PlanesListaRouteRouteWithChildren '/planes': typeof PlanesListaRouteRouteWithChildren
'/demo/tanstack-query': typeof DemoTanstackQueryRoute '/demo/tanstack-query': typeof DemoTanstackQueryRoute
<<<<<<< HEAD
'/planes/PlanesListRoute': typeof PlanesPlanesListRouteRoute
=======
>>>>>>> 4950f7efbf664bbd31ac8a673fe594af5baf07f6
'/planes/$planId': typeof PlanesPlanIdIndexRoute '/planes/$planId': typeof PlanesPlanIdIndexRoute
'/planes/nuevo': typeof PlanesListaNuevoRoute '/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute '/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
@@ -321,7 +325,15 @@ declare module '@tanstack/react-router' {
'/planes/$planId/': { '/planes/$planId/': {
id: '/planes/$planId/' id: '/planes/$planId/'
path: '/planes/$planId' path: '/planes/$planId'
<<<<<<< HEAD
<<<<<<< HEAD
fullPath: '/planes/$planId/' 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 preLoaderRoute: typeof PlanesPlanIdIndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }

View File

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

View File

@@ -1,29 +1,57 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute, useParams } from '@tanstack/react-router'
import { import {
FileText, FileText,
Download, Download,
RefreshCcw, RefreshCcw,
ExternalLink, ExternalLink,
CheckCircle2, CheckCircle2,
Clock, Clock,
FileJson FileJson,
} from "lucide-react" } from 'lucide-react'
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card" 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')({ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
component: RouteComponent, component: RouteComponent,
}) })
function 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 ( 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 */} {/* 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> <div>
<h1 className="text-xl font-bold text-slate-800">Documento del Plan</h1> <h1 className="text-xl font-bold text-slate-800">
<p className="text-sm text-muted-foreground">Vista previa y descarga del documento oficial</p> Documento del Plan
</h1>
<p className="text-muted-foreground text-sm">
Vista previa y descarga del documento oficial
</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm" className="gap-2"> <Button variant="outline" size="sm" className="gap-2">
@@ -32,80 +60,99 @@ function RouteComponent() {
<Button variant="outline" size="sm" className="gap-2"> <Button variant="outline" size="sm" className="gap-2">
<Download size={16} /> Descargar Word <Download size={16} /> Descargar Word
</Button> </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 <Download size={16} /> Descargar PDF
</Button> </Button>
</div> </div>
</div> </div>
{/* TARJETAS DE ESTADO */} {/* TARJETAS DE ESTADO */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<StatusCard <StatusCard
icon={<CheckCircle2 className="text-green-500" />} icon={<CheckCircle2 className="text-green-500" />}
label="Estado" label="Estado"
value="Generado" value="Generado"
/> />
<StatusCard <StatusCard
icon={<Clock className="text-blue-500" />} icon={<Clock className="text-blue-500" />}
label="Última generación" label="Última generación"
value="28 Ene 2024, 11:30" value="28 Ene 2024, 11:30"
/> />
<StatusCard <StatusCard
icon={<FileJson className="text-orange-500" />} icon={<FileJson className="text-orange-500" />}
label="Versión" label="Versión"
value="v1.2" value="v1.2"
/> />
</div> </div>
{/* CONTENEDOR DEL DOCUMENTO (Visor) */} {/* CONTENEDOR DEL DOCUMENTO (Visor) */}
<Card className="border-slate-200 shadow-sm overflow-hidden"> <Card className="overflow-hidden border-slate-200 shadow-sm">
<div className="bg-slate-100/50 p-2 border-b flex justify-between items-center px-4"> <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 text-slate-500 font-medium"> <div className="flex items-center gap-2 text-xs font-medium text-slate-500">
<FileText size={14} /> <FileText size={14} />
Plan_Estudios_ISC_2024.pdf Plan_Estudios_ISC_2024.pdf
</div> </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} /> Abrir en nueva pestaña <ExternalLink size={12} />
</Button> </Button>
</div> </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 */} {/* 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 */} {/* Contenido del Plan */}
<div className="text-center mb-12"> <div className="mb-12 text-center">
<p className="text-xs uppercase tracking-widest text-slate-400 font-bold mb-1">Universidad Tecnológica</p> <p className="mb-1 text-xs font-bold tracking-widest text-slate-400 uppercase">
<h2 className="text-2xl font-bold text-slate-800">Plan de Estudios 2024</h2> Universidad Tecnológica
<h3 className="text-lg text-teal-700 font-semibold">Ingeniería en Sistemas Computacionales</h3> </p>
<p className="text-xs text-slate-500 mt-1">Facultad de Ingeniería</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>
<div className="space-y-8 text-slate-700"> <div className="space-y-8 text-slate-700">
<section> <section>
<h4 className="font-bold text-sm mb-2">1. Objetivo General</h4> <h4 className="mb-2 text-sm font-bold">1. Objetivo General</h4>
<p className="text-sm leading-relaxed text-justify"> <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. 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> </p>
</section> </section>
<section> <section>
<h4 className="font-bold text-sm mb-2">2. Perfil de Ingreso</h4> <h4 className="mb-2 text-sm font-bold">2. Perfil de Ingreso</h4>
<p className="text-sm leading-relaxed text-justify"> <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. 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> </p>
</section> </section>
<section> <section>
<h4 className="font-bold text-sm mb-2">3. Perfil de Egreso</h4> <h4 className="mb-2 text-sm font-bold">3. Perfil de Egreso</h4>
<p className="text-sm leading-relaxed text-justify"> <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. Profesional capaz de diseñar, desarrollar e implementar
sistemas de software de calidad, administrar infraestructuras
de red y liderar proyectos tecnológicos multidisciplinarios.
</p> </p>
</section> </section>
</div> </div>
{/* Marca de agua o decoración lateral (opcional) */} {/* 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> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -114,18 +161,26 @@ function RouteComponent() {
} }
// Componente pequeño para las tarjetas de estado superior // 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 ( return (
<Card className="bg-white border-slate-200"> <Card className="border-slate-200 bg-white">
<CardContent className="p-4 flex items-center gap-4"> <CardContent className="flex items-center gap-4 p-4">
<div className="p-2 rounded-full bg-slate-50 border"> <div className="rounded-full border bg-slate-50 p-2">{icon}</div>
{icon}
</div>
<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> <p className="text-sm font-semibold text-slate-700">{value}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) )
} }

View File

@@ -177,7 +177,29 @@ function MapaCurricularPage() {
const manejarAgregarLinea = (nombre: string) => { const manejarAgregarLinea = (nombre: string) => {
const nombreNormalizado = nombre.trim() 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 = const esAreaComun =
nombreNormalizado.toLowerCase() === 'área común' || nombreNormalizado.toLowerCase() === 'área común' ||
nombreNormalizado.toLowerCase() === 'area comun' nombreNormalizado.toLowerCase() === 'area comun'
@@ -187,10 +209,12 @@ function MapaCurricularPage() {
return return
} }
// 4. Agregar la línea si todo está bien
const nueva = { const nueva = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
nombre: nombreNormalizado, nombre: nombreNormalizado,
orden: lineas.length + 1, orden: lineas.length + 1,
color: '#1976d2',
} }
setLineas([...lineas, nueva]) setLineas([...lineas, nueva])
@@ -198,6 +222,7 @@ function MapaCurricularPage() {
if (esAreaComun) { if (esAreaComun) {
setHasAreaComun(true) setHasAreaComun(true)
} }
setNombreNuevaLinea('') // Limpiar input setNombreNuevaLinea('') // Limpiar input
} }
@@ -599,19 +624,46 @@ function MapaCurricularPage() {
<label className="text-xs font-bold text-slate-500 uppercase"> <label className="text-xs font-bold text-slate-500 uppercase">
Créditos Créditos
</label> </label>
<Input type="number" value={editingData.creditos} /> <Input
type="number"
value={editingData.creditos}
onChange={(e) =>
setEditingData({
...editingData,
creditos: Number(e.target.value),
})
}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase"> <label className="text-xs font-bold text-slate-500 uppercase">
HD (Horas Docente) HD (Horas Docente)
</label> </label>
<Input type="number" value={editingData.hd} /> <Input
type="number"
value={editingData.hd}
onChange={(e) =>
setEditingData({
...editingData,
hd: Number(e.target.value),
})
}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase"> <label className="text-xs font-bold text-slate-500 uppercase">
HI (Horas Indep.) HI (Horas Indep.)
</label> </label>
<Input type="number" value={editingData.hi} /> <Input
type="number"
value={editingData.hi}
onChange={(e) =>
setEditingData({
...editingData,
hi: Number(e.target.value),
})
}
/>
</div> </div>
</div> </div>
@@ -621,11 +673,22 @@ function MapaCurricularPage() {
<label className="text-xs font-bold text-slate-500 uppercase"> <label className="text-xs font-bold text-slate-500 uppercase">
Ciclo Ciclo
</label> </label>
<Select value={editingData.ciclo?.toString() || 'null'}> <Select
value={editingData.ciclo?.toString() || 'unassigned'}
onValueChange={(val) =>
setEditingData({
...editingData,
ciclo: val === 'unassigned' ? null : Number(val),
})
}
>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="unassigned">
-- Sin Asignar --
</SelectItem>
{ciclosArray.map((n) => ( {ciclosArray.map((n) => (
<SelectItem key={n} value={n.toString()}> <SelectItem key={n} value={n.toString()}>
Ciclo {n} Ciclo {n}
@@ -634,15 +697,27 @@ function MapaCurricularPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-slate-500 uppercase"> <label className="text-xs font-bold text-slate-500 uppercase">
Línea Curricular Línea Curricular
</label> </label>
<Select value={editingData.lineaCurricularId || 'null'}> <Select
value={editingData.lineaCurricularId || 'unassigned'}
onValueChange={(val) =>
setEditingData({
...editingData,
lineaCurricularId: val === 'unassigned' ? null : val,
})
}
>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="unassigned">
-- Sin Asignar --
</SelectItem>
{lineas.map((l) => ( {lineas.map((l) => (
<SelectItem key={l.id} value={l.id}> <SelectItem key={l.id} value={l.id}>
{l.nombre} {l.nombre}
@@ -689,7 +764,12 @@ function MapaCurricularPage() {
<label className="text-xs font-bold text-slate-500 uppercase"> <label className="text-xs font-bold text-slate-500 uppercase">
Tipo Tipo
</label> </label>
<Select value={editingData.tipo}> <Select
value={editingData.tipo}
onValueChange={(val: 'obligatoria' | 'optativa') =>
setEditingData({ ...editingData, tipo: val })
}
>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>

View File

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

View File

@@ -5,12 +5,20 @@ import {
Clock, Clock,
Hash, Hash,
CalendarDays, CalendarDays,
Rocket, Save,
BookOpen,
CheckCircle2,
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect } from 'react'
import { Badge } from '@/components/ui/badge' 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')({ export const Route = createFileRoute('/planes/$planId/_detalle')({
component: RouteComponent, component: RouteComponent,
@@ -18,10 +26,55 @@ export const Route = createFileRoute('/planes/$planId/_detalle')({
function RouteComponent() { function RouteComponent() {
const { planId } = Route.useParams() 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 ( return (
<div className="min-h-screen bg-white"> <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="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm">
<div className="px-6 py-2"> <div className="px-6 py-2">
<Link <Link
@@ -33,50 +86,93 @@ function RouteComponent() {
</div> </div>
</div> </div>
{/* 2. Contenido Principal con Padding */}
<div className="mx-auto max-w-[1600px] space-y-8 p-8"> <div className="mx-auto max-w-[1600px] space-y-8 p-8">
{/* Header del Plan y Badges */} {/* Header del Plan */}
<div className="flex flex-col items-start justify-between gap-4 md:flex-row"> {isLoading ? (
<div> /* ===== SKELETON ===== */
<h1 className="text-3xl font-bold tracking-tight text-slate-900"> <div className="mx-auto max-w-[1600px] p-8">
Plan de Estudios 2024 <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
</h1> {Array.from({ length: 6 }).map((_, i) => (
<p className="mt-1 text-lg font-medium text-slate-500"> <DatosGeneralesSkeleton key={i} />
Ingeniería en Sistemas Computacionales ))}
</p> </div>
</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">
<div className="flex gap-2"> {/* <Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
<Badge <CheckCircle2 size={12} /> {data?.estados_plan?.etiqueta}
variant="secondary" </Badge> */}
className="gap-1 border-blue-100 bg-blue-50 px-3 text-blue-700" <Badge
> className={`gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100`}
<Rocket size={12} /> Ingeniería >
</Badge> {data?.estados_plan?.etiqueta}
<Badge </Badge>
variant="secondary" </div>
className="gap-1 border-orange-100 bg-orange-50 px-3 text-orange-700" </div>
> </>
<BookOpen size={12} /> Licenciatura )}
</Badge>
<Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100"> {/* 3. Cards de Información con Context Menu */}
<CheckCircle2 size={12} /> En Revisión <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
</Badge> <DropdownMenu>
</div> <DropdownMenuTrigger>
</div> <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 <InfoCard
icon={<Clock className="text-slate-400" />} icon={<Clock className="text-slate-400" />}
label="Duración" label="Duración"
value="9 Semestres" value={`${data?.numero_ciclos || 0} Ciclos`}
/> />
<InfoCard <InfoCard
icon={<Hash className="text-slate-400" />} icon={<Hash className="text-slate-400" />}
@@ -86,7 +182,7 @@ function RouteComponent() {
<InfoCard <InfoCard
icon={<CalendarDays className="text-slate-400" />} icon={<CalendarDays className="text-slate-400" />}
label="Creación" label="Creación"
value="14 ene 2024" value={data?.creado_en?.split('T')[0]} // Cortamos la fecha para que no sea tan larga
/> />
</div> </div>
@@ -117,7 +213,6 @@ function RouteComponent() {
</nav> </nav>
</div> </div>
{/* 5. Contenido del Tab */}
<main className="animate-in fade-in pt-2 duration-500"> <main className="animate-in fade-in pt-2 duration-500">
<Outlet /> <Outlet />
</main> </main>
@@ -126,24 +221,37 @@ function RouteComponent() {
) )
} }
// Sub-componente para las tarjetas de información
function InfoCard({ function InfoCard({
icon, icon,
label, label,
value, value,
isEditable,
}: { }: {
icon: React.ReactNode icon: React.ReactNode
label: string label: string
value: string value: string | number | undefined
isEditable?: boolean
}) { }) {
return ( return (
<div className="flex items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm"> <div
<div className="rounded-lg border bg-white p-2 shadow-sm">{icon}</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 ${
<div> isEditable
<p className="mb-1 text-[10px] leading-none font-bold tracking-wider text-slate-400 uppercase"> ? '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} {label}
</p> </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>
</div> </div>
) )
@@ -163,11 +271,29 @@ function Tab({
to={to} to={to}
params={params} params={params}
className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800" className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800"
activeProps={{ activeProps={{ className: 'border-teal-600 text-teal-700 font-bold' }}
className: 'border-teal-600 text-teal-700 font-bold',
}}
> >
{children} {children}
</Link> </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/')({ export const Route = createFileRoute('/planes/$planId/')({
beforeLoad: ({ params }) => { beforeLoad: ({ params }) => {
throw redirect({ throw redirect({
to: '/planes/$planId/datos', to: '/planes/$planId/materias',
params, params,
}) })
}, },