Barra de busqueda para filtrar referencias para la IA, cambios a FileDropZone

This commit is contained in:
2026-01-08 13:41:37 -06:00
parent edae79c255
commit cddc676f7d
10 changed files with 417 additions and 148 deletions

View File

@@ -20,6 +20,7 @@ import {
PLANTILLAS_ANEXO_1, PLANTILLAS_ANEXO_1,
PLANTILLAS_ANEXO_2, PLANTILLAS_ANEXO_2,
} from '@/features/planes/nuevo/catalogs' } from '@/features/planes/nuevo/catalogs'
import { cn } from '@/lib/utils'
export function PasoBasicosForm({ export function PasoBasicosForm({
wizard, wizard,
@@ -34,7 +35,9 @@ export function PasoBasicosForm({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1 sm:col-span-2"> <div className="grid gap-1 sm:col-span-2">
<Label htmlFor="nombrePlan">Nombre del plan</Label> <Label htmlFor="nombrePlan">
Nombre del plan <span className="text-destructive">*</span>
</Label>
<Input <Input
id="nombrePlan" id="nombrePlan"
placeholder="Ej. Ingeniería en Sistemas 2026" placeholder="Ej. Ingeniería en Sistemas 2026"
@@ -45,6 +48,7 @@ export function PasoBasicosForm({
datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value }, datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value },
})) }))
} }
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/> />
</div> </div>
@@ -65,9 +69,14 @@ export function PasoBasicosForm({
> >
<SelectTrigger <SelectTrigger
id="facultad" id="facultad"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!" className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.facultadId
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
> >
<SelectValue placeholder="Selecciona facultad…" /> <SelectValue placeholder="Ej. Facultad de Ingeniería" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{FACULTADES.map((f) => ( {FACULTADES.map((f) => (
@@ -93,9 +102,14 @@ export function PasoBasicosForm({
> >
<SelectTrigger <SelectTrigger
id="carrera" id="carrera"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!" className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.carreraId
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
> >
<SelectValue placeholder="Selecciona carrera…" /> <SelectValue placeholder="Ej. Ingeniería en Cibernética y Sistemas Computacionales" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{carrerasFiltradas.map((c) => ( {carrerasFiltradas.map((c) => (
@@ -120,9 +134,14 @@ export function PasoBasicosForm({
> >
<SelectTrigger <SelectTrigger
id="nivel" id="nivel"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!" className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.nivel
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
> >
<SelectValue placeholder="Selecciona nivel…" /> <SelectValue placeholder="Ej. Licenciatura" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{NIVELES.map((n) => ( {NIVELES.map((n) => (
@@ -150,9 +169,14 @@ export function PasoBasicosForm({
> >
<SelectTrigger <SelectTrigger
id="tipoCiclo" id="tipoCiclo"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!" className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.tipoCiclo
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
> >
<SelectValue placeholder="Selecciona tipo de ciclo…" /> <SelectValue placeholder="Ej. Semestre" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{TIPOS_CICLO.map((t) => ( {TIPOS_CICLO.map((t) => (
@@ -170,17 +194,20 @@ export function PasoBasicosForm({
id="numCiclos" id="numCiclos"
type="number" type="number"
min={1} min={1}
value={wizard.datosBasicos.numCiclos} value={wizard.datosBasicos.numCiclos ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({ onChange((w) => ({
...w, ...w,
datosBasicos: { datosBasicos: {
...w.datosBasicos, ...w.datosBasicos,
numCiclos: Number(e.target.value || 1), // Keep undefined when the input is empty so the field stays optional
numCiclos:
e.target.value === '' ? undefined : Number(e.target.value),
}, },
})) }))
} }
placeholder="1" className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 8"
/> />
</div> </div>
</div> </div>
@@ -190,11 +217,35 @@ export function PasoBasicosForm({
cardTitle="Plantilla de plan de estudios" cardTitle="Plantilla de plan de estudios"
cardDescription="Selecciona el Word para tu nuevo plan." cardDescription="Selecciona el Word para tu nuevo plan."
templatesData={PLANTILLAS_ANEXO_1} templatesData={PLANTILLAS_ANEXO_1}
selectedTemplateId={wizard.datosBasicos.plantillaPlanId || ''}
selectedVersion={wizard.datosBasicos.plantillaPlanVersion || ''}
onChange={({ templateId, version }) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
plantillaPlanId: templateId,
plantillaPlanVersion: version,
},
}))
}
/> />
<TemplateSelectorCard <TemplateSelectorCard
cardTitle="Mapa curricular" cardTitle="Plantilla de mapa curricular"
cardDescription="Selecciona el Excel para tu mapa curricular." cardDescription="Selecciona el Excel para tu mapa curricular."
templatesData={PLANTILLAS_ANEXO_2} templatesData={PLANTILLAS_ANEXO_2}
selectedTemplateId={wizard.datosBasicos.plantillaMapaId || ''}
selectedVersion={wizard.datosBasicos.plantillaMapaVersion || ''}
onChange={({ templateId, version }) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
plantillaMapaId: templateId,
plantillaMapaVersion: version,
},
}))
}
/> />
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react' import { useMemo, useState } from 'react'
import { import {
Card, Card,
@@ -46,32 +46,49 @@ interface Props {
cardTitle?: string cardTitle?: string
cardDescription?: string cardDescription?: string
templatesData?: Array<TemplateData> templatesData?: Array<TemplateData>
// Controlled selection (optional). If not provided, component manages its own state
selectedTemplateId?: string
selectedVersion?: string
onChange?: (sel: { templateId: string; version: string }) => void
} }
export function TemplateSelectorCard({ export function TemplateSelectorCard({
cardTitle = 'Configuración del Documento', cardTitle = 'Configuración del Documento',
cardDescription = 'Selecciona la base para tu nuevo plan.', cardDescription = 'Selecciona la base para tu nuevo plan.',
templatesData = DEFAULT_TEMPLATES_DATA, templatesData = DEFAULT_TEMPLATES_DATA,
selectedTemplateId,
selectedVersion,
onChange,
}: Props) { }: Props) {
const [selectedTemplate, setSelectedTemplate] = useState<string>('') const [internalTemplate, setInternalTemplate] = useState<string>('')
const [selectedVersion, setSelectedVersion] = useState<string>('') const [internalVersion, setInternalVersion] = useState<string>('')
const selectedTemplate = selectedTemplateId ?? internalTemplate
const version = selectedVersion ?? internalVersion
// Buscamos las versiones de la plantilla seleccionada // Buscamos las versiones de la plantilla seleccionada
const currentTemplateData = templatesData.find( const currentTemplateData = useMemo(
(t) => t.id === selectedTemplate, () => templatesData.find((t) => t.id === selectedTemplate),
[templatesData, selectedTemplate],
) )
const availableVersions = currentTemplateData?.versions || [] const availableVersions = currentTemplateData?.versions || []
const handleTemplateChange = (value: string) => { const handleTemplateChange = (value: string) => {
setSelectedTemplate(value)
// Buscamos los datos de esta plantilla
const template = templatesData.find((t) => t.id === value) const template = templatesData.find((t) => t.id === value)
const firstVersion = template?.versions?.[0] ?? ''
// Si tiene versiones, seleccionamos la primera automáticamente if (onChange) {
if (template && template.versions.length > 0) { onChange({ templateId: value, version: firstVersion })
setSelectedVersion(template.versions[0])
} else { } else {
setSelectedVersion('') setInternalTemplate(value)
setInternalVersion(firstVersion)
}
}
const handleVersionChange = (value: string) => {
if (onChange) {
onChange({ templateId: selectedTemplate, version: value })
} else {
setInternalVersion(value)
} }
} }
@@ -125,8 +142,8 @@ export function TemplateSelectorCard({
</div> </div>
<Select <Select
value={selectedVersion} value={version}
onValueChange={setSelectedVersion} onValueChange={handleVersionChange}
disabled={!selectedTemplate} disabled={!selectedTemplate}
> >
<SelectTrigger <SelectTrigger

View File

@@ -20,7 +20,7 @@ interface FileDropzoneProps {
} }
export function FileDropzone({ export function FileDropzone({
// onFilesChange, onFilesChange,
acceptedTypes = '.doc,.docx,.pdf', acceptedTypes = '.doc,.docx,.pdf',
maxFiles = 5, maxFiles = 5,
title = 'Arrastra archivos aquí', title = 'Arrastra archivos aquí',
@@ -39,16 +39,12 @@ export function FileDropzone({
setIsDragging(false) setIsDragging(false)
}, []) }, [])
const handleDrop = useCallback( const handleDrop = useCallback((e: React.DragEvent) => {
(e: React.DragEvent) => {
e.preventDefault() e.preventDefault()
setIsDragging(false) setIsDragging(false)
const droppedFiles = Array.from(e.dataTransfer.files) const droppedFiles = Array.from(e.dataTransfer.files)
addFiles(droppedFiles) addFiles(droppedFiles)
}, }, [])
[files],
)
const handleFileInput = useCallback( const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -57,29 +53,40 @@ export function FileDropzone({
addFiles(selectedFiles) addFiles(selectedFiles)
} }
}, },
[files], [],
) )
const addFiles = (newFiles: Array<File>) => { const addFiles = useCallback(
const uploadedFiles: Array<UploadedFile> = newFiles (newFiles: Array<File>) => {
.slice(0, maxFiles - files.length) const toUpload: Array<UploadedFile> = newFiles.map((file) => ({
.map((file) => ({ id:
id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, typeof crypto !== 'undefined' && 'randomUUID' in crypto
? (crypto as any).randomUUID()
: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: file.name, name: file.name,
size: formatFileSize(file.size), size: formatFileSize(file.size),
type: file.name.split('.').pop() || 'file', type: file.name.split('.').pop() || 'file',
})) }))
setFiles((prev) => {
const room = Math.max(0, maxFiles - prev.length)
const next = [...prev, ...toUpload.slice(0, room)].slice(0, maxFiles)
if (onFilesChange) onFilesChange(next)
return next
})
},
[maxFiles, onFilesChange],
)
const updatedFiles = [...files, ...uploadedFiles].slice(0, maxFiles) const removeFile = useCallback(
setFiles(updatedFiles) (fileId: string) => {
// onFilesChange(updatedFiles) setFiles((prev) => {
} const next = prev.filter((f) => f.id !== fileId)
if (onFilesChange) onFilesChange(next)
const removeFile = (fileId: string) => { return next
const updatedFiles = files.filter((f) => f.id !== fileId) })
setFiles(updatedFiles) },
// onFilesChange(updatedFiles) [onFilesChange],
} )
const formatFileSize = (bytes: number): string => { const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B' if (bytes < 1024) return bytes + ' B'

View File

@@ -1,3 +1,4 @@
import { FileDropzone } from './FileDropZone'
import ReferenciasParaIA from './ReferenciasParaIA' import ReferenciasParaIA from './ReferenciasParaIA'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types' import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
@@ -82,7 +83,17 @@ export function PasoDetallesPanel({
} }
/> />
</div> </div>
<ReferenciasParaIA /> <ReferenciasParaIA
onFilesChange={(files) =>
onChange((w) => ({
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
archivosAdjuntos: files,
},
}))
}
/>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
Opcional: se pueden adjuntar recursos IA más adelante. Opcional: se pueden adjuntar recursos IA más adelante.
@@ -229,10 +240,10 @@ export function PasoDetallesPanel({
wizard.subModoClonado === 'TRADICIONAL' wizard.subModoClonado === 'TRADICIONAL'
) { ) {
return ( return (
<div className="grid gap-4"> <div className="flex flex-col gap-4">
<div> <div className="flex flex-col gap-1">
<Label htmlFor="word">Word del plan (obligatorio)</Label> <Label htmlFor="word">Word del plan (obligatorio)</Label>
<input {/* <input
id="word" id="word"
type="file" type="file"
accept=".doc,.docx" accept=".doc,.docx"
@@ -248,6 +259,20 @@ export function PasoDetallesPanel({
}, },
})) }))
} }
/> */}
<FileDropzone
acceptedTypes=".doc,.docx"
maxFiles={1}
onFilesChange={(file) => {
onChange((w) => ({
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoWordPlanId: file,
},
}))
}}
/> />
</div> </div>
<div> <div>

View File

@@ -1,4 +1,7 @@
import { FileText, FolderOpen, Upload } from 'lucide-react' import { FileText, FolderOpen, Upload } from 'lucide-react'
import { useMemo, useState } from 'react'
import BarraBusqueda from '../../BarraBusqueda'
import { FileDropzone } from './FileDropZone' import { FileDropzone } from './FileDropZone'
@@ -13,6 +16,40 @@ import {
} from '@/components/ui/motion-tabs' } from '@/components/ui/motion-tabs'
import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs' import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs'
const ReferenciasParaIA = ({
onFilesChange,
}: {
onFilesChange?: (
files: Array<{ id: string; name: string; size: string; type: string }>,
) => void
}) => {
const [busquedaArchivos, setBusquedaArchivos] = useState('')
const [busquedaRepositorios, setBusquedaRepositorios] = useState('')
const cleanText = (text: string) => {
return text
.normalize('NFD') // Descompone "á" en "a" + "´"
.replace(/[\u0300-\u036f]/g, '') // Elimina los símbolos diacríticos
.toLowerCase() // Convierte a minúsculas
}
// Filtrado de archivos y de repositorios
const archivosFiltrados = useMemo(() => {
// Función helper para limpiar texto (quita acentos y hace minúsculas)
const term = cleanText(busquedaArchivos)
return ARCHIVOS.filter((archivo) =>
cleanText(archivo.nombre).includes(term),
)
}, [busquedaArchivos])
const repositoriosFiltrados = useMemo(() => {
const term = cleanText(busquedaRepositorios)
return REPOSITORIOS.filter((repositorio) =>
cleanText(repositorio.nombre).includes(term),
)
}, [busquedaRepositorios])
const tabs = [ const tabs = [
{ {
name: 'Archivos existentes', name: 'Archivos existentes',
@@ -22,11 +59,18 @@ const tabs = [
icon: FileText, icon: FileText,
content: ( content: (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col">
{ARCHIVOS.map((archivo) => ( <BarraBusqueda
value={busquedaArchivos}
onChange={setBusquedaArchivos}
placeholder="Buscar archivo existente..."
className="m-1 mb-1.5"
/>
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
{archivosFiltrados.map((archivo) => (
<Label <Label
key={archivo.id} key={archivo.id}
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-[[aria-checked=true]]:border-blue-600 has-[[aria-checked=true]]:bg-blue-50 dark:has-[[aria-checked=true]]:border-blue-900 dark:has-[[aria-checked=true]]:bg-blue-950" className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950"
> >
<Checkbox className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" /> <Checkbox className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" />
@@ -37,11 +81,14 @@ const tabs = [
{archivo.nombre} {archivo.nombre}
</p> </p>
<p className="text-muted-foreground text-xs">{archivo.tamaño}</p> <p className="text-muted-foreground text-xs">
{archivo.tamaño}
</p>
</div> </div>
</Label> </Label>
))} ))}
</div> </div>
</div>
), ),
}, },
@@ -53,11 +100,18 @@ const tabs = [
icon: FolderOpen, icon: FolderOpen,
content: ( content: (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col">
{REPOSITORIOS.map((repositorio) => ( <BarraBusqueda
value={busquedaRepositorios}
onChange={setBusquedaRepositorios}
placeholder="Buscar repositorio..."
className="m-1 mb-1.5"
/>
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
{repositoriosFiltrados.map((repositorio) => (
<Label <Label
key={repositorio.id} key={repositorio.id}
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-[[aria-checked=true]]:border-blue-600 has-[[aria-checked=true]]:bg-blue-50 dark:has-[[aria-checked=true]]:border-blue-900 dark:has-[[aria-checked=true]]:bg-blue-950" className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950"
> >
<Checkbox className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" /> <Checkbox className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" />
@@ -75,6 +129,7 @@ const tabs = [
</Label> </Label>
))} ))}
</div> </div>
</div>
), ),
}, },
@@ -88,9 +143,7 @@ const tabs = [
content: ( content: (
<div> <div>
<FileDropzone <FileDropzone
// onFilesChange={(files) => onFilesChange={onFilesChange}
// handleChange("archivosAdhocIds", files.map((f) => f.id))
// }
title="Sube archivos de referencia" title="Sube archivos de referencia"
description="Documentos que serán usados como contexto para la generación" description="Documentos que serán usados como contexto para la generación"
/> />
@@ -99,7 +152,6 @@ const tabs = [
}, },
] ]
const ReferenciasParaIA = () => {
return ( return (
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<Label>Referencias para la IA</Label> <Label>Referencias para la IA</Label>

View File

@@ -7,6 +7,10 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import {
PLANTILLAS_ANEXO_1,
PLANTILLAS_ANEXO_2,
} from '@/features/planes/nuevo/catalogs'
export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) { export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
const modo = wizard.modoCreacion const modo = wizard.modoCreacion
@@ -46,6 +50,35 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
{wizard.datosBasicos.numCiclos} ({wizard.datosBasicos.tipoCiclo}) {wizard.datosBasicos.numCiclos} ({wizard.datosBasicos.tipoCiclo})
</span> </span>
</div> </div>
{/* Plantillas seleccionadas */}
<div className="mt-2">
<span className="text-muted-foreground">Plantilla plan: </span>
<span className="font-medium">
{(() => {
const t = PLANTILLAS_ANEXO_1.find(
(x) => x.id === wizard.datosBasicos.plantillaPlanId,
)
const name =
t?.name || wizard.datosBasicos.plantillaPlanId || '—'
const ver = wizard.datosBasicos.plantillaPlanVersion || '—'
return `${name} · ${ver}`
})()}
</span>
</div>
<div>
<span className="text-muted-foreground">Mapa curricular: </span>
<span className="font-medium">
{(() => {
const t = PLANTILLAS_ANEXO_2.find(
(x) => x.id === wizard.datosBasicos.plantillaMapaId,
)
const name =
t?.name || wizard.datosBasicos.plantillaMapaId || '—'
const ver = wizard.datosBasicos.plantillaMapaVersion || '—'
return `${name} · ${ver}`
})()}
</span>
</div>
<div className="mt-2"> <div className="mt-2">
<span className="text-muted-foreground">Modo: </span> <span className="text-muted-foreground">Modo: </span>
<span className="font-medium"> <span className="font-medium">
@@ -59,6 +92,57 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
'Importado desde documentos tradicionales'} 'Importado desde documentos tradicionales'}
</span> </span>
</div> </div>
{modo === 'IA' && (
<div className="bg-muted/50 mt-2 rounded-md p-3">
<div>
<span className="text-muted-foreground">Enfoque: </span>
<span className="font-medium">
{wizard.iaConfig?.descripcionEnfoque || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Notas: </span>
<span className="font-medium">
{wizard.iaConfig?.notasAdicionales || '—'}
</span>
</div>
{!!(wizard.iaConfig?.archivosReferencia?.length || 0) && (
<div className="text-muted-foreground text-xs">
Archivos existentes:{' '}
{wizard.iaConfig?.archivosReferencia?.length}
</div>
)}
{!!(wizard.iaConfig?.repositoriosReferencia?.length || 0) && (
<div className="text-muted-foreground text-xs">
Repositorios:{' '}
{wizard.iaConfig?.repositoriosReferencia?.length}
</div>
)}
{!!(wizard.iaConfig?.archivosAdjuntos?.length || 0) && (
<div className="mt-2">
<div className="font-medium">Adjuntos</div>
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{wizard.iaConfig?.archivosAdjuntos?.map((f) => (
<li key={f.id}>
<span className="text-foreground">{f.name}</span>{' '}
<span>· {f.size}</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
{modo === 'CLONADO' && sub === 'TRADICIONAL' && (
<div className="mt-2">
<span className="text-muted-foreground">
Archivo Word del plan:{' '}
</span>
<span className="font-medium">
{wizard.clonTradicional?.archivoWordPlanId?.name || '—'}
</span>
</div>
)}
{wizard.resumen.previewPlan && ( {wizard.resumen.previewPlan && (
<div className="bg-muted mt-2 rounded-md p-3"> <div className="bg-muted mt-2 rounded-md p-3">
<div className="font-medium">Preview IA</div> <div className="font-medium">Preview IA</div>

View File

@@ -115,20 +115,6 @@ export const REPOSITORIOS = [
}, },
]; ];
export const ESTRUCTURAS_PLAN_ESTUDIO = [
{
id: "estruc-1",
nombre: "Estructura RVOE 2017.docx",
versiones: ["v1.0", "v1.1", "v2.0"],
},
{
id: "estruc-2",
nombre: "Estructura RVOE 2026.docx",
versiones: ["v1.0", "v1.1"],
},
{ id: "estruc-3", nombre: "Estructura ULSA 2022.docx", versiones: ["v1.0"] },
];
export const PLANTILLAS_ANEXO_1 = [ export const PLANTILLAS_ANEXO_1 = [
{ {
id: "sep-2025", id: "sep-2025",

View File

@@ -8,6 +8,18 @@ export function useNuevoPlanWizard() {
const [wizard, setWizard] = useState<NewPlanWizardState>({ const [wizard, setWizard] = useState<NewPlanWizardState>({
step: 1, step: 1,
modoCreacion: null, modoCreacion: null,
// datosBasicos: {
// nombrePlan: "",
// carreraId: "",
// facultadId: "",
// nivel: "",
// tipoCiclo: "",
// numCiclos: undefined,
// plantillaPlanId: "",
// plantillaPlanVersion: "",
// plantillaMapaId: "",
// plantillaMapaVersion: "",
// },
datosBasicos: { datosBasicos: {
nombrePlan: "Medicina", nombrePlan: "Medicina",
carreraId: "medico", carreraId: "medico",
@@ -15,6 +27,10 @@ export function useNuevoPlanWizard() {
nivel: "Licenciatura", nivel: "Licenciatura",
tipoCiclo: "SEMESTRE", tipoCiclo: "SEMESTRE",
numCiclos: 8, numCiclos: 8,
plantillaPlanId: "sep-2025",
plantillaPlanVersion: "v2025.2 (Vigente)",
plantillaMapaId: "sep-2017-xlsx",
plantillaMapaVersion: "v2017.0",
}, },
clonInterno: { planOrigenId: null }, clonInterno: { planOrigenId: null },
clonTradicional: { clonTradicional: {
@@ -27,6 +43,8 @@ export function useNuevoPlanWizard() {
poblacionObjetivo: "", poblacionObjetivo: "",
notasAdicionales: "", notasAdicionales: "",
archivosReferencia: [], archivosReferencia: [],
repositoriosReferencia: [],
archivosAdjuntos: [],
}, },
resumen: {}, resumen: {},
isLoading: false, isLoading: false,
@@ -47,12 +65,19 @@ export function useNuevoPlanWizard() {
!!wizard.datosBasicos.facultadId && !!wizard.datosBasicos.facultadId &&
!!wizard.datosBasicos.nivel && !!wizard.datosBasicos.nivel &&
(wizard.datosBasicos.numCiclos !== undefined && (wizard.datosBasicos.numCiclos !== undefined &&
wizard.datosBasicos.numCiclos > 0); wizard.datosBasicos.numCiclos > 0) &&
// Requerir ambas plantillas (plan y mapa) con versión
!!wizard.datosBasicos.plantillaPlanId &&
!!wizard.datosBasicos.plantillaPlanVersion &&
!!wizard.datosBasicos.plantillaMapaId &&
!!wizard.datosBasicos.plantillaMapaVersion;
const canContinueDesdeDetalles = (() => { const canContinueDesdeDetalles = (() => {
if (wizard.modoCreacion === "MANUAL") return true; if (wizard.modoCreacion === "MANUAL") return true;
if (wizard.modoCreacion === "IA") { if (wizard.modoCreacion === "IA") {
return !!wizard.iaConfig?.descripcionEnfoque; // Requerimos descripción del enfoque y notas adicionales
return !!wizard.iaConfig?.descripcionEnfoque &&
!!wizard.iaConfig?.notasAdicionales;
} }
if (wizard.modoCreacion === "CLONADO") { if (wizard.modoCreacion === "CLONADO") {
if (wizard.subModoClonado === "INTERNO") { if (wizard.subModoClonado === "INTERNO") {

View File

@@ -22,11 +22,20 @@ export type NewPlanWizardState = {
nivel: string; nivel: string;
tipoCiclo: TipoCiclo | ""; tipoCiclo: TipoCiclo | "";
numCiclos: number | undefined; numCiclos: number | undefined;
// Selección de plantillas (obligatorias)
plantillaPlanId?: string;
plantillaPlanVersion?: string;
plantillaMapaId?: string;
plantillaMapaVersion?: string;
}; };
clonInterno?: { planOrigenId: string | null }; clonInterno?: { planOrigenId: string | null };
clonTradicional?: { clonTradicional?: {
archivoWordPlanId: string | null; archivoWordPlanId:
archivoMapaExcelId: string | null; | { id: string; name: string; size: string; type: string }
| null;
archivoMapaExcelId:
| string
| null;
archivoAsignaturasExcelId: string | null; archivoAsignaturasExcelId: string | null;
}; };
iaConfig?: { iaConfig?: {
@@ -34,6 +43,10 @@ export type NewPlanWizardState = {
poblacionObjetivo: string; poblacionObjetivo: string;
notasAdicionales: string; notasAdicionales: string;
archivosReferencia: Array<string>; archivosReferencia: Array<string>;
repositoriosReferencia?: Array<string>;
archivosAdjuntos?: Array<
{ id: string; name: string; size: string; type: string }
>;
}; };
resumen: { previewPlan?: PlanPreview }; resumen: { previewPlan?: PlanPreview };
isLoading: boolean; isLoading: boolean;

View File

@@ -183,10 +183,19 @@ function RouteComponent() {
// Filtrado de planes // Filtrado de planes
const filteredPlans = useMemo(() => { const filteredPlans = useMemo(() => {
const term = search.trim().toLowerCase() // Función helper para limpiar texto (quita acentos y hace minúsculas)
const cleanText = (text: string) => {
return text
.normalize('NFD') // Descompone "á" en "a" + "´"
.replace(/[\u0300-\u036f]/g, '') // Elimina los símbolos diacríticos
.toLowerCase() // Convierte a minúsculas
}
// Limpiamos el término de búsqueda una sola vez antes de filtrar
const term = cleanText(search.trim())
return planes.filter((p) => { return planes.filter((p) => {
const matchName = term const matchName = term
? p.nombrePrograma.toLowerCase().includes(term) ? // Limpiamos también el nombre del programa antes de comparar
cleanText(p.nombrePrograma).includes(term)
: true : true
const matchFac = const matchFac =
facultadSel === 'todas' ? true : p.facultadId === facultadSel facultadSel === 'todas' ? true : p.facultadId === facultadSel