Merge branch 'main' into fix/Incidencias
This commit is contained in:
@@ -215,7 +215,15 @@ export function PasoBasicosForm({
|
||||
id="numCiclos"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={wizard.datosBasicos.numCiclos ?? ''}
|
||||
onKeyDown={(e) => {
|
||||
if (['.', ',', '-', 'e', 'E', '+'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(
|
||||
(w): NewPlanWizardState => ({
|
||||
@@ -223,10 +231,15 @@ export function PasoBasicosForm({
|
||||
datosBasicos: {
|
||||
...w.datosBasicos,
|
||||
// Keep undefined when the input is empty so the field stays optional
|
||||
numCiclos:
|
||||
e.target.value === ''
|
||||
? undefined
|
||||
: Number(e.target.value),
|
||||
numCiclos: (() => {
|
||||
const raw = e.target.value
|
||||
if (raw === '') return undefined
|
||||
const asNumber = Number(raw)
|
||||
if (Number.isNaN(asNumber)) return undefined
|
||||
// Coerce to positive integer (natural numbers without zero)
|
||||
const n = Math.floor(Math.abs(asNumber))
|
||||
return n >= 1 ? n : 1
|
||||
})(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -75,7 +75,7 @@ export function TemplateSelectorCard({
|
||||
|
||||
const handleTemplateChange = (value: string) => {
|
||||
const template = templatesData.find((t) => t.id === value)
|
||||
const firstVersion = template?.versions?.[0] ?? ''
|
||||
const firstVersion = template?.versions[0] ?? ''
|
||||
if (onChange) {
|
||||
onChange({ templateId: value, version: firstVersion })
|
||||
} else {
|
||||
|
||||
@@ -18,6 +18,7 @@ interface FileDropzoneProps {
|
||||
maxFiles?: number
|
||||
title?: string
|
||||
description?: string
|
||||
autoScrollToDropzone?: boolean
|
||||
}
|
||||
|
||||
export function FileDropzone({
|
||||
@@ -27,39 +28,71 @@ export function FileDropzone({
|
||||
maxFiles = 5,
|
||||
title = 'Arrastra archivos aquí',
|
||||
description = 'o haz clic para seleccionar',
|
||||
autoScrollToDropzone = false,
|
||||
}: FileDropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [files, setFiles] = useState<Array<UploadedFile>>(persistentFiles ?? [])
|
||||
const onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const prevFilesLengthRef = useRef(files.length)
|
||||
|
||||
const addFiles = useCallback(
|
||||
(newFiles: Array<File>) => {
|
||||
const toUpload: Array<UploadedFile> = newFiles.map((file) => ({
|
||||
id:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? (crypto as any).randomUUID()
|
||||
: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
file,
|
||||
}))
|
||||
setFiles((prev) => {
|
||||
const room = Math.max(0, maxFiles - prev.length)
|
||||
const next = [...prev, ...toUpload.slice(0, room)].slice(0, maxFiles)
|
||||
return next
|
||||
(incomingFiles: Array<File>) => {
|
||||
console.log(
|
||||
'incoming files:',
|
||||
incomingFiles.map((file) => file.name),
|
||||
)
|
||||
|
||||
setFiles((previousFiles) => {
|
||||
console.log(
|
||||
'previous files',
|
||||
previousFiles.map((f) => f.file.name),
|
||||
)
|
||||
|
||||
// Evitar duplicados por nombre (comprobación global en los archivos existentes)
|
||||
const existingFileNames = new Set(
|
||||
previousFiles.map((uploaded) => uploaded.file.name),
|
||||
)
|
||||
const uniqueNewFiles = incomingFiles.filter(
|
||||
(incomingFile) => !existingFileNames.has(incomingFile.name),
|
||||
)
|
||||
|
||||
// Convertir archivos a objetos con ID único para manejo en React
|
||||
const filesToUpload: Array<UploadedFile> = uniqueNewFiles.map(
|
||||
(incomingFile) => ({
|
||||
id:
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? (crypto as any).randomUUID()
|
||||
: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
file: incomingFile,
|
||||
}),
|
||||
)
|
||||
|
||||
// Calcular espacio disponible respetando el límite máximo
|
||||
const room = Math.max(0, maxFiles - previousFiles.length)
|
||||
const nextFiles = [
|
||||
...previousFiles,
|
||||
...filesToUpload.slice(0, room),
|
||||
].slice(0, maxFiles)
|
||||
return nextFiles
|
||||
})
|
||||
},
|
||||
[maxFiles],
|
||||
)
|
||||
|
||||
// Manejador para cuando se arrastran archivos sobre la zona
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
// Manejador para cuando se sale de la zona de arrastre
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
// Manejador para cuando se sueltan los archivos
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -70,33 +103,68 @@ export function FileDropzone({
|
||||
[addFiles],
|
||||
)
|
||||
|
||||
// Manejador para la selección de archivos mediante el input nativo
|
||||
const handleFileInput = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const selectedFiles = Array.from(e.target.files)
|
||||
addFiles(selectedFiles)
|
||||
// Corrección de bug: Limpiar el valor para permitir seleccionar el mismo archivo nuevamente si fue eliminado
|
||||
e.target.value = ''
|
||||
}
|
||||
},
|
||||
[addFiles],
|
||||
)
|
||||
|
||||
// Función para eliminar un archivo específico por su ID
|
||||
const removeFile = useCallback((fileId: string) => {
|
||||
setFiles((prev) => {
|
||||
const next = prev.filter((f) => f.id !== fileId)
|
||||
return next
|
||||
setFiles((previousFiles) => {
|
||||
console.log(
|
||||
'previous files',
|
||||
previousFiles.map((f) => f.file.name),
|
||||
)
|
||||
const remainingFiles = previousFiles.filter(
|
||||
(uploadedFile) => uploadedFile.id !== fileId,
|
||||
)
|
||||
return remainingFiles
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Keep latest callback in a ref to avoid retriggering effect on identity change
|
||||
// Mantener la referencia actualizada de la función callback externa para evitar loops en useEffect
|
||||
useEffect(() => {
|
||||
onFilesChangeRef.current = onFilesChange
|
||||
}, [onFilesChange])
|
||||
|
||||
// Only emit when files actually change to avoid parent update loops
|
||||
// Notificar al componente padre cuando cambia la lista de archivos
|
||||
useEffect(() => {
|
||||
if (onFilesChangeRef.current) onFilesChangeRef.current(files)
|
||||
}, [files])
|
||||
|
||||
// Scroll automático hacia abajo solo cuando se pasa de 0 a 1 o más archivos
|
||||
useEffect(() => {
|
||||
if (
|
||||
autoScrollToDropzone &&
|
||||
prevFilesLengthRef.current === 0 &&
|
||||
files.length > 0
|
||||
) {
|
||||
// Usar un pequeño timeout para asegurar que el renderizado se complete
|
||||
const timer = setTimeout(() => {
|
||||
bottomRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
})
|
||||
}, 100)
|
||||
|
||||
// Actualizar la referencia
|
||||
prevFilesLengthRef.current = files.length
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
|
||||
// Mantener sincronizada la referencia en otros casos
|
||||
prevFilesLengthRef.current = files.length
|
||||
}, [files.length, autoScrollToDropzone])
|
||||
|
||||
// Determinar el icono a mostrar según la extensión del archivo
|
||||
const getFileIcon = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'pdf':
|
||||
@@ -111,13 +179,19 @@ export function FileDropzone({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Elemento invisible para referencia de scroll */}
|
||||
<div ref={bottomRef} />
|
||||
|
||||
{/* Área principal de dropzone */}
|
||||
<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',
|
||||
'cursor-pointer rounded-xl border-2 border-dashed p-7 text-center transition-all duration-300',
|
||||
// Siempre usar borde por defecto a menos que se esté arrastrando
|
||||
'border-border hover:border-primary/50',
|
||||
isDragging && 'ring-primary ring-2 ring-offset-2',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
@@ -127,6 +201,7 @@ export function FileDropzone({
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
disabled={files.length >= maxFiles}
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
@@ -146,9 +221,9 @@ export function FileDropzone({
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-foreground text-sm font-medium">{title}</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{/* <p className="text-muted-foreground mt-1 text-xs">
|
||||
{description}
|
||||
</p>
|
||||
</p> */}
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Formatos:{' '}
|
||||
{acceptedTypes
|
||||
@@ -156,46 +231,63 @@ export function FileDropzone({
|
||||
.toUpperCase()
|
||||
.replace(/,/g, ', ')}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex items-center justify-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-primary text-xl font-bold',
|
||||
files.length >= maxFiles ? 'text-destructive' : '',
|
||||
)}
|
||||
>
|
||||
{files.length}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium transition-colors',
|
||||
files.length >= maxFiles
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground/80',
|
||||
)}
|
||||
>
|
||||
/ {maxFiles} archivos (máximo)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Uploaded files list */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-accent/50 border-border fade-in flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
{getFileIcon(item.file.type)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{item.file.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatFileSize(item.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||||
onClick={() => removeFile(item.id)}
|
||||
{/* Lista de archivos subidos (Orden inverso: más recientes primero) */}
|
||||
<div className="h-56 overflow-y-auto">
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{[...files].reverse().map((uploadedFile) => (
|
||||
<div
|
||||
key={uploadedFile.id}
|
||||
className="bg-accent/50 border-border fade-in flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{getFileIcon(uploadedFile.file.type)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{uploadedFile.file.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatFileSize(uploadedFile.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||||
onClick={() => removeFile(uploadedFile.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,8 @@ import ReferenciasParaIA from './ReferenciasParaIA'
|
||||
import type { UploadedFile } from './FileDropZone'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
@@ -46,18 +44,18 @@ export function PasoDetallesPanel({
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="desc">Descripción del enfoque</Label>
|
||||
<Label htmlFor="desc">Descripción del enfoque académico</Label>
|
||||
<textarea
|
||||
id="desc"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
placeholder="Describe el enfoque del programa…"
|
||||
value={wizard.iaConfig?.descripcionEnfoque || ''}
|
||||
value={wizard.iaConfig?.descripcionEnfoqueAcademico || ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
descripcionEnfoque: e.target.value,
|
||||
descripcionEnfoqueAcademico: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -66,7 +64,7 @@ export function PasoDetallesPanel({
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="notas">
|
||||
Notas adicionales
|
||||
Instrucciones adicionales para la IA
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
(Opcional)
|
||||
</span>
|
||||
@@ -75,13 +73,13 @@ export function PasoDetallesPanel({
|
||||
id="notas"
|
||||
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring min-h-24 w-full rounded-md border px-3 py-2 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
placeholder="Lineamientos institucionales, restricciones, etc."
|
||||
value={wizard.iaConfig?.notasAdicionales || ''}
|
||||
value={wizard.iaConfig?.instruccionesAdicionalesIA || ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
onChange((w) => ({
|
||||
...w,
|
||||
iaConfig: {
|
||||
...(w.iaConfig || ({} as any)),
|
||||
notasAdicionales: e.target.value,
|
||||
InstruccionesAdicionalesIA: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -133,38 +131,6 @@ export function PasoDetallesPanel({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Opcional: se pueden adjuntar recursos IA más adelante.
|
||||
</div>
|
||||
<Button disabled={isLoading}>
|
||||
{isLoading ? 'Generando…' : 'Generar borrador con IA'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{wizard.resumen.previewPlan && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preview IA</CardTitle>
|
||||
<CardDescription>
|
||||
Asignaturas aprox.:{' '}
|
||||
{wizard.resumen.previewPlan.numAsignaturasAprox}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="text-muted-foreground list-disc pl-5 text-sm">
|
||||
{wizard.resumen.previewPlan.secciones?.map((s) => (
|
||||
<li key={s.id}>
|
||||
<span className="text-foreground font-medium">
|
||||
{s.titulo}:
|
||||
</span>{' '}
|
||||
{s.resumen}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ const ReferenciasParaIA = ({
|
||||
placeholder="Buscar archivo existente..."
|
||||
className="m-1 mb-1.5"
|
||||
/>
|
||||
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
|
||||
<div className="flex h-96 flex-col gap-0.5 overflow-y-auto">
|
||||
{archivosFiltrados.map((archivo) => (
|
||||
<Label
|
||||
key={archivo.id}
|
||||
@@ -123,7 +123,7 @@ const ReferenciasParaIA = ({
|
||||
placeholder="Buscar repositorio..."
|
||||
className="m-1 mb-1.5"
|
||||
/>
|
||||
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
|
||||
<div className="flex h-96 flex-col gap-0.5 overflow-y-auto">
|
||||
{repositoriosFiltrados.map((repositorio) => (
|
||||
<Label
|
||||
key={repositorio.id}
|
||||
@@ -163,12 +163,13 @@ const ReferenciasParaIA = ({
|
||||
icon: Upload,
|
||||
|
||||
content: (
|
||||
<div>
|
||||
<div className="p-1">
|
||||
<FileDropzone
|
||||
persistentFiles={uploadedFiles}
|
||||
onFilesChange={onFilesChange}
|
||||
title="Sube archivos de referencia"
|
||||
description="Documentos que serán usados como contexto para la generación"
|
||||
autoScrollToDropzone={true}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
@@ -177,7 +178,12 @@ const ReferenciasParaIA = ({
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<Label>Referencias para la IA</Label>
|
||||
<Label>
|
||||
Referencias para la IA{' '}
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
(Opcional)
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<Tabs defaultValue="archivos-existentes" className="gap-4">
|
||||
<TabsList className="w-full">
|
||||
|
||||
@@ -116,13 +116,13 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
|
||||
<div>
|
||||
<span className="text-muted-foreground">Enfoque: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.iaConfig?.descripcionEnfoque || '—'}
|
||||
{wizard.iaConfig?.descripcionEnfoqueAcademico || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Notas: </span>
|
||||
<span className="font-medium">
|
||||
{wizard.iaConfig?.notasAdicionales || '—'}
|
||||
{wizard.iaConfig?.instruccionesAdicionalesIA || '—'}
|
||||
</span>
|
||||
</div>
|
||||
{archivosRef.length > 0 && (
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
// import type { Database } from '@/types/supabase'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useGeneratePlanAI } from '@/data/hooks/usePlans'
|
||||
// import { supabaseBrowser } from '@/data'
|
||||
import { useCreatePlanManual, useGeneratePlanAI } from '@/data/hooks/usePlans'
|
||||
|
||||
export function WizardControls({
|
||||
errorMessage,
|
||||
@@ -28,6 +31,8 @@ export function WizardControls({
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const generatePlanAI = useGeneratePlanAI()
|
||||
const createPlanManual = useCreatePlanManual()
|
||||
// const supabaseClient = supabaseBrowser()
|
||||
// const persistPlanFromAI = usePersistPlanFromAI()
|
||||
|
||||
const handleCreate = async () => {
|
||||
@@ -60,8 +65,10 @@ export function WizardControls({
|
||||
estructuraPlanId: wizard.datosBasicos.estructuraPlanId as string,
|
||||
},
|
||||
iaConfig: {
|
||||
descripcionEnfoque: wizard.iaConfig?.descripcionEnfoque || '',
|
||||
notasAdicionales: wizard.iaConfig?.notasAdicionales || '',
|
||||
descripcionEnfoqueAcademico:
|
||||
wizard.iaConfig?.descripcionEnfoqueAcademico || '',
|
||||
instruccionesAdicionalesIA:
|
||||
wizard.iaConfig?.instruccionesAdicionalesIA || '',
|
||||
archivosReferencia: wizard.iaConfig?.archivosReferencia || [],
|
||||
repositoriosIds: wizard.iaConfig?.repositoriosReferencia || [],
|
||||
archivosAdjuntos: wizard.iaConfig?.archivosAdjuntos || [],
|
||||
@@ -73,22 +80,32 @@ export function WizardControls({
|
||||
const data = await generatePlanAI.mutateAsync(aiInput as any)
|
||||
console.log(`${new Date().toISOString()} - Plan IA generado`, data)
|
||||
|
||||
navigate({ to: `/planes/${data.plan.id}` })
|
||||
navigate({
|
||||
to: `/planes/${data.plan.id}/datos`,
|
||||
state: { showConfetti: true },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback mocks for non-IA origins
|
||||
await new Promise((r) => setTimeout(r, 900))
|
||||
const nuevoId = (() => {
|
||||
if (wizard.tipoOrigen === 'MANUAL') return 'plan_new_manual_001'
|
||||
if (
|
||||
wizard.tipoOrigen === 'CLONADO_INTERNO' ||
|
||||
wizard.tipoOrigen === 'CLONADO_TRADICIONAL'
|
||||
)
|
||||
return 'plan_new_clone_001'
|
||||
return 'plan_new_import_001'
|
||||
})()
|
||||
navigate({ to: `/planes/${nuevoId}` })
|
||||
if (wizard.tipoOrigen === 'MANUAL') {
|
||||
// Crear plan vacío manualmente usando el hook
|
||||
const plan = await createPlanManual.mutateAsync({
|
||||
carreraId: wizard.datosBasicos.carreraId,
|
||||
estructuraId: wizard.datosBasicos.estructuraPlanId as string,
|
||||
nombre: wizard.datosBasicos.nombrePlan,
|
||||
nivel: wizard.datosBasicos.nivel as NivelPlanEstudio,
|
||||
tipoCiclo: wizard.datosBasicos.tipoCiclo as TipoCiclo,
|
||||
numCiclos: (wizard.datosBasicos.numCiclos as number) || 1,
|
||||
datos: {},
|
||||
})
|
||||
|
||||
// Navegar al nuevo plan
|
||||
navigate({
|
||||
to: `/planes/${plan.id}/datos`,
|
||||
state: { showConfetti: true },
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
|
||||
48
src/components/ui/lateral-confetti.tsx
Normal file
48
src/components/ui/lateral-confetti.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// src/components/ui/lateral-confetti.tsx
|
||||
|
||||
import confetti from 'canvas-confetti'
|
||||
|
||||
export function lateralConfetti() {
|
||||
// 1. Reset para limpiar cualquier configuración vieja pegada en memoria
|
||||
confetti.reset()
|
||||
|
||||
const duration = 1500
|
||||
const end = Date.now() + duration
|
||||
|
||||
// 2. Colores vibrantes (cálidos primero)
|
||||
const vibrantColors = [
|
||||
'#FF0000', // Rojo puro
|
||||
'#fcff42', // Amarillo
|
||||
'#88ff5a', // Verde
|
||||
'#26ccff', // Azul
|
||||
'#a25afd', // Morado
|
||||
]
|
||||
|
||||
;(function frame() {
|
||||
const commonSettings = {
|
||||
particleCount: 5,
|
||||
spread: 55,
|
||||
// origin: { x: 0.5 }, // No necesario si definimos origin abajo, pero útil en otros contextos
|
||||
colors: vibrantColors,
|
||||
zIndex: 99999,
|
||||
}
|
||||
|
||||
// Cañón izquierdo
|
||||
confetti({
|
||||
...commonSettings,
|
||||
angle: 60,
|
||||
origin: { x: 0, y: 0.6 },
|
||||
})
|
||||
|
||||
// Cañón derecho
|
||||
confetti({
|
||||
...commonSettings,
|
||||
angle: 120,
|
||||
origin: { x: 1, y: 0.6 },
|
||||
})
|
||||
|
||||
if (Date.now() < end) {
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
})()
|
||||
}
|
||||
Reference in New Issue
Block a user