478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
import { useQueryClient } from '@tanstack/react-query'
|
|
import { useNavigate } from '@tanstack/react-router'
|
|
import { Loader2 } from 'lucide-react'
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
import type { AISubjectUnifiedInput } from '@/data'
|
|
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
|
import type { TablesInsert } from '@/types/supabase'
|
|
import type { RealtimeChannel } from '@supabase/supabase-js'
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
supabaseBrowser,
|
|
useGenerateSubjectAI,
|
|
qk,
|
|
useCreateSubjectManual,
|
|
subjects_get_maybe,
|
|
} from '@/data'
|
|
|
|
export function WizardControls({
|
|
wizard,
|
|
setWizard,
|
|
errorMessage,
|
|
onPrev,
|
|
onNext,
|
|
disablePrev,
|
|
disableNext,
|
|
disableCreate,
|
|
isLastStep,
|
|
}: {
|
|
wizard: NewSubjectWizardState
|
|
setWizard: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
|
|
errorMessage?: string | null
|
|
onPrev: () => void
|
|
onNext: () => void
|
|
disablePrev: boolean
|
|
disableNext: boolean
|
|
disableCreate: boolean
|
|
isLastStep: boolean
|
|
}) {
|
|
const navigate = useNavigate()
|
|
const qc = useQueryClient()
|
|
const generateSubjectAI = useGenerateSubjectAI()
|
|
const createSubjectManual = useCreateSubjectManual()
|
|
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
|
const cancelledRef = useRef(false)
|
|
const realtimeChannelRef = useRef<RealtimeChannel | null>(null)
|
|
const watchSubjectIdRef = useRef<string | null>(null)
|
|
const watchTimeoutRef = useRef<number | null>(null)
|
|
|
|
useEffect(() => {
|
|
cancelledRef.current = false
|
|
return () => {
|
|
cancelledRef.current = true
|
|
}
|
|
}, [])
|
|
|
|
const stopSubjectWatch = useCallback(() => {
|
|
if (watchTimeoutRef.current) {
|
|
window.clearTimeout(watchTimeoutRef.current)
|
|
watchTimeoutRef.current = null
|
|
}
|
|
|
|
watchSubjectIdRef.current = null
|
|
|
|
const ch = realtimeChannelRef.current
|
|
if (ch) {
|
|
realtimeChannelRef.current = null
|
|
try {
|
|
supabaseBrowser().removeChannel(ch)
|
|
} catch {
|
|
// noop
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
stopSubjectWatch()
|
|
}
|
|
}, [stopSubjectWatch])
|
|
|
|
const handleSubjectReady = (args: {
|
|
id: string
|
|
plan_estudio_id: string
|
|
estado?: unknown
|
|
}) => {
|
|
if (cancelledRef.current) return
|
|
|
|
const estado = String(args.estado ?? '').toLowerCase()
|
|
if (estado === 'generando') return
|
|
|
|
stopSubjectWatch()
|
|
setIsSpinningIA(false)
|
|
setWizard((w) => ({ ...w, isLoading: false }))
|
|
|
|
navigate({
|
|
to: `/planes/${args.plan_estudio_id}/asignaturas/${args.id}`,
|
|
state: { showConfetti: true },
|
|
})
|
|
}
|
|
|
|
const beginSubjectWatch = (args: { subjectId: string; planId: string }) => {
|
|
stopSubjectWatch()
|
|
|
|
watchSubjectIdRef.current = args.subjectId
|
|
|
|
// Timeout de seguridad (mismo límite que teníamos con polling)
|
|
watchTimeoutRef.current = window.setTimeout(
|
|
() => {
|
|
if (cancelledRef.current) return
|
|
if (watchSubjectIdRef.current !== args.subjectId) return
|
|
|
|
stopSubjectWatch()
|
|
setIsSpinningIA(false)
|
|
setWizard((w) => ({
|
|
...w,
|
|
isLoading: false,
|
|
errorMessage:
|
|
'La generación está tardando demasiado. Intenta de nuevo en unos minutos.',
|
|
}))
|
|
},
|
|
6 * 60 * 1000,
|
|
)
|
|
|
|
const supabase = supabaseBrowser()
|
|
const channel = supabase.channel(`asignaturas-status-${args.subjectId}`)
|
|
realtimeChannelRef.current = channel
|
|
|
|
channel.on(
|
|
'postgres_changes',
|
|
{
|
|
event: 'UPDATE',
|
|
schema: 'public',
|
|
table: 'asignaturas',
|
|
filter: `id=eq.${args.subjectId}`,
|
|
},
|
|
(payload) => {
|
|
if (cancelledRef.current) return
|
|
|
|
const next: any = (payload as any)?.new
|
|
if (!next?.id || !next?.plan_estudio_id) return
|
|
handleSubjectReady({
|
|
id: String(next.id),
|
|
plan_estudio_id: String(next.plan_estudio_id),
|
|
estado: next.estado,
|
|
})
|
|
},
|
|
)
|
|
|
|
channel.subscribe((status) => {
|
|
if (cancelledRef.current) return
|
|
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
|
|
stopSubjectWatch()
|
|
setIsSpinningIA(false)
|
|
setWizard((w) => ({
|
|
...w,
|
|
isLoading: false,
|
|
errorMessage:
|
|
'No se pudo suscribir al estado de la asignatura. Intenta de nuevo.',
|
|
}))
|
|
}
|
|
})
|
|
}
|
|
|
|
const uploadAiAttachments = async (args: {
|
|
planId: string
|
|
files: Array<{ file: File }>
|
|
}): Promise<Array<string>> => {
|
|
const supabase = supabaseBrowser()
|
|
if (!args.files.length) return []
|
|
|
|
const runId = crypto.randomUUID()
|
|
const basePath = `planes/${args.planId}/asignaturas/ai/${runId}`
|
|
|
|
const keys: Array<string> = []
|
|
for (const f of args.files) {
|
|
const safeName = (f.file.name || 'archivo').replace(/[\\/]+/g, '_')
|
|
const key = `${basePath}/${crypto.randomUUID()}-${safeName}`
|
|
|
|
const { error } = await supabase.storage
|
|
.from('ai-storage')
|
|
.upload(key, f.file, {
|
|
contentType: f.file.type || undefined,
|
|
})
|
|
|
|
if (error) throw new Error(error.message)
|
|
keys.push(key)
|
|
}
|
|
|
|
return keys
|
|
}
|
|
|
|
const handleCreate = async () => {
|
|
setWizard((w) => ({
|
|
...w,
|
|
isLoading: true,
|
|
errorMessage: null,
|
|
}))
|
|
|
|
let startedWaiting = false
|
|
|
|
try {
|
|
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
|
if (!wizard.plan_estudio_id) {
|
|
throw new Error('Plan de estudio inválido.')
|
|
}
|
|
if (!wizard.datosBasicos.estructuraId) {
|
|
throw new Error('Estructura inválida.')
|
|
}
|
|
if (!wizard.datosBasicos.nombre.trim()) {
|
|
throw new Error('Nombre inválido.')
|
|
}
|
|
if (wizard.datosBasicos.creditos == null) {
|
|
throw new Error('Créditos inválidos.')
|
|
}
|
|
|
|
console.log(`${new Date().toISOString()} - Insertando asignatura IA`)
|
|
|
|
const supabase = supabaseBrowser()
|
|
const placeholder: TablesInsert<'asignaturas'> = {
|
|
plan_estudio_id: wizard.plan_estudio_id,
|
|
estructura_id: wizard.datosBasicos.estructuraId,
|
|
nombre: wizard.datosBasicos.nombre,
|
|
codigo: wizard.datosBasicos.codigo ?? null,
|
|
tipo: wizard.datosBasicos.tipo ?? undefined,
|
|
creditos: wizard.datosBasicos.creditos,
|
|
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
|
|
horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
|
|
estado: 'generando',
|
|
tipo_origen: 'IA',
|
|
}
|
|
|
|
const { data: inserted, error: insertError } = await supabase
|
|
.from('asignaturas')
|
|
.insert(placeholder)
|
|
.select('id,plan_estudio_id')
|
|
.single()
|
|
|
|
if (insertError) throw new Error(insertError.message)
|
|
const subjectId = inserted.id
|
|
|
|
setIsSpinningIA(true)
|
|
|
|
// Inicia watch realtime antes de disparar la Edge para no perder updates.
|
|
startedWaiting = true
|
|
beginSubjectWatch({ subjectId, planId: wizard.plan_estudio_id })
|
|
|
|
const archivosAdjuntos = await uploadAiAttachments({
|
|
planId: wizard.plan_estudio_id,
|
|
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
|
|
file: x.file,
|
|
})),
|
|
})
|
|
|
|
const payload: AISubjectUnifiedInput = {
|
|
datosUpdate: {
|
|
id: subjectId,
|
|
plan_estudio_id: wizard.plan_estudio_id,
|
|
estructura_id: wizard.datosBasicos.estructuraId,
|
|
nombre: wizard.datosBasicos.nombre,
|
|
codigo: wizard.datosBasicos.codigo ?? null,
|
|
tipo: wizard.datosBasicos.tipo ?? null,
|
|
creditos: wizard.datosBasicos.creditos,
|
|
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
|
|
horas_independientes:
|
|
wizard.datosBasicos.horasIndependientes ?? null,
|
|
},
|
|
iaConfig: {
|
|
descripcionEnfoqueAcademico:
|
|
wizard.iaConfig?.descripcionEnfoqueAcademico ?? undefined,
|
|
instruccionesAdicionalesIA:
|
|
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
|
|
archivosAdjuntos,
|
|
},
|
|
}
|
|
|
|
console.log(
|
|
`${new Date().toISOString()} - Disparando Edge IA asignatura (unified)`,
|
|
)
|
|
|
|
await generateSubjectAI.mutateAsync(payload as any)
|
|
|
|
// Fallback: una lectura puntual por si el UPDATE llegó antes de suscribir.
|
|
const latest = await subjects_get_maybe(subjectId)
|
|
if (latest) {
|
|
handleSubjectReady({
|
|
id: latest.id as any,
|
|
plan_estudio_id: latest.plan_estudio_id as any,
|
|
estado: (latest as any).estado,
|
|
})
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if (wizard.tipoOrigen === 'IA_MULTIPLE') {
|
|
const selected = wizard.sugerencias.filter((s) => s.selected)
|
|
|
|
if (selected.length === 0) {
|
|
throw new Error('Selecciona al menos una sugerencia.')
|
|
}
|
|
if (!wizard.plan_estudio_id) {
|
|
throw new Error('Plan de estudio inválido.')
|
|
}
|
|
if (!wizard.estructuraId) {
|
|
throw new Error('Selecciona una estructura para continuar.')
|
|
}
|
|
|
|
const supabase = supabaseBrowser()
|
|
|
|
setIsSpinningIA(true)
|
|
|
|
const archivosAdjuntos = await uploadAiAttachments({
|
|
planId: wizard.plan_estudio_id,
|
|
files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({
|
|
file: x.file,
|
|
})),
|
|
})
|
|
|
|
const placeholders: Array<TablesInsert<'asignaturas'>> = selected.map(
|
|
(s): TablesInsert<'asignaturas'> => ({
|
|
plan_estudio_id: wizard.plan_estudio_id,
|
|
estructura_id: wizard.estructuraId,
|
|
estado: 'generando',
|
|
nombre: s.nombre,
|
|
codigo: s.codigo ?? null,
|
|
tipo: s.tipo ?? undefined,
|
|
creditos: s.creditos ?? 0,
|
|
horas_academicas: s.horasAcademicas ?? null,
|
|
horas_independientes: s.horasIndependientes ?? null,
|
|
linea_plan_id: s.linea_plan_id ?? null,
|
|
numero_ciclo: s.numero_ciclo ?? null,
|
|
}),
|
|
)
|
|
|
|
const { data: inserted, error: insertError } = await supabase
|
|
.from('asignaturas')
|
|
.insert(placeholders)
|
|
.select('id')
|
|
|
|
if (insertError) {
|
|
throw new Error(insertError.message)
|
|
}
|
|
|
|
const insertedIds = inserted.map((r) => r.id)
|
|
if (insertedIds.length !== selected.length) {
|
|
throw new Error('No se pudieron crear todas las asignaturas.')
|
|
}
|
|
|
|
// Disparar generación en paralelo (no bloquear navegación)
|
|
insertedIds.forEach((id, idx) => {
|
|
const s = selected[idx]
|
|
const creditosForEdge =
|
|
typeof s.creditos === 'number' && s.creditos > 0
|
|
? s.creditos
|
|
: undefined
|
|
const payload: AISubjectUnifiedInput = {
|
|
datosUpdate: {
|
|
id,
|
|
plan_estudio_id: wizard.plan_estudio_id,
|
|
estructura_id: wizard.estructuraId ?? undefined,
|
|
nombre: s.nombre,
|
|
codigo: s.codigo ?? null,
|
|
tipo: s.tipo ?? null,
|
|
creditos: creditosForEdge,
|
|
horas_academicas: s.horasAcademicas ?? null,
|
|
horas_independientes: s.horasIndependientes ?? null,
|
|
numero_ciclo: s.numero_ciclo ?? null,
|
|
linea_plan_id: s.linea_plan_id ?? null,
|
|
},
|
|
iaConfig: {
|
|
descripcionEnfoqueAcademico: s.descripcion,
|
|
instruccionesAdicionalesIA:
|
|
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
|
|
archivosAdjuntos,
|
|
},
|
|
}
|
|
|
|
void generateSubjectAI.mutateAsync(payload as any).catch((e) => {
|
|
console.error('Error generando asignatura IA (multiple):', e)
|
|
})
|
|
})
|
|
|
|
// Invalidar la query del listado del plan (una vez) para que la lista
|
|
// muestre el estado actualizado y recargue cuando lleguen updates.
|
|
qc.invalidateQueries({
|
|
queryKey: qk.planAsignaturas(wizard.plan_estudio_id),
|
|
})
|
|
|
|
navigate({
|
|
to: `/planes/${wizard.plan_estudio_id}/asignaturas`,
|
|
resetScroll: false,
|
|
})
|
|
|
|
setIsSpinningIA(false)
|
|
|
|
return
|
|
}
|
|
|
|
if (wizard.tipoOrigen === 'MANUAL') {
|
|
if (!wizard.plan_estudio_id) {
|
|
throw new Error('Plan de estudio inválido.')
|
|
}
|
|
|
|
const asignatura = await createSubjectManual.mutateAsync({
|
|
plan_estudio_id: wizard.plan_estudio_id,
|
|
estructura_id: wizard.datosBasicos.estructuraId!,
|
|
nombre: wizard.datosBasicos.nombre,
|
|
codigo: wizard.datosBasicos.codigo ?? null,
|
|
tipo: wizard.datosBasicos.tipo ?? undefined,
|
|
creditos: wizard.datosBasicos.creditos ?? 0,
|
|
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
|
|
horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
|
|
linea_plan_id: null,
|
|
numero_ciclo: null,
|
|
})
|
|
|
|
navigate({
|
|
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
|
|
state: { showConfetti: true },
|
|
resetScroll: false,
|
|
})
|
|
}
|
|
} catch (err: any) {
|
|
setIsSpinningIA(false)
|
|
stopSubjectWatch()
|
|
setWizard((w) => ({
|
|
...w,
|
|
isLoading: false,
|
|
errorMessage: err?.message ?? 'Error creando la asignatura',
|
|
}))
|
|
} finally {
|
|
if (!startedWaiting) {
|
|
setIsSpinningIA(false)
|
|
setWizard((w) => ({ ...w, isLoading: false }))
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex grow items-center justify-between">
|
|
<Button variant="secondary" onClick={onPrev} disabled={disablePrev}>
|
|
Anterior
|
|
</Button>
|
|
|
|
<div className="mx-2 flex-1">
|
|
{(errorMessage ?? wizard.errorMessage) && (
|
|
<span className="text-destructive text-sm font-medium">
|
|
{errorMessage ?? wizard.errorMessage}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mx-2 flex w-5 items-center justify-center">
|
|
<Loader2
|
|
className={
|
|
wizard.tipoOrigen === 'IA_SIMPLE' && isSpinningIA
|
|
? 'text-muted-foreground h-6 w-6 animate-spin'
|
|
: 'h-6 w-6 opacity-0'
|
|
}
|
|
aria-hidden={!(wizard.tipoOrigen === 'IA_SIMPLE' && isSpinningIA)}
|
|
/>
|
|
</div>
|
|
|
|
{isLastStep ? (
|
|
<Button onClick={handleCreate} disabled={disableCreate}>
|
|
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}
|
|
</Button>
|
|
) : (
|
|
<Button onClick={onNext} disabled={disableNext}>
|
|
Siguiente
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|