finalizada sección de Referencias para la IA
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { CARRERAS } from '@/features/planes/new/catalogs'
|
||||
import type { NewPlanWizardState } from '@/features/planes/new/types'
|
||||
import type { CARRERAS } from '@/features/planes/nuevo/catalogs'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
FACULTADES,
|
||||
NIVELES,
|
||||
TIPOS_CICLO,
|
||||
} from '@/features/planes/new/catalogs'
|
||||
} from '@/features/planes/nuevo/catalogs'
|
||||
|
||||
export function PasoBasicosForm({
|
||||
wizard,
|
||||
@@ -146,7 +146,7 @@ export function PasoBasicosForm({
|
||||
id="tipoCiclo"
|
||||
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
|
||||
>
|
||||
<SelectValue />
|
||||
<SelectValue placeholder="Selecciona tipo de ciclo…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIPOS_CICLO.map((t) => (
|
||||
@@ -174,6 +174,7 @@ export function PasoBasicosForm({
|
||||
},
|
||||
}))
|
||||
}
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { NewPlanWizardState } from '@/features/planes/new/types'
|
||||
import ReferenciasParaIA from './PasoDetallesPanel/ReferenciasParaIA'
|
||||
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -14,7 +16,7 @@ import {
|
||||
CARRERAS,
|
||||
FACULTADES,
|
||||
PLANES_EXISTENTES,
|
||||
} from '@/features/planes/new/catalogs'
|
||||
} from '@/features/planes/nuevo/catalogs'
|
||||
|
||||
export function PasoDetallesPanel({
|
||||
wizard,
|
||||
@@ -42,8 +44,8 @@ export function PasoDetallesPanel({
|
||||
|
||||
if (wizard.modoCreacion === 'IA') {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="desc">Descripción del enfoque</Label>
|
||||
<textarea
|
||||
id="desc"
|
||||
@@ -61,24 +63,8 @@ export function PasoDetallesPanel({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="poblacion">Población objetivo</Label>
|
||||
<Input
|
||||
id="poblacion"
|
||||
placeholder="Ej. Egresados de bachillerato con perfil STEM"
|
||||
value={wizard.iaConfig?.poblacionObjetivo || ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
poblacionObjetivo: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="notas">Notas adicionales</Label>
|
||||
<textarea
|
||||
id="notas"
|
||||
@@ -96,6 +82,7 @@ export function PasoDetallesPanel({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ReferenciasParaIA />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Opcional: se pueden adjuntar recursos IA más adelante.
|
||||
|
||||
186
src/components/planes/wizard/PasoDetallesPanel/FileDropZone.tsx
Normal file
186
src/components/planes/wizard/PasoDetallesPanel/FileDropZone.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Upload, File, X, FileText } from 'lucide-react'
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface UploadedFile {
|
||||
id: string
|
||||
name: string
|
||||
size: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface FileDropzoneProps {
|
||||
onFilesChange?: (files: Array<UploadedFile>) => void
|
||||
acceptedTypes?: string
|
||||
maxFiles?: number
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function FileDropzone({
|
||||
// onFilesChange,
|
||||
acceptedTypes = '.doc,.docx,.pdf',
|
||||
maxFiles = 5,
|
||||
title = 'Arrastra archivos aquí',
|
||||
description = 'o haz clic para seleccionar',
|
||||
}: FileDropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [files, setFiles] = useState<Array<UploadedFile>>([])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files)
|
||||
addFiles(droppedFiles)
|
||||
},
|
||||
[files],
|
||||
)
|
||||
|
||||
const handleFileInput = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const selectedFiles = Array.from(e.target.files)
|
||||
addFiles(selectedFiles)
|
||||
}
|
||||
},
|
||||
[files],
|
||||
)
|
||||
|
||||
const addFiles = (newFiles: Array<File>) => {
|
||||
const uploadedFiles: Array<UploadedFile> = newFiles
|
||||
.slice(0, maxFiles - files.length)
|
||||
.map((file) => ({
|
||||
id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name,
|
||||
size: formatFileSize(file.size),
|
||||
type: file.name.split('.').pop() || 'file',
|
||||
}))
|
||||
|
||||
const updatedFiles = [...files, ...uploadedFiles].slice(0, maxFiles)
|
||||
setFiles(updatedFiles)
|
||||
// onFilesChange(updatedFiles)
|
||||
}
|
||||
|
||||
const removeFile = (fileId: string) => {
|
||||
const updatedFiles = files.filter((f) => f.id !== fileId)
|
||||
setFiles(updatedFiles)
|
||||
// onFilesChange(updatedFiles)
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'pdf':
|
||||
return <FileText className="text-destructive h-4 w-4" />
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return <FileText className="text-info h-4 w-4" />
|
||||
default:
|
||||
return <File className="text-muted-foreground h-4 w-4" />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'border-border hover:border-primary/50 cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all duration-300',
|
||||
isDragging && 'active',
|
||||
)}
|
||||
style={{ background: 'var(--gradient-subtle)' }}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept={acceptedTypes}
|
||||
multiple
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label htmlFor="file-upload" className="cursor-pointer">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-12 w-12 items-center justify-center rounded-xl transition-colors',
|
||||
isDragging
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<Upload className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-foreground text-sm font-medium">{title}</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{description}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Formatos:{' '}
|
||||
{acceptedTypes
|
||||
.replace(/\./g, '')
|
||||
.toUpperCase()
|
||||
.replace(/,/g, ', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Uploaded files list */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="bg-accent/50 border-border fade-in flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
{getFileIcon(file.type)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">{file.size}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||||
onClick={() => removeFile(file.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files.length >= maxFiles && (
|
||||
<p className="text-warning text-center text-xs">
|
||||
Máximo de {maxFiles} archivos alcanzado
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { FileText, FolderOpen, Upload } from 'lucide-react'
|
||||
|
||||
import { FileDropzone } from './FileDropZone'
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContents,
|
||||
} from '@/components/ui/motion-tabs'
|
||||
import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
name: 'Archivos existentes',
|
||||
|
||||
value: 'archivos-existentes',
|
||||
|
||||
icon: FileText,
|
||||
|
||||
content: (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{ARCHIVOS.map((archivo) => (
|
||||
<Label
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{archivo.nombre}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-xs">{archivo.tamaño}</p>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Repositorios',
|
||||
|
||||
value: 'repositorios',
|
||||
|
||||
icon: FolderOpen,
|
||||
|
||||
content: (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{REPOSITORIOS.map((repositorio) => (
|
||||
<Label
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
|
||||
<FolderOpen className="text-muted-foreground h-4 w-4" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{repositorio.nombre}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{repositorio.descripcion} · {repositorio.cantidadArchivos}{' '}
|
||||
archivos
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Subir archivos',
|
||||
|
||||
value: 'subir-archivos',
|
||||
|
||||
icon: Upload,
|
||||
|
||||
content: (
|
||||
<div>
|
||||
<FileDropzone
|
||||
// onFilesChange={(files) =>
|
||||
// handleChange("archivosAdhocIds", files.map((f) => f.id))
|
||||
// }
|
||||
title="Sube archivos de referencia"
|
||||
description="Documentos que serán usados como contexto para la generación"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const ReferenciasParaIA = () => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<Label>Referencias para la IA</Label>
|
||||
|
||||
<Tabs defaultValue="archivos-existentes" className="gap-4">
|
||||
<TabsList className="w-full">
|
||||
{tabs.map(({ icon: Icon, name, value }) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="flex items-center gap-1 px-2.5 sm:px-3"
|
||||
>
|
||||
<Icon />
|
||||
|
||||
<span className="hidden sm:inline">{name}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<TabsContents className="bg-background mx-1 -mt-2 mb-1 h-full rounded-sm">
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="animate-in fade-in duration-300 ease-out"
|
||||
>
|
||||
{tab.content}
|
||||
</TabsContent>
|
||||
))}
|
||||
</TabsContents>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReferenciasParaIA
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
NewPlanWizardState,
|
||||
ModoCreacion,
|
||||
SubModoClonado,
|
||||
} from '@/features/planes/new/types'
|
||||
} from '@/features/planes/nuevo/types'
|
||||
|
||||
import {
|
||||
Card,
|
||||
@@ -23,6 +23,19 @@ export function PasoModoCardGroup({
|
||||
}) {
|
||||
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
|
||||
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
|
||||
const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => {
|
||||
const key = e.key
|
||||
if (
|
||||
key === 'Enter' ||
|
||||
key === ' ' ||
|
||||
key === 'Spacebar' ||
|
||||
key === 'Space'
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
cb()
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card
|
||||
@@ -34,6 +47,15 @@ export function PasoModoCardGroup({
|
||||
subModoClonado: undefined,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
modoCreacion: 'MANUAL',
|
||||
subModoClonado: undefined,
|
||||
})),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -54,6 +76,15 @@ export function PasoModoCardGroup({
|
||||
subModoClonado: undefined,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
modoCreacion: 'IA',
|
||||
subModoClonado: undefined,
|
||||
})),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -70,6 +101,11 @@ export function PasoModoCardGroup({
|
||||
<Card
|
||||
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
|
||||
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({ ...w, modoCreacion: 'CLONADO' })),
|
||||
)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
@@ -88,6 +124,11 @@ export function PasoModoCardGroup({
|
||||
e.stopPropagation()
|
||||
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' }))
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' })),
|
||||
)
|
||||
}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
|
||||
isSubSelected('INTERNO')
|
||||
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
||||
@@ -105,6 +146,11 @@ export function PasoModoCardGroup({
|
||||
e.stopPropagation()
|
||||
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' }))
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
handleKeyActivate(e, () =>
|
||||
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' })),
|
||||
)
|
||||
}
|
||||
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
|
||||
isSubSelected('TRADICIONAL')
|
||||
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NewPlanWizardState } from '@/features/planes/new/types'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
import {
|
||||
Card,
|
||||
|
||||
Reference in New Issue
Block a user