Se generan planes de estudio y asignaturas con IA en segundo plano y se actualiza con realtime de supabase #146

Merged
Guillermo.Arrieta merged 6 commits from issue/142-creacin-de-planes-de-estudio-y-de-asignaturas-con- into main 2026-02-27 18:32:48 +00:00
9 changed files with 1000 additions and 497 deletions

View File

@@ -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}

View File

@@ -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,
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 ?? 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,
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)
navigate({
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
state: { showConfetti: true },
// 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
}
@@ -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 = {
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,
// (opcionales) parches directos si el edge los usa
estructura_id: wizard.estructuraId,
linea_plan_id: s.linea_plan_id,
numero_ciclo: s.numero_ciclo,
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,16 +424,19 @@ export function WizardControls({
}
} 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">

View File

@@ -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.
}
}

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

@@ -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' },
})

View File

@@ -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()

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,
@@ -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) =>

View File

@@ -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