From d6c567195a43e17ba3a9de83d9915d828d0d6b05 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Thu, 12 Feb 2026 16:14:32 -0600 Subject: [PATCH] =?UTF-8?q?Generaci=C3=B3n=20existosa=20de=20m=C3=BAltiple?= =?UTF-8?q?s=20asignaturas=20con=20IA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TODO: actualización automática de el estado de las asignaturas generadas --- .../asignaturas/wizard/WizardControls.tsx | 85 ++++++++++++++++++- src/data/api/plans.api.ts | 6 +- src/data/api/subjects.api.ts | 66 +++++++++----- .../planes/$planId/_detalle/asignaturas.tsx | 61 +++++++++---- src/types/plan.ts | 6 +- 5 files changed, 180 insertions(+), 44 deletions(-) diff --git a/src/components/asignaturas/wizard/WizardControls.tsx b/src/components/asignaturas/wizard/WizardControls.tsx index 9188df1..d393f03 100644 --- a/src/components/asignaturas/wizard/WizardControls.tsx +++ b/src/components/asignaturas/wizard/WizardControls.tsx @@ -1,10 +1,12 @@ +import { useQueryClient } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' -import type { AIGenerateSubjectInput } from '@/data' +import type { AIGenerateSubjectInput, AIGenerateSubjectJsonInput } from '@/data' import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types' +import type { TablesInsert } from '@/types/supabase' import { Button } from '@/components/ui/button' -import { useGenerateSubjectAI } from '@/data' +import { supabaseBrowser, useGenerateSubjectAI, qk } from '@/data' export function WizardControls({ wizard, @@ -28,6 +30,7 @@ export function WizardControls({ isLastStep: boolean }) { const navigate = useNavigate() + const qc = useQueryClient() const generateSubjectAI = useGenerateSubjectAI() const handleCreate = async () => { setWizard((w) => ({ @@ -37,7 +40,7 @@ export function WizardControls({ })) try { - if (wizard.tipoOrigen === 'IA') { + if (wizard.tipoOrigen === 'IA' || wizard.tipoOrigen === 'IA_SIMPLE') { const aiInput: AIGenerateSubjectInput = { plan_estudio_id: wizard.plan_estudio_id, datosBasicos: { @@ -77,6 +80,82 @@ export function WizardControls({ }) return } + + if (wizard.tipoOrigen === 'IA_MULTIPLE') { + const selected = wizard.sugerencias.filter((s) => s.selected) + + if (selected.length === 0) { + throw new Error('Selecciona al menos una sugerencia.') + } + if (!wizard.plan_estudio_id) { + throw new Error('Plan de estudio inválido.') + } + if (!wizard.estructuraId) { + throw new Error('Selecciona una estructura para continuar.') + } + + const supabase = supabaseBrowser() + + const placeholders: Array> = selected.map( + (s): TablesInsert<'asignaturas'> => ({ + plan_estudio_id: wizard.plan_estudio_id, + estructura_id: wizard.estructuraId, + estado: 'generando', + nombre: s.nombre, + codigo: s.codigo ?? null, + tipo: s.tipo ?? undefined, + creditos: s.creditos ?? 0, + horas_academicas: s.horasAcademicas ?? null, + horas_independientes: s.horasIndependientes ?? null, + linea_plan_id: s.linea_plan_id ?? null, + numero_ciclo: s.numero_ciclo ?? null, + }), + ) + + const { data: inserted, error: insertError } = await supabase + .from('asignaturas') + .insert(placeholders) + .select('id') + + if (insertError) { + throw new Error(insertError.message) + } + + const insertedIds = inserted.map((r) => r.id) + if (insertedIds.length !== selected.length) { + throw new Error('No se pudieron crear todas las asignaturas.') + } + + // Disparar generación en paralelo (no bloquear navegación) + insertedIds.forEach((id, idx) => { + const s = selected[idx] + const payload: AIGenerateSubjectJsonInput = { + id, + descripcionEnfoqueAcademico: s.descripcion, + // (opcionales) parches directos si el edge los usa + estructura_id: wizard.estructuraId, + linea_plan_id: s.linea_plan_id, + numero_ciclo: s.numero_ciclo, + } + + void generateSubjectAI.mutateAsync(payload).catch((e) => { + console.error('Error generando asignatura IA (multiple):', e) + }) + }) + + // Invalidar la query del listado del plan (una vez) para que la lista + // muestre el estado actualizado y recargue cuando lleguen updates. + qc.invalidateQueries({ + queryKey: qk.planAsignaturas(wizard.plan_estudio_id), + }) + + navigate({ + to: `/planes/${wizard.plan_estudio_id}/asignaturas`, + resetScroll: false, + }) + + return + } } catch (err: any) { setWizard((w) => ({ ...w, diff --git a/src/data/api/plans.api.ts b/src/data/api/plans.api.ts index 61cfa5d..0d151fe 100644 --- a/src/data/api/plans.api.ts +++ b/src/data/api/plans.api.ts @@ -165,7 +165,7 @@ export async function plan_asignaturas_list( const { data, error } = await supabase .from('asignaturas') .select( - 'id,plan_estudio_id,horas_academicas, horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en', + 'id,plan_estudio_id,horas_academicas,horas_independientes,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,asignatura_hash,conversation_id,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en', ) .eq('plan_estudio_id', planId) .order('numero_ciclo', { ascending: true, nullsFirst: false }) @@ -189,7 +189,7 @@ export async function plans_history( const { data, error, count } = await supabase .from('cambios_plan') .select( - 'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo', + 'id,plan_estudio_id,cambiado_por,cambiado_en,tipo,campo,valor_anterior,valor_nuevo,response_id', { count: 'exact' }, // <--- Pedimos el conteo exacto ) .eq('plan_estudio_id', planId) @@ -304,7 +304,7 @@ export async function ai_generate_plan( archivosAdjuntos: undefined, // los manejamos aparte }), ) - input.iaConfig.archivosAdjuntos.forEach((file, index) => { + input.iaConfig.archivosAdjuntos.forEach((file) => { edgeFunctionBody.append(`archivosAdjuntos`, file.file) }) diff --git a/src/data/api/subjects.api.ts b/src/data/api/subjects.api.ts index 7a3dedb..89c5397 100644 --- a/src/data/api/subjects.api.ts +++ b/src/data/api/subjects.api.ts @@ -41,7 +41,7 @@ export async function subjects_get(subjectId: UUID): Promise { .from('asignaturas') .select( ` - id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,conversation_id,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, + id,plan_estudio_id,estructura_id,codigo,nombre,tipo,creditos,numero_ciclo,linea_plan_id,orden_celda,estado,datos,contenido_tematico,horas_academicas,horas_independientes,asignatura_hash,conversation_id,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, planes_estudio( id,carrera_id,estructura_id,nombre,nivel,tipo_ciclo,numero_ciclos,datos,estado_actual_id,activo,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en, carreras(id,facultad_id,nombre,nombre_corto,clave_sep,activa, facultades(id,nombre,nombre_corto,color,icono)) @@ -138,6 +138,26 @@ export type AIGenerateSubjectInput = { } } +/** + * Edge (JSON): actualizar/llenar una asignatura existente por id. + * Nota: este flujo NO acepta `instruccionesAdicionalesIA` (solo FormData lo usa). + */ +export type AIGenerateSubjectJsonInput = Partial<{ + plan_estudio_id: Asignatura['plan_estudio_id'] + nombre: Asignatura['nombre'] + codigo: Asignatura['codigo'] + tipo: Asignatura['tipo'] | null + creditos: Asignatura['creditos'] + horas_academicas: Asignatura['horas_academicas'] | null + horas_independientes: Asignatura['horas_independientes'] | null + estructura_id: Asignatura['estructura_id'] | null + linea_plan_id: Asignatura['linea_plan_id'] | null + numero_ciclo: Asignatura['numero_ciclo'] | null + descripcionEnfoqueAcademico: string +}> & { + id: Asignatura['id'] +} + export type GenerateSubjectSuggestionsInput = { plan_estudio_id: UUID enfoque?: string @@ -173,27 +193,33 @@ export async function generate_subject_suggestions( } export async function ai_generate_subject( - input: AIGenerateSubjectInput, + input: AIGenerateSubjectInput | AIGenerateSubjectJsonInput, ): Promise { - 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) + 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( + EDGE.ai_generate_subject, + edgeFunctionBody, + undefined, + supabaseBrowser(), + ) + } + + return invokeEdge(EDGE.ai_generate_subject, input, { + headers: { 'Content-Type': 'application/json' }, }) - return invokeEdge( - EDGE.ai_generate_subject, - edgeFunctionBody, - undefined, - supabaseBrowser(), - ) } export async function subjects_persist_from_ai(payload: { diff --git a/src/routes/planes/$planId/_detalle/asignaturas.tsx b/src/routes/planes/$planId/_detalle/asignaturas.tsx index 63e03bc..0ae82ca 100644 --- a/src/routes/planes/$planId/_detalle/asignaturas.tsx +++ b/src/routes/planes/$planId/_detalle/asignaturas.tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from '@tanstack/react-query' import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router' import { Plus, @@ -8,9 +9,10 @@ import { BookOpen, Loader2, } from 'lucide-react' -import { useState, useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import type { Asignatura, AsignaturaStatus, TipoAsignatura } from '@/types/plan' +import type { Tables } from '@/types/supabase' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -30,13 +32,14 @@ import { TableHeader, TableRow, } from '@/components/ui/table' -import { usePlanAsignaturas, usePlanLineas } from '@/data' +import { qk, supabaseBrowser, usePlanAsignaturas, usePlanLineas } from '@/data' // --- Configuración de Estilos --- const statusConfig: Record< AsignaturaStatus, { label: string; className: string } > = { + generando: { label: 'Generando', className: 'bg-slate-100 text-slate-600' }, borrador: { label: 'Borrador', className: 'bg-slate-100 text-slate-600' }, revisada: { label: 'Revisada', className: 'bg-amber-100 text-amber-700' }, aprobada: { label: 'Aprobada', className: 'bg-emerald-100 text-emerald-700' }, @@ -44,31 +47,31 @@ const statusConfig: Record< const tipoConfig: Record = { - obligatoria: { + OBLIGATORIA: { label: 'Obligatoria', className: 'bg-blue-100 text-blue-700', }, - optativa: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' }, - troncal: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' }, + OPTATIVA: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' }, + TRONCAL: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' }, + OTRA: { label: 'Otra', className: 'bg-slate-100 text-slate-700' }, } // --- Mapeadores de API --- -const mapAsignaturas = (asigApi: Array = []): Array => { +const mapAsignaturas = ( + asigApi: Array> = [], +): Array => { return asigApi.map((asig) => ({ id: asig.id, - clave: asig.codigo, + clave: asig.codigo ?? '', nombre: asig.nombre, - creditos: asig.creditos ?? 0, + creditos: asig.creditos, ciclo: asig.numero_ciclo ?? null, lineaCurricularId: asig.linea_plan_id ?? null, - tipo: - asig.tipo?.toLowerCase() === 'obligatoria' ? 'obligatoria' : 'optativa', - estado: 'borrador', // O el campo que venga de tu API - hd: Math.floor((asig.horas_semana ?? 0) / 2), - hi: Math.ceil((asig.horas_semana ?? 0) / 2), - prerrequisitos: Array.isArray(asig.prerrequisitos) - ? asig.prerrequisitos - : [], + tipo: asig.tipo, + estado: asig.estado, + hd: asig.horas_academicas ?? 0, + hi: asig.horas_independientes ?? 0, + prerrequisitos: [], })) } @@ -79,12 +82,38 @@ export const Route = createFileRoute('/planes/$planId/_detalle/asignaturas')({ function AsignaturasPage() { const { planId } = Route.useParams() const navigate = useNavigate() + const queryClient = useQueryClient() // 1. Fetch de datos reales const { data: asignaturasApi, isLoading: loadingAsig } = usePlanAsignaturas(planId) const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(planId) + useEffect(() => { + const supabase = supabaseBrowser() + const channel = supabase + .channel(`plan:${planId}:asignaturas:updates`) + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'asignaturas', + filter: `plan_estudio_id=eq.${planId}`, + }, + () => { + queryClient.invalidateQueries({ + queryKey: qk.planAsignaturas(planId), + }) + }, + ) + .subscribe() + + return () => { + supabase.removeChannel(channel) + } + }, [planId, queryClient]) + // 2. Estados de filtrado const [searchTerm, setSearchTerm] = useState('') const [filterTipo, setFilterTipo] = useState('all') diff --git a/src/types/plan.ts b/src/types/plan.ts index 2d1ec12..b786038 100644 --- a/src/types/plan.ts +++ b/src/types/plan.ts @@ -1,3 +1,5 @@ +import type { Tables } from './supabase' + export type PlanStatus = | 'borrador' | 'revision' @@ -12,9 +14,9 @@ export type TipoPlan = | 'Doctorado' | 'Especialidad' -export type TipoAsignatura = 'obligatoria' | 'optativa' | 'troncal' +export type TipoAsignatura = Tables<'asignaturas'>['tipo'] -export type AsignaturaStatus = 'borrador' | 'revisada' | 'aprobada' +export type AsignaturaStatus = Tables<'asignaturas'>['estado'] export interface Facultad { id: string