Se generan planes de estudio y asignaturas con IA en segundo plano y se actualiza con realtime de supabase #146
@@ -1,6 +1,7 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import type { AIGeneratePlanInput } from '@/data'
|
import type { AIGeneratePlanInput } from '@/data'
|
||||||
import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain'
|
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 type { Database } from '@/types/supabase'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
// import { supabaseBrowser } from '@/data'
|
import { plans_get_maybe } from '@/data/api/plans.api'
|
||||||
import { useCreatePlanManual, useGeneratePlanAI } from '@/data/hooks/usePlans'
|
import {
|
||||||
|
useCreatePlanManual,
|
||||||
|
useDeletePlanEstudio,
|
||||||
|
useGeneratePlanAI,
|
||||||
|
} from '@/data/hooks/usePlans'
|
||||||
|
import { qk } from '@/data/query/keys'
|
||||||
|
|
||||||
export function WizardControls({
|
export function WizardControls({
|
||||||
errorMessage,
|
errorMessage,
|
||||||
@@ -35,10 +41,88 @@ export function WizardControls({
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const generatePlanAI = useGeneratePlanAI()
|
const generatePlanAI = useGeneratePlanAI()
|
||||||
const createPlanManual = useCreatePlanManual()
|
const createPlanManual = useCreatePlanManual()
|
||||||
|
const deletePlan = useDeletePlanEstudio()
|
||||||
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
||||||
|
const [pollPlanId, setPollPlanId] = useState<string | null>(null)
|
||||||
|
const cancelledRef = useRef(false)
|
||||||
// const supabaseClient = supabaseBrowser()
|
// const supabaseClient = supabaseBrowser()
|
||||||
// const persistPlanFromAI = usePersistPlanFromAI()
|
// 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 () => {
|
const handleCreate = async () => {
|
||||||
// Start loading
|
// Start loading
|
||||||
setWizard(
|
setWizard(
|
||||||
@@ -82,14 +166,16 @@ export function WizardControls({
|
|||||||
console.log(`${new Date().toISOString()} - Enviando a generar plan IA`)
|
console.log(`${new Date().toISOString()} - Enviando a generar plan IA`)
|
||||||
|
|
||||||
setIsSpinningIA(true)
|
setIsSpinningIA(true)
|
||||||
const plan = await generatePlanAI.mutateAsync(aiInput as any)
|
const resp: any = await generatePlanAI.mutateAsync(aiInput as any)
|
||||||
setIsSpinningIA(false)
|
const planId = resp?.plan?.id ?? resp?.id
|
||||||
console.log(`${new Date().toISOString()} - Plan IA generado`, plan)
|
console.log(`${new Date().toISOString()} - Plan IA generado`, resp)
|
||||||
|
|
||||||
navigate({
|
if (!planId) {
|
||||||
to: `/planes/${plan.id}`,
|
throw new Error('No se pudo obtener el id del plan generado por IA')
|
||||||
state: { showConfetti: true },
|
}
|
||||||
})
|
|
||||||
|
// Inicia polling con React Query; el efecto navega o marca error.
|
||||||
|
setPollPlanId(String(planId))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,16 +200,20 @@ export function WizardControls({
|
|||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setIsSpinningIA(false)
|
setIsSpinningIA(false)
|
||||||
|
setPollPlanId(null)
|
||||||
setWizard((w) => ({
|
setWizard((w) => ({
|
||||||
...w,
|
...w,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
errorMessage: err?.message ?? 'Error generando el plan',
|
errorMessage: err?.message ?? 'Error generando el plan',
|
||||||
}))
|
}))
|
||||||
} finally {
|
} finally {
|
||||||
|
// Si entramos en polling, el loading se corta desde el efecto terminal.
|
||||||
|
if (!pollPlanId) {
|
||||||
setIsSpinningIA(false)
|
setIsSpinningIA(false)
|
||||||
setWizard((w) => ({ ...w, isLoading: false }))
|
setWizard((w) => ({ ...w, isLoading: false }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex grow items-center justify-between">
|
<div className="flex grow items-center justify-between">
|
||||||
|
|||||||
@@ -144,6 +144,48 @@ export async function plans_get(planId: UUID): Promise<PlanEstudio> {
|
|||||||
return requireData(data, 'Plan no encontrado.')
|
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(
|
export async function plan_lineas_list(
|
||||||
planId: UUID,
|
planId: UUID,
|
||||||
): Promise<Array<LineaPlan>> {
|
): Promise<Array<LineaPlan>> {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
plan_lineas_list,
|
plan_lineas_list,
|
||||||
plans_clone_from_existing,
|
plans_clone_from_existing,
|
||||||
plans_create_manual,
|
plans_create_manual,
|
||||||
|
plans_delete,
|
||||||
plans_generate_document,
|
plans_generate_document,
|
||||||
plans_get,
|
plans_get,
|
||||||
plans_get_document,
|
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() {
|
export function useGeneratePlanDocumento() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const qk = {
|
|||||||
|
|
||||||
planesList: (filters: unknown) => ['planes', 'list', filters] as const,
|
planesList: (filters: unknown) => ['planes', 'list', filters] as const,
|
||||||
plan: (planId: string) => ['planes', 'detail', planId] 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,
|
planLineas: (planId: string) => ['planes', planId, 'lineas'] as const,
|
||||||
planAsignaturas: (planId: string) =>
|
planAsignaturas: (planId: string) =>
|
||||||
['planes', planId, 'asignaturas'] as const,
|
['planes', planId, 'asignaturas'] as const,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user