Feat: generación IA de asignaturas, navegación con confetti y ajustes de API

closes #63:
- Añadido AIGenerateSubjectInput y nueva implementación ai_generate_subject que envía FormData (soporta archivosAdjuntos) al Edge Function.
- Creado hook useGenerateSubjectAI (mutation) y usado en WizardControls de asignaturas para generar la asignatura vía IA.
- WizardControls (asignaturas) construye el payload IA, invoca la mutación y navega al detalle de la asignatura creada pasando state.showConfetti para lanzar confetti.
- Ajustes en subjects.api.ts (nombres de endpoint, tipos y envío de datos) y sincronización de tipos en WizardControls (plan y campos básicos).
- Ruta de detalle de asignatura ($asignaturaId) ahora lee location.state.showConfetti y dispara lateralConfetti al entrar.
- Eliminado el prop onCreate del modal de nueva asignatura (la creación IA se gestiona internamente).
This commit was merged in pull request #76.
This commit is contained in:
2026-02-05 13:22:16 -06:00
parent f00fabeac5
commit b1a233fa8c
6 changed files with 117 additions and 30 deletions

View File

@@ -1,6 +1,10 @@
import { useNavigate } from '@tanstack/react-router'
import type { AIGenerateSubjectInput } from '@/data'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import { Button } from '@/components/ui/button'
import { useGenerateSubjectAI } from '@/data'
export function WizardControls({
wizard,
@@ -12,7 +16,6 @@ export function WizardControls({
disableNext,
disableCreate,
isLastStep,
onCreate,
}: {
wizard: NewSubjectWizardState
setWizard: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
@@ -23,8 +26,9 @@ export function WizardControls({
disableNext: boolean
disableCreate: boolean
isLastStep: boolean
onCreate: () => Promise<void> | void
}) {
const navigate = useNavigate()
const generateSubjectAI = useGenerateSubjectAI()
const handleCreate = async () => {
setWizard((w) => ({
...w,
@@ -33,7 +37,46 @@ export function WizardControls({
}))
try {
await onCreate()
if (wizard.tipoOrigen === 'IA') {
const aiInput: AIGenerateSubjectInput = {
plan_estudio_id: wizard.plan_estudio_id,
datosBasicos: {
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!,
},
iaConfig: {
descripcionEnfoqueAcademico:
wizard.iaConfig!.descripcionEnfoqueAcademico,
instruccionesAdicionalesIA:
wizard.iaConfig!.instruccionesAdicionalesIA,
archivosReferencia: wizard.iaConfig!.archivosReferencia,
repositoriosReferencia:
wizard.iaConfig!.repositoriosReferencia || [],
archivosAdjuntos: wizard.iaConfig!.archivosAdjuntos || [],
},
}
console.log(
`${new Date().toISOString()} - Enviando a generar asignatura con IA`,
)
const asignatura = await generateSubjectAI.mutateAsync(aiInput)
console.log(
`${new Date().toISOString()} - Asignatura IA generada`,
asignatura,
)
navigate({
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
state: { showConfetti: true },
})
return
}
} catch (err: any) {
setWizard((w) => ({
...w,

View File

@@ -1,5 +1,6 @@
import { useNavigate } from '@tanstack/react-router'
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'
@@ -54,11 +55,11 @@ export function WizardControls({
? wizard.datosBasicos.numCiclos
: 1
const aiInput = {
const aiInput: AIGeneratePlanInput = {
datosBasicos: {
nombrePlan: wizard.datosBasicos.nombrePlan,
carreraId: wizard.datosBasicos.carrera.id || undefined,
facultadId: wizard.datosBasicos.facultad.id || undefined,
carreraId: wizard.datosBasicos.carrera.id,
facultadId: wizard.datosBasicos.facultad.id,
nivel: wizard.datosBasicos.nivel as string,
tipoCiclo: tipoCicloSafe,
numCiclos: numCiclosSafe,

View File

@@ -11,11 +11,12 @@ import type {
TipoAsignatura,
UUID,
} from '../types/domain'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import type { Database } from '@/types/supabase'
const EDGE = {
subjects_create_manual: 'subjects_create_manual',
ai_generate_subject: 'ai_generate_subject',
ai_generate_subject: 'ai-generate-subject',
subjects_persist_from_ai: 'subjects_persist_from_ai',
subjects_clone_from_existing: 'subjects_clone_from_existing',
subjects_import_from_file: 'subjects_import_from_file',
@@ -102,26 +103,58 @@ export async function subjects_create_manual(
return invokeEdge<Asignatura>(EDGE.subjects_create_manual, payload)
}
export async function ai_generate_subject(payload: {
planId: UUID
export type AIGenerateSubjectInput = {
plan_estudio_id: Asignatura['plan_estudio_id']
datosBasicos: {
nombre: string
clave?: string
tipo: TipoAsignatura
creditos: number
horasSemana?: number
estructuraId: UUID
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
}
iaConfig: {
descripcionEnfoque: string
notasAdicionales?: string
archivosExistentesIds?: Array<UUID>
repositoriosIds?: Array<UUID>
archivosAdhocIds?: Array<UUID>
usarMCP?: boolean
// 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>
}
}): Promise<any> {
return invokeEdge<any>(EDGE.ai_generate_subject, payload)
}
export async function ai_generate_subject(
input: AIGenerateSubjectInput,
): Promise<any> {
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, index) => {
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
})
return invokeEdge<any>(
EDGE.ai_generate_subject,
edgeFunctionBody,
undefined,
supabaseBrowser(),
)
}
export async function subjects_persist_from_ai(payload: {

View File

@@ -97,7 +97,10 @@ export function useCreateSubjectManual() {
}
export function useGenerateSubjectAI() {
return useMutation({ mutationFn: ai_generate_subject })
const qc = useQueryClient()
return useMutation({
mutationFn: ai_generate_subject,
})
}
export function usePersistSubjectFromAI() {

View File

@@ -118,10 +118,6 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
isLastStep={idx >= Wizard.steps.length - 1}
wizard={wizard}
setWizard={setWizard}
onCreate={async () => {
await crearAsignatura()
handleClose()
}}
/>
</Wizard.Stepper.Controls>
}

View File

@@ -1,6 +1,8 @@
import { createFileRoute, notFound } from '@tanstack/react-router'
import { createFileRoute, notFound, useLocation } from '@tanstack/react-router'
import { useEffect } from 'react'
import AsignaturaDetailPage from '@/components/asignaturas/detalle/AsignaturaDetailPage'
import { lateralConfetti } from '@/components/ui/lateral-confetti'
import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { subjects_get } from '@/data/api/subjects.api'
import { qk } from '@/data/query/keys'
@@ -35,6 +37,15 @@ export const Route = createFileRoute(
function RouteComponent() {
// const { planId, asignaturaId } = Route.useParams()
const location = useLocation()
// Confetti al llegar desde creación
useEffect(() => {
if ((location.state as any)?.showConfetti) {
lateralConfetti()
window.history.replaceState({}, document.title) // Limpiar el estado para que no se repita
}
}, [location.state])
return (
<div>