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:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user