Se corrigen incidencias

This commit is contained in:
2026-01-23 10:57:54 -06:00
parent ffed64dbcd
commit 695e069a9f
8 changed files with 266 additions and 135 deletions

View File

@@ -85,6 +85,7 @@ export interface BibliografiaEntry {
} }
interface BibliografiaTabProps { interface BibliografiaTabProps {
id: string
bibliografia: Array<BibliografiaEntry> bibliografia: Array<BibliografiaEntry>
onSave: (bibliografia: Array<BibliografiaEntry>) => void onSave: (bibliografia: Array<BibliografiaEntry>) => void
isSaving: boolean isSaving: boolean
@@ -92,12 +93,14 @@ interface BibliografiaTabProps {
export function BibliographyItem({ export function BibliographyItem({
bibliografia, bibliografia,
asignaturaId, id,
onSave, onSave,
isSaving, isSaving,
}: BibliografiaTabProps) { }: BibliografiaTabProps) {
console.log(id)
const { data: bibliografia2, isLoading: loadinmateria } = const { data: bibliografia2, isLoading: loadinmateria } =
useSubjectBibliografia(asignaturaId) useSubjectBibliografia(id)
const [entries, setEntries] = useState<Array<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)

View File

@@ -36,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
@@ -58,27 +59,13 @@ 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 (
<input <input
type="text" type="text"
value={String(value)} value={String(value)}
onChange={(e) => onSave(e.target.value)} onChange={(e) => onSave(e.target.value)}
onBlur={(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}`} 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 ?? ''} `}
/> />
) )
} }
@@ -240,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>

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

@@ -19,7 +19,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>>([])

View File

@@ -1,4 +1,4 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute, useParams } from '@tanstack/react-router'
import { import {
FileText, FileText,
Download, Download,
@@ -6,24 +6,52 @@ import {
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() {
return ( const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
<div className="flex flex-col gap-6 p-6 bg-slate-50/30 min-h-screen"> const handleDownloadPdf = async () => {
console.log('entre aqui ')
try {
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
})
const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
link.href = url
link.download = 'plan_estudios.pdf'
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
} catch (error) {
console.error(error)
alert('No se pudo generar el PDF')
}
}
return (
<div className="flex 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,14 +60,18 @@ 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"
@@ -58,54 +90,69 @@ function RouteComponent() {
</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,15 +161,23 @@ 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>

View File

@@ -12,11 +12,12 @@ import { useState, useEffect } from 'react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
ContextMenu, DropdownMenu,
ContextMenuContent, DropdownMenuContent,
ContextMenuItem, DropdownMenuItem,
ContextMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/context-menu' } from '@/components/ui/dropdown-menu'
import { Skeleton } from '@/components/ui/skeleton'
import { usePlan } from '@/data/hooks/usePlans' import { usePlan } from '@/data/hooks/usePlans'
export const Route = createFileRoute('/planes/$planId/_detalle')({ export const Route = createFileRoute('/planes/$planId/_detalle')({
@@ -25,7 +26,7 @@ export const Route = createFileRoute('/planes/$planId/_detalle')({
function RouteComponent() { function RouteComponent() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const { data } = usePlan(planId) const { data, isLoading } = usePlan(planId)
// Estados locales para manejar la edición "en vivo" antes de persistir // Estados locales para manejar la edición "en vivo" antes de persistir
const [nombrePlan, setNombrePlan] = useState('') const [nombrePlan, setNombrePlan] = useState('')
@@ -87,63 +88,86 @@ function RouteComponent() {
<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 */} {/* Header del Plan */}
<div className="flex flex-col items-start justify-between gap-4 md:flex-row"> {isLoading ? (
<div> /* ===== SKELETON ===== */
<h1 className="flex items-baseline gap-2 text-3xl font-bold tracking-tight text-slate-900"> <div className="mx-auto max-w-[1600px] p-8">
<span>{nivelPlan} en</span> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<span {Array.from({ length: 6 }).map((_, i) => (
role="textbox" <DatosGeneralesSkeleton key={i} />
tabIndex={0} ))}
contentEditable </div>
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> </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>
<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 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} <CheckCircle2 size={12} /> {data?.estados_plan?.etiqueta}
</Badge> */} </Badge> */}
<Badge <Badge
className={`gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100`} className={`gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100`}
> >
{data?.estados_plan?.etiqueta} {data?.estados_plan?.etiqueta}
</Badge> </Badge>
</div> </div>
</div> </div>
</>
)}
{/* 3. Cards de Información con Context Menu */} {/* 3. Cards de Información con Context Menu */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<ContextMenu> <DropdownMenu>
<ContextMenuTrigger> <DropdownMenuTrigger>
{/* 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={nivelPlan} value={nivelPlan}
isEditable isEditable
/> />
</ContextMenuTrigger> </DropdownMenuTrigger>
<ContextMenuContent className="w-48">
<DropdownMenuContent className="w-48">
{niveles.map((n) => ( {niveles.map((n) => (
<ContextMenuItem key={n} onClick={() => setNivelPlan(n)}> <DropdownMenuItem
key={n}
onClick={() => {
setNivelPlan(n)
setIsDirty(true)
}}
>
{n} {n}
</ContextMenuItem> </DropdownMenuItem>
))} ))}
</ContextMenuContent> </DropdownMenuContent>
</ContextMenu> </DropdownMenu>
<InfoCard <InfoCard
icon={<Clock className="text-slate-400" />} icon={<Clock className="text-slate-400" />}
@@ -212,7 +236,7 @@ function InfoCard({
<div <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 ${ 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 isEditable
? 'cursor-context-menu hover:border-teal-200 hover:bg-white' ? 'cursor-pointer hover:border-teal-200 hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500/40'
: '' : ''
}`} }`}
> >
@@ -253,3 +277,23 @@ function Tab({
</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,
}) })
}, },