Generación de múltiples asignaturas con sugerencias #105
@@ -1,10 +1,12 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
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 { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
|
||||||
|
import type { TablesInsert } from '@/types/supabase'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useGenerateSubjectAI } from '@/data'
|
import { supabaseBrowser, useGenerateSubjectAI, qk } from '@/data'
|
||||||
|
|
||||||
export function WizardControls({
|
export function WizardControls({
|
||||||
wizard,
|
wizard,
|
||||||
@@ -28,6 +30,7 @@ export function WizardControls({
|
|||||||
isLastStep: boolean
|
isLastStep: boolean
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
const generateSubjectAI = useGenerateSubjectAI()
|
const generateSubjectAI = useGenerateSubjectAI()
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
setWizard((w) => ({
|
setWizard((w) => ({
|
||||||
@@ -37,7 +40,7 @@ export function WizardControls({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (wizard.tipoOrigen === 'IA') {
|
if (wizard.tipoOrigen === 'IA' || wizard.tipoOrigen === 'IA_SIMPLE') {
|
||||||
const aiInput: AIGenerateSubjectInput = {
|
const aiInput: AIGenerateSubjectInput = {
|
||||||
plan_estudio_id: wizard.plan_estudio_id,
|
plan_estudio_id: wizard.plan_estudio_id,
|
||||||
datosBasicos: {
|
datosBasicos: {
|
||||||
@@ -77,6 +80,82 @@ export function WizardControls({
|
|||||||
})
|
})
|
||||||
return
|
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) {
|
} catch (err: any) {
|
||||||
setWizard((w) => ({
|
setWizard((w) => ({
|
||||||
...w,
|
...w,
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export async function plan_asignaturas_list(
|
|||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('asignaturas')
|
.from('asignaturas')
|
||||||
.select(
|
.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)
|
.eq('plan_estudio_id', planId)
|
||||||
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
.order('numero_ciclo', { ascending: true, nullsFirst: false })
|
||||||
@@ -189,7 +189,7 @@ export async function plans_history(
|
|||||||
const { data, error, count } = await supabase
|
const { data, error, count } = await supabase
|
||||||
.from('cambios_plan')
|
.from('cambios_plan')
|
||||||
.select(
|
.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
|
{ count: 'exact' }, // <--- Pedimos el conteo exacto
|
||||||
)
|
)
|
||||||
.eq('plan_estudio_id', planId)
|
.eq('plan_estudio_id', planId)
|
||||||
@@ -304,7 +304,7 @@ export async function ai_generate_plan(
|
|||||||
archivosAdjuntos: undefined, // los manejamos aparte
|
archivosAdjuntos: undefined, // los manejamos aparte
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
input.iaConfig.archivosAdjuntos.forEach((file, index) => {
|
input.iaConfig.archivosAdjuntos.forEach((file) => {
|
||||||
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
|
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export async function subjects_get(subjectId: UUID): Promise<Asignatura> {
|
|||||||
.from('asignaturas')
|
.from('asignaturas')
|
||||||
.select(
|
.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(
|
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,
|
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))
|
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 = {
|
export type GenerateSubjectSuggestionsInput = {
|
||||||
plan_estudio_id: UUID
|
plan_estudio_id: UUID
|
||||||
enfoque?: string
|
enfoque?: string
|
||||||
@@ -173,27 +193,33 @@ export async function generate_subject_suggestions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function ai_generate_subject(
|
export async function ai_generate_subject(
|
||||||
input: AIGenerateSubjectInput,
|
input: AIGenerateSubjectInput | AIGenerateSubjectJsonInput,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const edgeFunctionBody = new FormData()
|
if ('datosBasicos' in input) {
|
||||||
edgeFunctionBody.append('plan_estudio_id', input.plan_estudio_id)
|
const edgeFunctionBody = new FormData()
|
||||||
edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos))
|
edgeFunctionBody.append('plan_estudio_id', input.plan_estudio_id)
|
||||||
edgeFunctionBody.append(
|
edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos))
|
||||||
'iaConfig',
|
edgeFunctionBody.append(
|
||||||
JSON.stringify({
|
'iaConfig',
|
||||||
...input.iaConfig,
|
JSON.stringify({
|
||||||
archivosAdjuntos: undefined, // los manejamos aparte
|
...input.iaConfig,
|
||||||
}),
|
archivosAdjuntos: undefined, // los manejamos aparte
|
||||||
)
|
}),
|
||||||
input.iaConfig?.archivosAdjuntos?.forEach((file) => {
|
)
|
||||||
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
|
input.iaConfig?.archivosAdjuntos?.forEach((file) => {
|
||||||
|
edgeFunctionBody.append(`archivosAdjuntos`, file.file)
|
||||||
|
})
|
||||||
|
return invokeEdge<any>(
|
||||||
|
EDGE.ai_generate_subject,
|
||||||
|
edgeFunctionBody,
|
||||||
|
undefined,
|
||||||
|
supabaseBrowser(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return invokeEdge<any>(EDGE.ai_generate_subject, input, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
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: {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, Outlet, useNavigate } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -8,9 +9,10 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import type { Asignatura, AsignaturaStatus, TipoAsignatura } from '@/types/plan'
|
import type { Asignatura, AsignaturaStatus, TipoAsignatura } from '@/types/plan'
|
||||||
|
import type { Tables } from '@/types/supabase'
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -30,13 +32,14 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { usePlanAsignaturas, usePlanLineas } from '@/data'
|
import { qk, supabaseBrowser, usePlanAsignaturas, usePlanLineas } from '@/data'
|
||||||
|
|
||||||
// --- Configuración de Estilos ---
|
// --- Configuración de Estilos ---
|
||||||
const statusConfig: Record<
|
const statusConfig: Record<
|
||||||
AsignaturaStatus,
|
AsignaturaStatus,
|
||||||
{ label: string; className: string }
|
{ label: string; className: string }
|
||||||
> = {
|
> = {
|
||||||
|
generando: { label: 'Generando', className: 'bg-slate-100 text-slate-600' },
|
||||||
borrador: { label: 'Borrador', 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' },
|
revisada: { label: 'Revisada', className: 'bg-amber-100 text-amber-700' },
|
||||||
aprobada: { label: 'Aprobada', className: 'bg-emerald-100 text-emerald-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 }> =
|
const tipoConfig: Record<TipoAsignatura, { label: string; className: string }> =
|
||||||
{
|
{
|
||||||
obligatoria: {
|
OBLIGATORIA: {
|
||||||
label: 'Obligatoria',
|
label: 'Obligatoria',
|
||||||
className: 'bg-blue-100 text-blue-700',
|
className: 'bg-blue-100 text-blue-700',
|
||||||
},
|
},
|
||||||
optativa: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' },
|
OPTATIVA: { label: 'Optativa', className: 'bg-purple-100 text-purple-700' },
|
||||||
troncal: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' },
|
TRONCAL: { label: 'Troncal', className: 'bg-slate-100 text-slate-700' },
|
||||||
|
OTRA: { label: 'Otra', className: 'bg-slate-100 text-slate-700' },
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Mapeadores de API ---
|
// --- Mapeadores de API ---
|
||||||
const mapAsignaturas = (asigApi: Array<any> = []): Array<Asignatura> => {
|
const mapAsignaturas = (
|
||||||
|
asigApi: Array<Tables<'asignaturas'>> = [],
|
||||||
|
): Array<Asignatura> => {
|
||||||
return asigApi.map((asig) => ({
|
return asigApi.map((asig) => ({
|
||||||
id: asig.id,
|
id: asig.id,
|
||||||
clave: asig.codigo,
|
clave: asig.codigo ?? '',
|
||||||
nombre: asig.nombre,
|
nombre: asig.nombre,
|
||||||
creditos: asig.creditos ?? 0,
|
creditos: asig.creditos,
|
||||||
ciclo: asig.numero_ciclo ?? null,
|
ciclo: asig.numero_ciclo ?? null,
|
||||||
lineaCurricularId: asig.linea_plan_id ?? null,
|
lineaCurricularId: asig.linea_plan_id ?? null,
|
||||||
tipo:
|
tipo: asig.tipo,
|
||||||
asig.tipo?.toLowerCase() === 'obligatoria' ? 'obligatoria' : 'optativa',
|
estado: asig.estado,
|
||||||
estado: 'borrador', // O el campo que venga de tu API
|
hd: asig.horas_academicas ?? 0,
|
||||||
hd: Math.floor((asig.horas_semana ?? 0) / 2),
|
hi: asig.horas_independientes ?? 0,
|
||||||
hi: Math.ceil((asig.horas_semana ?? 0) / 2),
|
prerrequisitos: [],
|
||||||
prerrequisitos: Array.isArray(asig.prerrequisitos)
|
|
||||||
? asig.prerrequisitos
|
|
||||||
: [],
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,12 +82,38 @@ export const Route = createFileRoute('/planes/$planId/_detalle/asignaturas')({
|
|||||||
function AsignaturasPage() {
|
function AsignaturasPage() {
|
||||||
const { planId } = Route.useParams()
|
const { planId } = Route.useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
// 1. Fetch de datos reales
|
// 1. Fetch de datos reales
|
||||||
const { data: asignaturasApi, isLoading: loadingAsig } =
|
const { data: asignaturasApi, isLoading: loadingAsig } =
|
||||||
usePlanAsignaturas(planId)
|
usePlanAsignaturas(planId)
|
||||||
const { data: lineasApi, isLoading: loadingLineas } = usePlanLineas(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
|
// 2. Estados de filtrado
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [filterTipo, setFilterTipo] = useState<string>('all')
|
const [filterTipo, setFilterTipo] = useState<string>('all')
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Tables } from './supabase'
|
||||||
|
|
||||||
export type PlanStatus =
|
export type PlanStatus =
|
||||||
| 'borrador'
|
| 'borrador'
|
||||||
| 'revision'
|
| 'revision'
|
||||||
@@ -12,9 +14,9 @@ export type TipoPlan =
|
|||||||
| 'Doctorado'
|
| 'Doctorado'
|
||||||
| 'Especialidad'
|
| '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 {
|
export interface Facultad {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
Reference in New Issue
Block a user