Generación existosa de múltiples asignaturas con IA
TODO: actualización automática de el estado de las asignaturas generadas
This commit is contained in:
@@ -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<TablesInsert<'asignaturas'>> = 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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
|
||||
.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,8 +193,9 @@ export async function generate_subject_suggestions(
|
||||
}
|
||||
|
||||
export async function ai_generate_subject(
|
||||
input: AIGenerateSubjectInput,
|
||||
input: AIGenerateSubjectInput | AIGenerateSubjectJsonInput,
|
||||
): Promise<any> {
|
||||
if ('datosBasicos' in input) {
|
||||
const edgeFunctionBody = new FormData()
|
||||
edgeFunctionBody.append('plan_estudio_id', input.plan_estudio_id)
|
||||
edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos))
|
||||
@@ -194,6 +215,11 @@ export async function ai_generate_subject(
|
||||
undefined,
|
||||
supabaseBrowser(),
|
||||
)
|
||||
}
|
||||
|
||||
return invokeEdge<any>(EDGE.ai_generate_subject, input, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function subjects_persist_from_ai(payload: {
|
||||
|
||||
@@ -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<TipoAsignatura, { label: string; className: string }> =
|
||||
{
|
||||
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<any> = []): Array<Asignatura> => {
|
||||
const mapAsignaturas = (
|
||||
asigApi: Array<Tables<'asignaturas'>> = [],
|
||||
): Array<Asignatura> => {
|
||||
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<string>('all')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user