4 Commits

10 changed files with 558 additions and 117 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,8 +19,27 @@ 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 { cn } from '@/lib/utils'
// import { toast } from 'sonner'; // import { toast } from 'sonner';
// import { mockLibraryResources } from '@/data/mockMateriaData'; // import { mockLibraryResources } from '@/data/mockMateriaData';
@@ -84,19 +85,20 @@ export interface BibliografiaEntry {
} }
interface BibliografiaTabProps { interface BibliografiaTabProps {
bibliografia: BibliografiaEntry[] bibliografia: Array<BibliografiaEntry>
onSave: (bibliografia: BibliografiaEntry[]) => void onSave: (bibliografia: Array<BibliografiaEntry>) => void
isSaving: boolean isSaving: boolean
} }
export function BibliographyItem({ export function BibliographyItem({
bibliografia, bibliografia,
asignaturaId,
onSave, onSave,
isSaving, isSaving,
}: BibliografiaTabProps) { }: BibliografiaTabProps) {
const { data: bibliografia2, isLoading: loadinmateria } = const { data: bibliografia2, isLoading: loadinmateria } =
useSubjectBibliografia('9d4dda6a-488f-428a-8a07-38081592a641') useSubjectBibliografia(asignaturaId)
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)

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'
@@ -68,23 +73,33 @@ function EditableHeaderField({
} }
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={`border-none bg-transparent 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 +117,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 +194,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
@@ -280,6 +296,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 +330,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

@@ -151,6 +151,7 @@ export interface FileRoutesByFullPath {
'/planes/$planId': typeof PlanesPlanIdIndexRoute '/planes/$planId': typeof PlanesPlanIdIndexRoute
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren '/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
'/planes/nuevo': typeof PlanesListaNuevoRoute '/planes/nuevo': typeof PlanesListaNuevoRoute
'/planes/$planId/': typeof PlanesPlanIdIndexRoute
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute '/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
'/planes/$planId/datos': typeof PlanesPlanIdDetalleDatosRoute '/planes/$planId/datos': typeof PlanesPlanIdDetalleDatosRoute
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute '/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
@@ -168,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
@@ -215,6 +220,7 @@ export interface FileRouteTypes {
| '/planes/$planId' | '/planes/$planId'
| '/planes/$planId/asignaturas' | '/planes/$planId/asignaturas'
| '/planes/nuevo' | '/planes/nuevo'
| '/planes/$planId/'
| '/planes/$planId/asignaturas/$asignaturaId' | '/planes/$planId/asignaturas/$asignaturaId'
| '/planes/$planId/datos' | '/planes/$planId/datos'
| '/planes/$planId/documento' | '/planes/$planId/documento'
@@ -319,7 +325,11 @@ declare module '@tanstack/react-router' {
'/planes/$planId/': { '/planes/$planId/': {
id: '/planes/$planId/' id: '/planes/$planId/'
path: '/planes/$planId' path: '/planes/$planId'
<<<<<<< HEAD
fullPath: '/planes/$planId/'
=======
fullPath: '/planes/$planId' fullPath: '/planes/$planId'
>>>>>>> 4950f7efbf664bbd31ac8a673fe594af5baf07f6
preLoaderRoute: typeof PlanesPlanIdIndexRouteImport preLoaderRoute: typeof PlanesPlanIdIndexRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }

View File

@@ -73,7 +73,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

@@ -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,19 @@ 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 {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
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 +25,55 @@ export const Route = createFileRoute('/planes/$planId/_detalle')({
function RouteComponent() { function RouteComponent() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const { data } = 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 +85,70 @@ 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"> <div className="flex flex-col items-start justify-between gap-4 md:flex-row">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900"> <h1 className="flex items-baseline gap-2 text-3xl font-bold tracking-tight text-slate-900">
Plan de Estudios 2024 <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> </h1>
<p className="mt-1 text-lg font-medium text-slate-500"> <p className="mt-1 text-lg font-medium text-slate-500">
Ingeniería en Sistemas Computacionales {data?.carreras?.facultades?.nombre}{' '}
{data?.carreras?.nombre_corto}
</p> </p>
</div> </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">
<CheckCircle2 size={12} /> {data?.estados_plan?.etiqueta}
</Badge> */}
<Badge <Badge
variant="secondary" className={`gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100`}
className="gap-1 border-blue-100 bg-blue-50 px-3 text-blue-700"
> >
<Rocket size={12} /> Ingeniería {data?.estados_plan?.etiqueta}
</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> </Badge>
</div> </div>
</div> </div>
{/* 3. Cards de Información (Nivel, Duración, etc.) */} {/* 3. Cards de Información con Context Menu */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<ContextMenu>
<ContextMenuTrigger>
{/* Eliminamos el div extra y aplicamos el estilo directamente al trigger si es necesario,
pero con asChild, la InfoCard será el trigger real */}
<InfoCard <InfoCard
icon={<GraduationCap className="text-slate-400" />} icon={<GraduationCap className="text-slate-400" />}
label="Nivel" label="Nivel"
value="Superior" value={nivelPlan}
isEditable
/> />
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{niveles.map((n) => (
<ContextMenuItem key={n} onClick={() => setNivelPlan(n)}>
{n}
</ContextMenuItem>
))}
</ContextMenuContent>
</ContextMenu>
<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 +158,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 +189,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 +197,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-context-menu hover:border-teal-200 hover:bg-white'
: ''
}`}
>
<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,9 +247,7 @@ 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>