Merge pull request 'Se generan planes de estudio y asignaturas con IA en segundo plano y se actualiza con realtime de supabase' (#146) from issue/142-creacin-de-planes-de-estudio-y-de-asignaturas-con- into main

Reviewed-on: #146
This commit was merged in pull request #146.
This commit is contained in:
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>> onChange: Dispatch<SetStateAction<NewSubjectWizardState>>
}) { }) {
const enfoque = wizard.iaMultiple?.enfoque ?? '' const enfoque = wizard.iaMultiple?.enfoque ?? ''
const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 10 const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 5
const isLoading = wizard.iaMultiple?.isLoading ?? false const isLoading = wizard.iaMultiple?.isLoading ?? false
const [showConservacionTooltip, setShowConservacionTooltip] = useState(false) const [showConservacionTooltip, setShowConservacionTooltip] = useState(false)
@@ -163,7 +163,7 @@ export default function PasoSugerenciasForm({
Cantidad de sugerencias Cantidad de sugerencias
</Label> </Label>
<Input <Input
placeholder="Ej. 10" placeholder="Ej. 5"
value={cantidadDeSugerencias} value={cantidadDeSugerencias}
type="number" type="number"
min={1} min={1}

View File

@@ -1,11 +1,12 @@
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } 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 { 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 { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { TablesInsert } from '@/types/supabase' import type { TablesInsert } from '@/types/supabase'
import type { RealtimeChannel } from '@supabase/supabase-js'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@@ -13,6 +14,7 @@ import {
useGenerateSubjectAI, useGenerateSubjectAI,
qk, qk,
useCreateSubjectManual, useCreateSubjectManual,
subjects_get_maybe,
} from '@/data' } from '@/data'
export function WizardControls({ export function WizardControls({
@@ -41,6 +43,154 @@ export function WizardControls({
const generateSubjectAI = useGenerateSubjectAI() const generateSubjectAI = useGenerateSubjectAI()
const createSubjectManual = useCreateSubjectManual() const createSubjectManual = useCreateSubjectManual()
const [isSpinningIA, setIsSpinningIA] = useState(false) 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 () => { const handleCreate = async () => {
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,
@@ -48,48 +198,99 @@ export function WizardControls({
errorMessage: null, errorMessage: null,
})) }))
let startedWaiting = false
try { try {
if (wizard.tipoOrigen === 'IA_SIMPLE') { 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, 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, nombre: wizard.datosBasicos.nombre,
codigo: wizard.datosBasicos.codigo, codigo: wizard.datosBasicos.codigo ?? null,
tipo: wizard.datosBasicos.tipo!, tipo: wizard.datosBasicos.tipo ?? null,
creditos: wizard.datosBasicos.creditos!, creditos: wizard.datosBasicos.creditos,
horasIndependientes: wizard.datosBasicos.horasIndependientes, horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
horasAcademicas: wizard.datosBasicos.horasAcademicas, horas_independientes:
estructuraId: wizard.datosBasicos.estructuraId!, wizard.datosBasicos.horasIndependientes ?? null,
}, },
iaConfig: { iaConfig: {
descripcionEnfoqueAcademico: descripcionEnfoqueAcademico:
wizard.iaConfig!.descripcionEnfoqueAcademico, wizard.iaConfig?.descripcionEnfoqueAcademico ?? undefined,
instruccionesAdicionalesIA: instruccionesAdicionalesIA:
wizard.iaConfig!.instruccionesAdicionalesIA, wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined,
archivosReferencia: wizard.iaConfig!.archivosReferencia, archivosAdjuntos,
repositoriosReferencia:
wizard.iaConfig!.repositoriosReferencia || [],
archivosAdjuntos: wizard.iaConfig!.archivosAdjuntos || [],
}, },
} }
console.log( console.log(
`${new Date().toISOString()} - Enviando a generar asignatura con IA`, `${new Date().toISOString()} - Disparando Edge IA asignatura (unified)`,
) )
setIsSpinningIA(true) await generateSubjectAI.mutateAsync(payload as any)
const asignatura = await generateSubjectAI.mutateAsync(aiInput)
// await new Promise((resolve) => setTimeout(resolve, 20000)) // debug // Fallback: una lectura puntual por si el UPDATE llegó antes de suscribir.
setIsSpinningIA(false) const latest = await subjects_get_maybe(subjectId)
// console.log( if (latest) {
// `${new Date().toISOString()} - Asignatura IA generada`, handleSubjectReady({
// asignatura, 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 return
} }
@@ -108,6 +309,15 @@ export function WizardControls({
const supabase = supabaseBrowser() 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( const placeholders: Array<TablesInsert<'asignaturas'>> = selected.map(
(s): TablesInsert<'asignaturas'> => ({ (s): TablesInsert<'asignaturas'> => ({
plan_estudio_id: wizard.plan_estudio_id, plan_estudio_id: wizard.plan_estudio_id,
@@ -141,16 +351,33 @@ export function WizardControls({
// Disparar generación en paralelo (no bloquear navegación) // Disparar generación en paralelo (no bloquear navegación)
insertedIds.forEach((id, idx) => { insertedIds.forEach((id, idx) => {
const s = selected[idx] const s = selected[idx]
const payload: AIGenerateSubjectJsonInput = { const creditosForEdge =
id, typeof s.creditos === 'number' && s.creditos > 0
descripcionEnfoqueAcademico: s.descripcion, ? s.creditos
// (opcionales) parches directos si el edge los usa : undefined
estructura_id: wizard.estructuraId, const payload: AISubjectUnifiedInput = {
linea_plan_id: s.linea_plan_id, datosUpdate: {
numero_ciclo: s.numero_ciclo, 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) console.error('Error generando asignatura IA (multiple):', e)
}) })
}) })
@@ -166,6 +393,8 @@ export function WizardControls({
resetScroll: false, resetScroll: false,
}) })
setIsSpinningIA(false)
return return
} }
@@ -195,14 +424,17 @@ export function WizardControls({
} }
} catch (err: any) { } catch (err: any) {
setIsSpinningIA(false) setIsSpinningIA(false)
stopSubjectWatch()
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,
isLoading: false, isLoading: false,
errorMessage: err?.message ?? 'Error creando la asignatura', errorMessage: err?.message ?? 'Error creando la asignatura',
})) }))
} finally { } finally {
setIsSpinningIA(false) if (!startedWaiting) {
setWizard((w) => ({ ...w, isLoading: false })) setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false }))
}
} }
} }

View File

@@ -1,15 +1,21 @@
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 { useCallback, 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'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types' import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
// import type { Database } from '@/types/supabase' // import type { Database } from '@/types/supabase'
import type { RealtimeChannel } from '@supabase/supabase-js'
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 { supabaseBrowser } from '@/data/supabase/client'
export function WizardControls({ export function WizardControls({
errorMessage, errorMessage,
@@ -35,9 +41,152 @@ 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 supabaseClient = supabaseBrowser() const cancelledRef = useRef(false)
// const persistPlanFromAI = usePersistPlanFromAI() 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 () => { const handleCreate = async () => {
// Start loading // Start loading
@@ -82,14 +231,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 realtime; los efectos navegan o marcan error.
beginPlanWatch(String(planId))
return return
} }
@@ -114,14 +265,14 @@ export function WizardControls({
} }
} catch (err: any) { } catch (err: any) {
setIsSpinningIA(false) setIsSpinningIA(false)
stopPlanWatch()
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 {
setIsSpinningIA(false) // Si entramos en watch realtime, el loading se corta desde checkPlanStateAndAct.
setWizard((w) => ({ ...w, isLoading: false }))
} }
} }

View File

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

View File

@@ -15,7 +15,6 @@ import type {
TipoAsignatura, TipoAsignatura,
UUID, UUID,
} from '../types/domain' } from '../types/domain'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import type { import type {
AsignaturaSugerida, AsignaturaSugerida,
DataAsignaturaSugerida, DataAsignaturaSugerida,
@@ -178,54 +177,49 @@ export async function subjects_create_manual(
return requireData(data, 'No se pudo crear la asignatura.') return requireData(data, 'No se pudo crear la asignatura.')
} }
export type AIGenerateSubjectInput = { /**
plan_estudio_id: Asignatura['plan_estudio_id'] * Nuevo payload unificado (JSON) para la Edge `ai_generate_subject`.
datosBasicos: { * - Siempre incluye `datosUpdate.plan_estudio_id`.
nombre: Asignatura['nombre'] * - `datosUpdate.id` es opcional (si no existe, la Edge puede crear).
codigo?: Asignatura['codigo'] * En el frontend, insertamos primero y usamos `id` para actualizar.
tipo: Asignatura['tipo'] | null */
creditos: Asignatura['creditos'] | null export type AISubjectUnifiedInput = {
horasAcademicas?: Asignatura['horas_academicas'] | null datosUpdate: Partial<{
horasIndependientes?: Asignatura['horas_independientes'] | null id: string
estructuraId: Asignatura['estructura_id'] | null 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?: { iaConfig?: {
descripcionEnfoqueAcademico: string descripcionEnfoqueAcademico?: string
instruccionesAdicionalesIA: string instruccionesAdicionalesIA?: string
archivosReferencia: Array<string> archivosAdjuntos?: Array<string>
repositoriosReferencia?: Array<string>
archivosAdjuntos?: Array<UploadedFile>
} }
} }
/** export async function subjects_get_maybe(
* Edge (JSON): actualizar/llenar una asignatura existente por id. subjectId: UUID,
* Nota: este flujo NO acepta `instruccionesAdicionalesIA` (solo FormData lo usa). ): Promise<Asignatura | null> {
*/ const supabase = supabaseBrowser()
export type AIGenerateSubjectJsonInput = Partial<{
plan_estudio_id: Asignatura['plan_estudio_id'] const { data, error } = await supabase
nombre: Asignatura['nombre'] .from('asignaturas')
codigo: Asignatura['codigo'] .select('id,plan_estudio_id,estado')
tipo: Asignatura['tipo'] | null .eq('id', subjectId)
creditos: Asignatura['creditos'] .maybeSingle()
horas_academicas: Asignatura['horas_academicas'] | null
horas_independientes: Asignatura['horas_independientes'] | null throwIfError(error)
estructura_id: Asignatura['estructura_id'] | null return (data ?? null) as unknown as Asignatura | null
linea_plan_id: Asignatura['linea_plan_id'] | null
numero_ciclo: Asignatura['numero_ciclo'] | null
descripcionEnfoqueAcademico: string
}> & {
id: Asignatura['id']
} }
export type GenerateSubjectSuggestionsInput = { export type GenerateSubjectSuggestionsInput = {
@@ -263,30 +257,8 @@ export async function generate_subject_suggestions(
} }
export async function ai_generate_subject( export async function ai_generate_subject(
input: AIGenerateSubjectInput | AIGenerateSubjectJsonInput, input: AISubjectUnifiedInput,
): Promise<any> { ): 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, { return invokeEdge<any>(EDGE.ai_generate_subject, input, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) })

View File

@@ -4,6 +4,7 @@ import {
useQuery, useQuery,
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import { useEffect } from 'react'
import { import {
ai_generate_plan, ai_generate_plan,
@@ -12,6 +13,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,
@@ -25,6 +27,7 @@ import {
} from '../api/plans.api' } from '../api/plans.api'
import { lineas_delete } from '../api/subjects.api' import { lineas_delete } from '../api/subjects.api'
import { qk } from '../query/keys' import { qk } from '../query/keys'
import { supabaseBrowser } from '../supabase/client'
import type { import type {
PlanListFilters, PlanListFilters,
@@ -71,23 +74,79 @@ export function usePlanLineas(planId: UUID | null | undefined) {
} }
export function usePlanAsignaturas(planId: UUID | null | undefined) { export function usePlanAsignaturas(planId: UUID | null | undefined) {
return useQuery({ const qc = useQueryClient()
const query = useQuery({
queryKey: planId queryKey: planId
? qk.planAsignaturas(planId) ? qk.planAsignaturas(planId)
: ['planes', 'asignaturas', null], : ['planes', 'asignaturas', null],
queryFn: () => plan_asignaturas_list(planId as UUID), queryFn: () => plan_asignaturas_list(planId as UUID),
enabled: Boolean(planId), 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( 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() { export function useGeneratePlanDocumento() {
const qc = useQueryClient() const qc = useQueryClient()

View File

@@ -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,
@@ -22,6 +23,8 @@ export const qk = {
sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const, sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const,
asignatura: (asignaturaId: string) => asignatura: (asignaturaId: string) =>
['asignaturas', 'detail', asignaturaId] as const, ['asignaturas', 'detail', asignaturaId] as const,
asignaturaMaybe: (asignaturaId: string) =>
['asignaturas', 'detail-maybe', asignaturaId] as const,
asignaturaBibliografia: (asignaturaId: string) => asignaturaBibliografia: (asignaturaId: string) =>
['asignaturas', asignaturaId, 'bibliografia'] as const, ['asignaturas', asignaturaId, 'bibliografia'] as const,
asignaturaHistorial: (asignaturaId: string) => asignaturaHistorial: (asignaturaId: string) =>

View File

@@ -2,6 +2,7 @@ import {
createFileRoute, createFileRoute,
Outlet, Outlet,
Link, Link,
useLocation,
useParams, useParams,
useRouterState, useRouterState,
} from '@tanstack/react-router' } from '@tanstack/react-router'
@@ -9,6 +10,7 @@ import { ArrowLeft, GraduationCap } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { lateralConfetti } from '@/components/ui/lateral-confetti'
import { useSubject, useUpdateAsignatura } from '@/data' import { useSubject, useUpdateAsignatura } from '@/data'
export const Route = createFileRoute( export const Route = createFileRoute(
@@ -62,8 +64,7 @@ interface DatosPlan {
} }
function AsignaturaLayout() { function AsignaturaLayout() {
const routerState = useRouterState() const location = useLocation()
const state = routerState.location.state as any
const { asignaturaId } = useParams({ const { asignaturaId } = useParams({
from: '/planes/$planId/asignaturas/$asignaturaId', from: '/planes/$planId/asignaturas/$asignaturaId',
}) })
@@ -117,6 +118,14 @@ function AsignaturaLayout() {
select: (state) => state.location.pathname, 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) { if (loadingAsig) {
return ( return (
<div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white"> <div className="flex h-screen items-center justify-center bg-[#0b1d3a] text-white">
@@ -130,7 +139,7 @@ function AsignaturaLayout() {
return ( return (
<div> <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"> <div className="mx-auto max-w-7xl px-6 py-10">
<Link <Link
to="/planes/$planId/asignaturas" to="/planes/$planId/asignaturas"

File diff suppressed because it is too large Load Diff