Compare commits
4 Commits
4950f7efbf
...
ffed64dbcd
| Author | SHA1 | Date | |
|---|---|---|---|
| ffed64dbcd | |||
| e1751ef694 | |||
| 7a7f07b20a | |||
| bf209aa843 |
@@ -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",
|
||||
|
||||
@@ -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,8 +19,27 @@ 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 { cn } from '@/lib/utils'
|
||||
// import { toast } from 'sonner';
|
||||
// import { mockLibraryResources } from '@/data/mockMateriaData';
|
||||
|
||||
@@ -84,19 +85,20 @@ export interface BibliografiaEntry {
|
||||
}
|
||||
|
||||
interface BibliografiaTabProps {
|
||||
bibliografia: BibliografiaEntry[]
|
||||
onSave: (bibliografia: BibliografiaEntry[]) => void
|
||||
bibliografia: Array<BibliografiaEntry>
|
||||
onSave: (bibliografia: Array<BibliografiaEntry>) => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export function BibliographyItem({
|
||||
bibliografia,
|
||||
asignaturaId,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: BibliografiaTabProps) {
|
||||
const { data: bibliografia2, isLoading: loadinmateria } =
|
||||
useSubjectBibliografia('9d4dda6a-488f-428a-8a07-38081592a641')
|
||||
const [entries, setEntries] = useState<BibliografiaEntry[]>(bibliografia)
|
||||
useSubjectBibliografia(asignaturaId)
|
||||
const [entries, setEntries] = useState<Array<BibliografiaEntry>>(bibliografia)
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -68,23 +73,33 @@ function EditableHeaderField({
|
||||
}
|
||||
|
||||
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={`border-none bg-transparent 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 +117,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 +194,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
|
||||
@@ -280,6 +296,7 @@ export default function MateriaDetailPage() {
|
||||
<TabsContent value="bibliografia">
|
||||
<BibliographyItem
|
||||
bibliografia={bibliografia}
|
||||
id={asignaturaId}
|
||||
onSave={handleSaveBibliografia}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
@@ -313,7 +330,7 @@ export default function MateriaDetailPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="historial">
|
||||
<HistorialTab />
|
||||
<HistorialTab asignaturaId={asignaturaId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
250
src/components/ui/context-menu.tsx
Normal file
250
src/components/ui/context-menu.tsx
Normal 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,
|
||||
}
|
||||
@@ -151,6 +151,7 @@ export interface FileRoutesByFullPath {
|
||||
'/planes/$planId': typeof PlanesPlanIdIndexRoute
|
||||
'/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren
|
||||
'/planes/nuevo': typeof PlanesListaNuevoRoute
|
||||
'/planes/$planId/': typeof PlanesPlanIdIndexRoute
|
||||
'/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute
|
||||
'/planes/$planId/datos': typeof PlanesPlanIdDetalleDatosRoute
|
||||
'/planes/$planId/documento': typeof PlanesPlanIdDetalleDocumentoRoute
|
||||
@@ -168,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
|
||||
@@ -215,6 +220,7 @@ export interface FileRouteTypes {
|
||||
| '/planes/$planId'
|
||||
| '/planes/$planId/asignaturas'
|
||||
| '/planes/nuevo'
|
||||
| '/planes/$planId/'
|
||||
| '/planes/$planId/asignaturas/$asignaturaId'
|
||||
| '/planes/$planId/datos'
|
||||
| '/planes/$planId/documento'
|
||||
@@ -319,7 +325,11 @@ declare module '@tanstack/react-router' {
|
||||
'/planes/$planId/': {
|
||||
id: '/planes/$planId/'
|
||||
path: '/planes/$planId'
|
||||
<<<<<<< HEAD
|
||||
fullPath: '/planes/$planId/'
|
||||
=======
|
||||
fullPath: '/planes/$planId'
|
||||
>>>>>>> 4950f7efbf664bbd31ac8a673fe594af5baf07f6
|
||||
preLoaderRoute: typeof PlanesPlanIdIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ function DatosGeneralesPage() {
|
||||
navigate({
|
||||
to: '/planes/$planId/iaplan',
|
||||
params: {
|
||||
planId: '1', // o dinámico
|
||||
planId: planId, // o dinámico
|
||||
},
|
||||
state: {
|
||||
prefill: descripcion,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -233,6 +233,7 @@ function MateriasPage() {
|
||||
},
|
||||
state: {
|
||||
realId: materia.id, // 👈 ID largo oculto
|
||||
asignaturaId: materia.id,
|
||||
} as any,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,12 +5,19 @@ 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 {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu'
|
||||
import { usePlan } from '@/data/hooks/usePlans'
|
||||
|
||||
export const Route = createFileRoute('/planes/$planId/_detalle')({
|
||||
component: RouteComponent,
|
||||
@@ -18,10 +25,55 @@ export const Route = createFileRoute('/planes/$planId/_detalle')({
|
||||
|
||||
function RouteComponent() {
|
||||
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 (
|
||||
<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 +85,70 @@ 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 */}
|
||||
{/* Header del Plan */}
|
||||
<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 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">
|
||||
Ingeniería en Sistemas Computacionales
|
||||
{data?.carreras?.facultades?.nombre}{' '}
|
||||
{data?.carreras?.nombre_corto}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Badges de la derecha */}
|
||||
<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
|
||||
variant="secondary"
|
||||
className="gap-1 border-blue-100 bg-blue-50 px-3 text-blue-700"
|
||||
className={`gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100`}
|
||||
>
|
||||
<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
|
||||
{data?.estados_plan?.etiqueta}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Cards de Información (Nivel, Duración, etc.) */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{/* 3. Cards de Información con Context Menu */}
|
||||
<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
|
||||
icon={<GraduationCap className="text-slate-400" />}
|
||||
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
|
||||
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 +158,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 +189,6 @@ function RouteComponent() {
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 5. Contenido del Tab */}
|
||||
<main className="animate-in fade-in pt-2 duration-500">
|
||||
<Outlet />
|
||||
</main>
|
||||
@@ -126,24 +197,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-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}
|
||||
</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,9 +247,7 @@ 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>
|
||||
|
||||
Reference in New Issue
Block a user