Creación de planes de estudio con polling debido a mandar crear los datos en segundo plano

This commit is contained in:
2026-02-25 17:37:06 -06:00
parent f28804bb5b
commit 4d1f102acb
5 changed files with 534 additions and 373 deletions

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { AIGeneratePlanInput } from '@/data'
import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain'
@@ -8,8 +9,13 @@ import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
// import type { Database } from '@/types/supabase'
import { Button } from '@/components/ui/button'
// import { supabaseBrowser } from '@/data'
import { useCreatePlanManual, useGeneratePlanAI } from '@/data/hooks/usePlans'
import { plans_get_maybe } from '@/data/api/plans.api'
import {
useCreatePlanManual,
useDeletePlanEstudio,
useGeneratePlanAI,
} from '@/data/hooks/usePlans'
import { qk } from '@/data/query/keys'
export function WizardControls({
errorMessage,
@@ -35,10 +41,88 @@ export function WizardControls({
const navigate = useNavigate()
const generatePlanAI = useGeneratePlanAI()
const createPlanManual = useCreatePlanManual()
const deletePlan = useDeletePlanEstudio()
const [isSpinningIA, setIsSpinningIA] = useState(false)
const [pollPlanId, setPollPlanId] = useState<string | null>(null)
const cancelledRef = useRef(false)
// const supabaseClient = supabaseBrowser()
// const persistPlanFromAI = usePersistPlanFromAI()
useEffect(() => {
cancelledRef.current = false
return () => {
cancelledRef.current = true
}
}, [])
const planQuery = useQuery({
queryKey: pollPlanId
? qk.planMaybe(pollPlanId)
: ['planes', 'detail-maybe', null],
queryFn: () => plans_get_maybe(pollPlanId as string),
enabled: Boolean(pollPlanId),
refetchInterval: pollPlanId ? 3000 : false,
refetchIntervalInBackground: true,
staleTime: 0,
})
useEffect(() => {
if (!pollPlanId) return
if (cancelledRef.current) return
// Si aún no existe en BDD, seguimos esperando.
const plan = planQuery.data
if (!plan) return
const clave = String(plan.estados_plan?.clave ?? '').toUpperCase()
if (clave.startsWith('GENERANDO')) return
if (clave.startsWith('BORRADOR')) {
setPollPlanId(null)
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false }))
navigate({
to: `/planes/${plan.id}`,
state: { showConfetti: true },
})
return
}
if (clave.startsWith('FALLID')) {
// Detenemos el polling primero para evitar loops.
setPollPlanId(null)
setIsSpinningIA(false)
deletePlan
.mutateAsync(plan.id)
.catch(() => {
// Si falla el borrado, igual mostramos el error.
})
.finally(() => {
setWizard((w) => ({
...w,
isLoading: false,
errorMessage: 'La generación del plan falló',
}))
})
}
}, [pollPlanId, planQuery.data, navigate, setWizard, deletePlan])
useEffect(() => {
if (!pollPlanId) return
if (!planQuery.isError) return
setPollPlanId(null)
setIsSpinningIA(false)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage:
(planQuery.error as any)?.message ??
'Error consultando el estado del plan',
}))
}, [pollPlanId, planQuery.isError, planQuery.error, setWizard])
const handleCreate = async () => {
// Start loading
setWizard(
@@ -82,14 +166,16 @@ export function WizardControls({
console.log(`${new Date().toISOString()} - Enviando a generar plan IA`)
setIsSpinningIA(true)
const plan = await generatePlanAI.mutateAsync(aiInput as any)
setIsSpinningIA(false)
console.log(`${new Date().toISOString()} - Plan IA generado`, plan)
const resp: any = await generatePlanAI.mutateAsync(aiInput as any)
const planId = resp?.plan?.id ?? resp?.id
console.log(`${new Date().toISOString()} - Plan IA generado`, resp)
navigate({
to: `/planes/${plan.id}`,
state: { showConfetti: true },
})
if (!planId) {
throw new Error('No se pudo obtener el id del plan generado por IA')
}
// Inicia polling con React Query; el efecto navega o marca error.
setPollPlanId(String(planId))
return
}
@@ -114,16 +200,20 @@ export function WizardControls({
}
} catch (err: any) {
setIsSpinningIA(false)
setPollPlanId(null)
setWizard((w) => ({
...w,
isLoading: false,
errorMessage: err?.message ?? 'Error generando el plan',
}))
} finally {
// Si entramos en polling, el loading se corta desde el efecto terminal.
if (!pollPlanId) {
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false }))
}
}
}
return (
<div className="flex grow items-center justify-between">

View File

@@ -144,6 +144,48 @@ export async function plans_get(planId: UUID): Promise<PlanEstudio> {
return requireData(data, 'Plan no encontrado.')
}
/**
* Variante de `plans_get` que NO lanza si no existe (devuelve null).
* Útil para flujos de polling donde el plan puede tardar en aparecer.
*/
export async function plans_get_maybe(
planId: UUID,
): Promise<PlanEstudio | null> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('planes_estudio')
.select(
`
*,
carreras (*, facultades(*)),
estructuras_plan (*),
estados_plan (*)
`,
)
.eq('id', planId)
.maybeSingle()
throwIfError(error)
return (data ?? null) as unknown as PlanEstudio | null
}
export async function plans_delete(planId: UUID): Promise<{ id: UUID }> {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('planes_estudio')
.delete()
.eq('id', planId)
.select('id')
.maybeSingle()
throwIfError(error)
// Si por alguna razón no retorna fila (RLS / triggers), devolvemos el id solicitado.
return { id: ((data as any)?.id ?? planId) as UUID }
}
export async function plan_lineas_list(
planId: UUID,
): Promise<Array<LineaPlan>> {

View File

@@ -12,6 +12,7 @@ import {
plan_lineas_list,
plans_clone_from_existing,
plans_create_manual,
plans_delete,
plans_generate_document,
plans_get,
plans_get_document,
@@ -263,6 +264,23 @@ export function useTransitionPlanEstado() {
})
}
export function useDeletePlanEstudio() {
const qc = useQueryClient()
return useMutation({
mutationFn: (planId: UUID) => plans_delete(planId),
onSuccess: (_ok, planId) => {
qc.invalidateQueries({ queryKey: ['planes', 'list'] })
qc.removeQueries({ queryKey: qk.plan(planId) })
qc.removeQueries({ queryKey: qk.planMaybe(planId) })
qc.removeQueries({ queryKey: qk.planAsignaturas(planId) })
qc.removeQueries({ queryKey: qk.planLineas(planId) })
qc.removeQueries({ queryKey: qk.planHistorial(planId) })
qc.removeQueries({ queryKey: qk.planDocumento(planId) })
},
})
}
export function useGeneratePlanDocumento() {
const qc = useQueryClient()

View File

@@ -13,6 +13,7 @@ export const qk = {
planesList: (filters: unknown) => ['planes', 'list', filters] as const,
plan: (planId: string) => ['planes', 'detail', planId] as const,
planMaybe: (planId: string) => ['planes', 'detail-maybe', planId] as const,
planLineas: (planId: string) => ['planes', planId, 'lineas'] as const,
planAsignaturas: (planId: string) =>
['planes', planId, 'asignaturas'] as const,

File diff suppressed because it is too large Load Diff