Se generan planes de estudio y asignaturas con IA en segundo plano y se actualiza con realtime de supabase #146
@@ -26,7 +26,7 @@ export default function PasoSugerenciasForm({
|
||||
onChange: Dispatch<SetStateAction<NewSubjectWizardState>>
|
||||
}) {
|
||||
const enfoque = wizard.iaMultiple?.enfoque ?? ''
|
||||
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 10
|
||||
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 5
|
||||
const isLoading = wizard.iaMultiple?.isLoading ?? false
|
||||
|
||||
const [showConservacionTooltip, setShowConservacionTooltip] = useState(false)
|
||||
@@ -163,7 +163,7 @@ export default function PasoSugerenciasForm({
|
||||
Cantidad de sugerencias
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="Ej. 10"
|
||||
placeholder="Ej. 5"
|
||||
value={cantidadDeSugerencias}
|
||||
type="number"
|
||||
min={1}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { AIGenerateSubjectInput, AIGenerateSubjectJsonInput } from '@/data'
|
||||
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 {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
useGenerateSubjectAI,
|
||||
qk,
|
||||
useCreateSubjectManual,
|
||||
subjects_get_maybe,
|
||||
} from '@/data'
|
||||
|
||||
export function WizardControls({
|
||||
@@ -41,6 +43,154 @@ export function WizardControls({
|
||||
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,
|
||||
@@ -48,48 +198,99 @@ export function WizardControls({
|
||||
errorMessage: null,
|
||||
}))
|
||||
|
||||
let startedWaiting = false
|
||||
|
||||
try {
|
||||
if (wizard.tipoOrigen === 'IA_SIMPLE') {
|
||||
const aiInput: AIGenerateSubjectInput = {
|
||||
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,
|
||||
datosBasicos: {
|
||||
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,
|
||||
tipo: wizard.datosBasicos.tipo!,
|
||||
creditos: wizard.datosBasicos.creditos!,
|
||||
horasIndependientes: wizard.datosBasicos.horasIndependientes,
|
||||
horasAcademicas: wizard.datosBasicos.horasAcademicas,
|
||||
estructuraId: wizard.datosBasicos.estructuraId!,
|
||||
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,
|
||||
wizard.iaConfig?.descripcionEnfoqueAcademico ?? undefined,
|
||||
instruccionesAdicionalesIA:
|
||||
wizard.iaConfig!.instruccionesAdicionalesIA,
|
||||
archivosReferencia: wizard.iaConfig!.archivosReferencia,
|
||||
repositoriosReferencia:
|
||||
wizard.iaConfig!.repositoriosReferencia || [],
|
||||
archivosAdjuntos: wizard.iaConfig!.archivosAdjuntos || [],
|
||||
wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
|
||||
archivosAdjuntos,
|
||||
},
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${new Date().toISOString()} - Enviando a generar asignatura con IA`,
|
||||
`${new Date().toISOString()} - Disparando Edge IA asignatura (unified)`,
|
||||
)
|
||||
|
||||
setIsSpinningIA(true)
|
||||
const asignatura = await generateSubjectAI.mutateAsync(aiInput)
|
||||
// await new Promise((resolve) => setTimeout(resolve, 20000)) // debug
|
||||
setIsSpinningIA(false)
|
||||
// console.log(
|
||||
// `${new Date().toISOString()} - Asignatura IA generada`,
|
||||
// asignatura,
|
||||
// )
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
navigate({
|
||||
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
|
||||
state: { showConfetti: true },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -108,6 +309,15 @@ export function WizardControls({
|
||||
|
||||
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,
|
||||
@@ -141,16 +351,33 @@ export function WizardControls({
|
||||
// Disparar generación en paralelo (no bloquear navegación)
|
||||
insertedIds.forEach((id, idx) => {
|
||||
const s = selected[idx]
|
||||
const payload: AIGenerateSubjectJsonInput = {
|
||||
id,
|
||||
descripcionEnfoqueAcademico: s.descripcion,
|
||||
// (opcionales) parches directos si el edge los usa
|
||||
estructura_id: wizard.estructuraId,
|
||||
linea_plan_id: s.linea_plan_id,
|
||||
numero_ciclo: s.numero_ciclo,
|
||||
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).catch((e) => {
|
||||
void generateSubjectAI.mutateAsync(payload as any).catch((e) => {
|
||||
console.error('Error generando asignatura IA (multiple):', e)
|
||||
})
|
||||
})
|
||||
@@ -166,6 +393,8 @@ export function WizardControls({
|
||||
resetScroll: false,
|
||||
})
|
||||
|
||||
setIsSpinningIA(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -195,14 +424,17 @@ export function WizardControls({
|
||||
}
|
||||
} catch (err: any) {
|
||||
setIsSpinningIA(false)
|
||||
stopSubjectWatch()
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage: err?.message ?? 'Error creando la asignatura',
|
||||
}))
|
||||
} finally {
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({ ...w, isLoading: false }))
|
||||
if (!startedWaiting) {
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({ ...w, isLoading: false }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { AIGeneratePlanInput } from '@/data'
|
||||
import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain'
|
||||
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
|
||||
// import type { Database } from '@/types/supabase'
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js'
|
||||
|
||||
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 { supabaseBrowser } from '@/data/supabase/client'
|
||||
|
||||
export function WizardControls({
|
||||
errorMessage,
|
||||
@@ -35,9 +41,152 @@ export function WizardControls({
|
||||
const navigate = useNavigate()
|
||||
const generatePlanAI = useGeneratePlanAI()
|
||||
const createPlanManual = useCreatePlanManual()
|
||||
const deletePlan = useDeletePlanEstudio()
|
||||
const [isSpinningIA, setIsSpinningIA] = useState(false)
|
||||
// const supabaseClient = supabaseBrowser()
|
||||
// const persistPlanFromAI = usePersistPlanFromAI()
|
||||
const cancelledRef = useRef(false)
|
||||
const realtimeChannelRef = useRef<RealtimeChannel | null>(null)
|
||||
const watchPlanIdRef = useRef<string | null>(null)
|
||||
const watchTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
cancelledRef.current = false
|
||||
return () => {
|
||||
cancelledRef.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopPlanWatch = useCallback(() => {
|
||||
if (watchTimeoutRef.current) {
|
||||
window.clearTimeout(watchTimeoutRef.current)
|
||||
watchTimeoutRef.current = null
|
||||
}
|
||||
|
||||
watchPlanIdRef.current = null
|
||||
|
||||
const ch = realtimeChannelRef.current
|
||||
if (ch) {
|
||||
realtimeChannelRef.current = null
|
||||
try {
|
||||
supabaseBrowser().removeChannel(ch)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPlanWatch()
|
||||
}
|
||||
}, [stopPlanWatch])
|
||||
|
||||
const checkPlanStateAndAct = useCallback(
|
||||
async (planId: string) => {
|
||||
if (cancelledRef.current) return
|
||||
if (watchPlanIdRef.current !== planId) return
|
||||
|
||||
const plan = await plans_get_maybe(planId as any)
|
||||
if (!plan) return
|
||||
|
||||
const clave = String(plan.estados_plan?.clave ?? '').toUpperCase()
|
||||
|
||||
if (clave.startsWith('GENERANDO')) return
|
||||
|
||||
if (clave.startsWith('BORRADOR')) {
|
||||
stopPlanWatch()
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({ ...w, isLoading: false }))
|
||||
navigate({
|
||||
to: `/planes/${plan.id}`,
|
||||
state: { showConfetti: true },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (clave.startsWith('FALLID')) {
|
||||
stopPlanWatch()
|
||||
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ó',
|
||||
}))
|
||||
})
|
||||
}
|
||||
},
|
||||
[deletePlan, navigate, setWizard, stopPlanWatch],
|
||||
)
|
||||
|
||||
const beginPlanWatch = useCallback(
|
||||
(planId: string) => {
|
||||
stopPlanWatch()
|
||||
watchPlanIdRef.current = planId
|
||||
|
||||
watchTimeoutRef.current = window.setTimeout(
|
||||
() => {
|
||||
if (cancelledRef.current) return
|
||||
if (watchPlanIdRef.current !== planId) return
|
||||
|
||||
stopPlanWatch()
|
||||
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(`planes-status-${planId}`)
|
||||
realtimeChannelRef.current = channel
|
||||
|
||||
channel.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'planes_estudio',
|
||||
filter: `id=eq.${planId}`,
|
||||
},
|
||||
() => {
|
||||
void checkPlanStateAndAct(planId)
|
||||
},
|
||||
)
|
||||
|
||||
channel.subscribe((status) => {
|
||||
const st = status as
|
||||
| 'SUBSCRIBED'
|
||||
| 'TIMED_OUT'
|
||||
| 'CLOSED'
|
||||
| 'CHANNEL_ERROR'
|
||||
if (cancelledRef.current) return
|
||||
if (st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
|
||||
stopPlanWatch()
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage:
|
||||
'No se pudo suscribir al estado del plan. Intenta de nuevo.',
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback inmediato por si el plan ya cambió antes de suscribir.
|
||||
void checkPlanStateAndAct(planId)
|
||||
},
|
||||
[checkPlanStateAndAct, setWizard, stopPlanWatch],
|
||||
)
|
||||
|
||||
const handleCreate = async () => {
|
||||
// Start loading
|
||||
@@ -82,14 +231,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 realtime; los efectos navegan o marcan error.
|
||||
beginPlanWatch(String(planId))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -114,14 +265,14 @@ export function WizardControls({
|
||||
}
|
||||
} catch (err: any) {
|
||||
setIsSpinningIA(false)
|
||||
stopPlanWatch()
|
||||
setWizard((w) => ({
|
||||
...w,
|
||||
isLoading: false,
|
||||
errorMessage: err?.message ?? 'Error generando el plan',
|
||||
}))
|
||||
} finally {
|
||||
setIsSpinningIA(false)
|
||||
setWizard((w) => ({ ...w, isLoading: false }))
|
||||
// Si entramos en watch realtime, el loading se corta desde checkPlanStateAndAct.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
TipoAsignatura,
|
||||
UUID,
|
||||
} from '../types/domain'
|
||||
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
|
||||
import type {
|
||||
AsignaturaSugerida,
|
||||
DataAsignaturaSugerida,
|
||||
@@ -178,54 +177,49 @@ export async function subjects_create_manual(
|
||||
return requireData(data, 'No se pudo crear la asignatura.')
|
||||
}
|
||||
|
||||
export type AIGenerateSubjectInput = {
|
||||
plan_estudio_id: Asignatura['plan_estudio_id']
|
||||
datosBasicos: {
|
||||
nombre: Asignatura['nombre']
|
||||
codigo?: Asignatura['codigo']
|
||||
tipo: Asignatura['tipo'] | null
|
||||
creditos: Asignatura['creditos'] | null
|
||||
horasAcademicas?: Asignatura['horas_academicas'] | null
|
||||
horasIndependientes?: Asignatura['horas_independientes'] | null
|
||||
estructuraId: Asignatura['estructura_id'] | null
|
||||
/**
|
||||
* Nuevo payload unificado (JSON) para la Edge `ai_generate_subject`.
|
||||
* - Siempre incluye `datosUpdate.plan_estudio_id`.
|
||||
* - `datosUpdate.id` es opcional (si no existe, la Edge puede crear).
|
||||
* En el frontend, insertamos primero y usamos `id` para actualizar.
|
||||
*/
|
||||
export type AISubjectUnifiedInput = {
|
||||
datosUpdate: Partial<{
|
||||
id: string
|
||||
plan_estudio_id: string
|
||||
estructura_id: string
|
||||
nombre: string
|
||||
codigo: string | null
|
||||
tipo: string | null
|
||||
creditos: number
|
||||
horas_academicas: number | null
|
||||
horas_independientes: number | null
|
||||
numero_ciclo: number | null
|
||||
linea_plan_id: string | null
|
||||
orden_celda: number | null
|
||||
}> & {
|
||||
plan_estudio_id: string
|
||||
}
|
||||
// clonInterno?: {
|
||||
// facultadId?: string
|
||||
// carreraId?: string
|
||||
// planOrigenId?: string
|
||||
// asignaturaOrigenId?: string | null
|
||||
// }
|
||||
// clonTradicional?: {
|
||||
// archivoWordAsignaturaId: string | null
|
||||
// archivosAdicionalesIds: Array<string>
|
||||
// }
|
||||
iaConfig?: {
|
||||
descripcionEnfoqueAcademico: string
|
||||
instruccionesAdicionalesIA: string
|
||||
archivosReferencia: Array<string>
|
||||
repositoriosReferencia?: Array<string>
|
||||
archivosAdjuntos?: Array<UploadedFile>
|
||||
descripcionEnfoqueAcademico?: string
|
||||
instruccionesAdicionalesIA?: string
|
||||
archivosAdjuntos?: Array<string>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge (JSON): actualizar/llenar una asignatura existente por id.
|
||||
* Nota: este flujo NO acepta `instruccionesAdicionalesIA` (solo FormData lo usa).
|
||||
*/
|
||||
export type AIGenerateSubjectJsonInput = Partial<{
|
||||
plan_estudio_id: Asignatura['plan_estudio_id']
|
||||
nombre: Asignatura['nombre']
|
||||
codigo: Asignatura['codigo']
|
||||
tipo: Asignatura['tipo'] | null
|
||||
creditos: Asignatura['creditos']
|
||||
horas_academicas: Asignatura['horas_academicas'] | null
|
||||
horas_independientes: Asignatura['horas_independientes'] | null
|
||||
estructura_id: Asignatura['estructura_id'] | null
|
||||
linea_plan_id: Asignatura['linea_plan_id'] | null
|
||||
numero_ciclo: Asignatura['numero_ciclo'] | null
|
||||
descripcionEnfoqueAcademico: string
|
||||
}> & {
|
||||
id: Asignatura['id']
|
||||
export async function subjects_get_maybe(
|
||||
subjectId: UUID,
|
||||
): Promise<Asignatura | null> {
|
||||
const supabase = supabaseBrowser()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('asignaturas')
|
||||
.select('id,plan_estudio_id,estado')
|
||||
.eq('id', subjectId)
|
||||
.maybeSingle()
|
||||
|
||||
throwIfError(error)
|
||||
return (data ?? null) as unknown as Asignatura | null
|
||||
}
|
||||
|
||||
export type GenerateSubjectSuggestionsInput = {
|
||||
@@ -263,30 +257,8 @@ export async function generate_subject_suggestions(
|
||||
}
|
||||
|
||||
export async function ai_generate_subject(
|
||||
input: AIGenerateSubjectInput | AIGenerateSubjectJsonInput,
|
||||
input: AISubjectUnifiedInput,
|
||||
): Promise<any> {
|
||||
if ('datosBasicos' in input) {
|
||||
const edgeFunctionBody = new FormData()
|
||||
edgeFunctionBody.append('plan_estudio_id', input.plan_estudio_id)
|
||||
edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos))
|
||||
edgeFunctionBody.append(
|
||||
'iaConfig',
|
||||
JSON.stringify({
|
||||
...input.iaConfig,
|
||||
archivosAdjuntos: undefined, // los manejamos aparte
|
||||
}),
|
||||
)
|
||||
input.iaConfig?.archivosAdjuntos?.forEach((file) => {
|
||||
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
|
||||
})
|
||||
return invokeEdge<any>(
|
||||
EDGE.ai_generate_subject,
|
||||
edgeFunctionBody,
|
||||
undefined,
|
||||
supabaseBrowser(),
|
||||
)
|
||||
}
|
||||
|
||||
return invokeEdge<any>(EDGE.ai_generate_subject, input, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import {
|
||||
ai_generate_plan,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
plan_lineas_list,
|
||||
plans_clone_from_existing,
|
||||
plans_create_manual,
|
||||
plans_delete,
|
||||
plans_generate_document,
|
||||
plans_get,
|
||||
plans_get_document,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
} from '../api/plans.api'
|
||||
import { lineas_delete } from '../api/subjects.api'
|
||||
import { qk } from '../query/keys'
|
||||
import { supabaseBrowser } from '../supabase/client'
|
||||
|
||||
import type {
|
||||
PlanListFilters,
|
||||
@@ -71,23 +74,79 @@ export function usePlanLineas(planId: UUID | null | undefined) {
|
||||
}
|
||||
|
||||
export function usePlanAsignaturas(planId: UUID | null | undefined) {
|
||||
return useQuery({
|
||||
const qc = useQueryClient()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: planId
|
||||
? qk.planAsignaturas(planId)
|
||||
: ['planes', 'asignaturas', null],
|
||||
queryFn: () => plan_asignaturas_list(planId as UUID),
|
||||
enabled: Boolean(planId),
|
||||
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data
|
||||
if (!Array.isArray(data)) return false
|
||||
const hayGenerando = data.some(
|
||||
(a: any) => (a as { estado?: unknown }).estado === 'generando',
|
||||
)
|
||||
return hayGenerando ? 500 : false
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!planId) return
|
||||
|
||||
const supabase = supabaseBrowser()
|
||||
const channel = supabase.channel(`plan-asignaturas-${planId}`)
|
||||
|
||||
channel.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'asignaturas',
|
||||
filter: `plan_estudio_id=eq.${planId}`,
|
||||
},
|
||||
(payload: {
|
||||
eventType?: 'INSERT' | 'UPDATE' | 'DELETE'
|
||||
new?: any
|
||||
old?: any
|
||||
}) => {
|
||||
const eventType = payload.eventType
|
||||
|
||||
if (eventType === 'DELETE') {
|
||||
const oldRow: any = payload.old
|
||||
const deletedId = oldRow?.id
|
||||
if (!deletedId) return
|
||||
|
||||
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
|
||||
if (!Array.isArray(prev)) return prev
|
||||
return prev.filter((a: any) => String(a?.id) !== String(deletedId))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const newRow: any = payload.new
|
||||
if (!newRow?.id) return
|
||||
|
||||
qc.setQueryData(qk.planAsignaturas(planId), (prev) => {
|
||||
if (!Array.isArray(prev)) return prev
|
||||
|
||||
const idx = prev.findIndex(
|
||||
(a: any) => String(a?.id) === String(newRow.id),
|
||||
)
|
||||
if (idx === -1) return [...prev, newRow]
|
||||
|
||||
const next = [...prev]
|
||||
next[idx] = { ...prev[idx], ...newRow }
|
||||
return next
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
channel.subscribe()
|
||||
|
||||
return () => {
|
||||
try {
|
||||
supabase.removeChannel(channel)
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}, [planId, qc])
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
export function usePlanHistorial(
|
||||
@@ -263,6 +322,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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -22,6 +23,8 @@ export const qk = {
|
||||
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
|
||||
asignatura: (asignaturaId: string) =>
|
||||
['asignaturas', 'detail', asignaturaId] as const,
|
||||
asignaturaMaybe: (asignaturaId: string) =>
|
||||
['asignaturas', 'detail-maybe', asignaturaId] as const,
|
||||
asignaturaBibliografia: (asignaturaId: string) =>
|
||||
['asignaturas', asignaturaId, 'bibliografia'] as const,
|
||||
asignaturaHistorial: (asignaturaId: string) =>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createFileRoute,
|
||||
Outlet,
|
||||
Link,
|
||||
useLocation,
|
||||
useParams,
|
||||
useRouterState,
|
||||
} from '@tanstack/react-router'
|
||||
@@ -9,6 +10,7 @@ import { ArrowLeft, GraduationCap } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { lateralConfetti } from '@/components/ui/lateral-confetti'
|
||||
import { useSubject, useUpdateAsignatura } from '@/data'
|
||||
|
||||
export const Route = createFileRoute(
|
||||
@@ -62,8 +64,7 @@ interface DatosPlan {
|
||||
}
|
||||
|
||||
function AsignaturaLayout() {
|
||||
const routerState = useRouterState()
|
||||
const state = routerState.location.state as any
|
||||
const location = useLocation()
|
||||
const { asignaturaId } = useParams({
|
||||
from: '/planes/$planId/asignaturas/$asignaturaId',
|
||||
})
|
||||
@@ -117,6 +118,14 @@ function AsignaturaLayout() {
|
||||
select: (state) => state.location.pathname,
|
||||
})
|
||||
|
||||
// Confetti al llegar desde creación IA
|
||||
useEffect(() => {
|
||||
if ((location.state as any)?.showConfetti) {
|
||||
lateralConfetti()
|
||||
window.history.replaceState({}, document.title)
|
||||
}
|
||||
}, [location.state])
|
||||
|
||||
if (loadingAsig) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white">
|
||||
@@ -130,7 +139,7 @@ function AsignaturaLayout() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section className="bg-gradient-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
|
||||
<section className="bg-linear-to-b from-[#0b1d3a] to-[#0e2a5c] text-white">
|
||||
<div className="mx-auto max-w-7xl px-6 py-10">
|
||||
<Link
|
||||
to="/planes/$planId/asignaturas"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user