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 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,
@@ -12,7 +16,6 @@ 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>>
@@ -23,8 +26,9 @@ 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,
@@ -33,7 +37,46 @@ export function WizardControls({
})) }))
try { 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) { } catch (err: any) {
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,

View File

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

View File

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

View File

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

View File

@@ -118,10 +118,6 @@ 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,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 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'
@@ -35,6 +37,15 @@ 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>