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

closes #45:
- 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 is contained in:
2026-02-05 13:24:36 -06:00
parent 268ac064b1
commit 00369df786
6 changed files with 30 additions and 117 deletions

View File

@@ -1,10 +1,6 @@
import { useNavigate } from '@tanstack/react-router'
import type { AIGenerateSubjectInput } from '@/data'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types' import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useGenerateSubjectAI } from '@/data'
export function WizardControls({ export function WizardControls({
wizard, wizard,
@@ -16,6 +12,7 @@ export function WizardControls({
disableNext, disableNext,
disableCreate, disableCreate,
isLastStep, isLastStep,
onCreate,
}: { }: {
wizard: NewSubjectWizardState wizard: NewSubjectWizardState
setWizard: React.Dispatch<React.SetStateAction<NewSubjectWizardState>> setWizard: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
@@ -26,9 +23,8 @@ export function WizardControls({
disableNext: boolean disableNext: boolean
disableCreate: boolean disableCreate: boolean
isLastStep: boolean isLastStep: boolean
onCreate: () => Promise<void> | void
}) { }) {
const navigate = useNavigate()
const generateSubjectAI = useGenerateSubjectAI()
const handleCreate = async () => { const handleCreate = async () => {
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,
@@ -37,46 +33,7 @@ export function WizardControls({
})) }))
try { try {
if (wizard.tipoOrigen === 'IA') { await onCreate()
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) { } catch (err: any) {
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,

View File

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

View File

@@ -11,12 +11,11 @@ import type {
TipoAsignatura, TipoAsignatura,
UUID, UUID,
} from '../types/domain' } from '../types/domain'
import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone'
import type { Database } from '@/types/supabase' import type { Database } from '@/types/supabase'
const EDGE = { const EDGE = {
subjects_create_manual: 'subjects_create_manual', 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_persist_from_ai: 'subjects_persist_from_ai',
subjects_clone_from_existing: 'subjects_clone_from_existing', subjects_clone_from_existing: 'subjects_clone_from_existing',
subjects_import_from_file: 'subjects_import_from_file', subjects_import_from_file: 'subjects_import_from_file',
@@ -103,58 +102,26 @@ export async function subjects_create_manual(
return invokeEdge<Asignatura>(EDGE.subjects_create_manual, payload) return invokeEdge<Asignatura>(EDGE.subjects_create_manual, payload)
} }
export type AIGenerateSubjectInput = { export async function ai_generate_subject(payload: {
plan_estudio_id: Asignatura['plan_estudio_id'] planId: UUID
datosBasicos: { datosBasicos: {
nombre: Asignatura['nombre'] nombre: string
codigo?: Asignatura['codigo'] clave?: string
tipo: Asignatura['tipo'] | null tipo: TipoAsignatura
creditos: Asignatura['creditos'] | null creditos: number
horasAcademicas?: Asignatura['horas_academicas'] | null horasSemana?: number
horasIndependientes?: Asignatura['horas_independientes'] | null estructuraId: UUID
estructuraId: Asignatura['estructura_id'] | null
} }
// clonInterno?: { iaConfig: {
// facultadId?: string descripcionEnfoque: string
// carreraId?: string notasAdicionales?: string
// planOrigenId?: string archivosExistentesIds?: Array<UUID>
// asignaturaOrigenId?: string | null repositoriosIds?: Array<UUID>
// } archivosAdhocIds?: Array<UUID>
// clonTradicional?: { usarMCP?: boolean
// 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: { export async function subjects_persist_from_ai(payload: {

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import { createFileRoute, notFound, useLocation } from '@tanstack/react-router' import { createFileRoute, notFound } from '@tanstack/react-router'
import { useEffect } from 'react'
import AsignaturaDetailPage from '@/components/asignaturas/detalle/AsignaturaDetailPage' import AsignaturaDetailPage from '@/components/asignaturas/detalle/AsignaturaDetailPage'
import { lateralConfetti } from '@/components/ui/lateral-confetti'
import { NotFoundPage } from '@/components/ui/NotFoundPage' import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { subjects_get } from '@/data/api/subjects.api' import { subjects_get } from '@/data/api/subjects.api'
import { qk } from '@/data/query/keys' import { qk } from '@/data/query/keys'
@@ -37,15 +35,6 @@ export const Route = createFileRoute(
function RouteComponent() { function RouteComponent() {
// const { planId, asignaturaId } = Route.useParams() // 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 ( return (
<div> <div>