Merge pull request 'Se agregan indicadores de que está generandose el plan o la asignatura' (#115) from issue/29-botn-de-generando-plan-de-estudios into main

Reviewed-on: #115
This commit was merged in pull request #115.
This commit is contained in:
2026-02-13 18:46:20 +00:00
6 changed files with 105 additions and 55 deletions

View File

@@ -1,12 +1,19 @@
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react'
import { useState } from 'react'
import type { AIGenerateSubjectInput, AIGenerateSubjectJsonInput } 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 type { TablesInsert } from '@/types/supabase'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { supabaseBrowser, useGenerateSubjectAI, qk } from '@/data' import {
supabaseBrowser,
useGenerateSubjectAI,
qk,
useCreateSubjectManual,
} from '@/data'
export function WizardControls({ export function WizardControls({
wizard, wizard,
@@ -32,6 +39,8 @@ export function WizardControls({
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient() const qc = useQueryClient()
const generateSubjectAI = useGenerateSubjectAI() const generateSubjectAI = useGenerateSubjectAI()
const createSubjectManual = useCreateSubjectManual()
const [isSpinningIA, setIsSpinningIA] = useState(false)
const handleCreate = async () => { const handleCreate = async () => {
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,
@@ -40,7 +49,7 @@ export function WizardControls({
})) }))
try { try {
if (wizard.tipoOrigen === 'IA' || wizard.tipoOrigen === 'IA_SIMPLE') { if (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: {
@@ -68,11 +77,14 @@ export function WizardControls({
`${new Date().toISOString()} - Enviando a generar asignatura con IA`, `${new Date().toISOString()} - Enviando a generar asignatura con IA`,
) )
setIsSpinningIA(true)
const asignatura = await generateSubjectAI.mutateAsync(aiInput) const asignatura = await generateSubjectAI.mutateAsync(aiInput)
console.log( // await new Promise((resolve) => setTimeout(resolve, 20000)) // debug
`${new Date().toISOString()} - Asignatura IA generada`, setIsSpinningIA(false)
asignatura, // console.log(
) // `${new Date().toISOString()} - Asignatura IA generada`,
// asignatura,
// )
navigate({ navigate({
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`, to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
@@ -156,13 +168,40 @@ export function WizardControls({
return return
} }
if (wizard.tipoOrigen === 'MANUAL') {
if (!wizard.plan_estudio_id) {
throw new Error('Plan de estudio inválido.')
}
const asignatura = await createSubjectManual.mutateAsync({
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.datosBasicos.estructuraId!,
nombre: wizard.datosBasicos.nombre,
codigo: wizard.datosBasicos.codigo ?? null,
tipo: wizard.datosBasicos.tipo ?? undefined,
creditos: wizard.datosBasicos.creditos ?? 0,
horas_academicas: wizard.datosBasicos.horasAcademicas ?? null,
horas_independientes: wizard.datosBasicos.horasIndependientes ?? null,
linea_plan_id: null,
numero_ciclo: null,
})
navigate({
to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`,
state: { showConfetti: true },
resetScroll: false,
})
}
} catch (err: any) { } catch (err: any) {
setIsSpinningIA(false)
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,
isLoading: false, isLoading: false,
errorMessage: err?.message ?? 'Error creando la asignatura', errorMessage: err?.message ?? 'Error creando la asignatura',
})) }))
} finally { } finally {
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false })) setWizard((w) => ({ ...w, isLoading: false }))
} }
} }
@@ -181,6 +220,17 @@ export function WizardControls({
)} )}
</div> </div>
<div className="mx-2 flex w-5 items-center justify-center">
<Loader2
className={
wizard.tipoOrigen === 'IA_SIMPLE' && isSpinningIA
? 'text-muted-foreground h-6 w-6 animate-spin'
: 'h-6 w-6 opacity-0'
}
aria-hidden={!(wizard.tipoOrigen === 'IA_SIMPLE' && isSpinningIA)}
/>
</div>
{isLastStep ? ( {isLastStep ? (
<Button onClick={handleCreate} disabled={disableCreate}> <Button onClick={handleCreate} disabled={disableCreate}>
{wizard.isLoading ? 'Creando...' : 'Crear Asignatura'} {wizard.isLoading ? 'Creando...' : 'Crear Asignatura'}

View File

@@ -1,4 +1,6 @@
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react'
import { useState } from 'react'
import type { AIGeneratePlanInput } from '@/data' import type { AIGeneratePlanInput } from '@/data'
import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain' import type { NivelPlanEstudio, TipoCiclo } from '@/data/types/domain'
@@ -33,6 +35,7 @@ export function WizardControls({
const navigate = useNavigate() const navigate = useNavigate()
const generatePlanAI = useGeneratePlanAI() const generatePlanAI = useGeneratePlanAI()
const createPlanManual = useCreatePlanManual() const createPlanManual = useCreatePlanManual()
const [isSpinningIA, setIsSpinningIA] = useState(false)
// const supabaseClient = supabaseBrowser() // const supabaseClient = supabaseBrowser()
// const persistPlanFromAI = usePersistPlanFromAI() // const persistPlanFromAI = usePersistPlanFromAI()
@@ -78,7 +81,9 @@ export function WizardControls({
console.log(`${new Date().toISOString()} - Enviando a generar plan IA`) console.log(`${new Date().toISOString()} - Enviando a generar plan IA`)
setIsSpinningIA(true)
const plan = await generatePlanAI.mutateAsync(aiInput as any) const plan = await generatePlanAI.mutateAsync(aiInput as any)
setIsSpinningIA(false)
console.log(`${new Date().toISOString()} - Plan IA generado`, plan) console.log(`${new Date().toISOString()} - Plan IA generado`, plan)
navigate({ navigate({
@@ -108,12 +113,14 @@ export function WizardControls({
return return
} }
} catch (err: any) { } catch (err: any) {
setIsSpinningIA(false)
setWizard((w) => ({ setWizard((w) => ({
...w, ...w,
isLoading: false, isLoading: false,
errorMessage: err?.message ?? 'Error generando el plan', errorMessage: err?.message ?? 'Error generando el plan',
})) }))
} finally { } finally {
setIsSpinningIA(false)
setWizard((w) => ({ ...w, isLoading: false })) setWizard((w) => ({ ...w, isLoading: false }))
} }
} }
@@ -130,6 +137,17 @@ export function WizardControls({
</span> </span>
)} )}
</div> </div>
<div className="mx-2 flex w-5 items-center justify-center">
<Loader2
className={
wizard.tipoOrigen === 'IA' && isSpinningIA
? 'text-muted-foreground h-6 w-6 animate-spin'
: 'h-6 w-6 opacity-0'
}
aria-hidden={!(wizard.tipoOrigen === 'IA' && isSpinningIA)}
/>
</div>
{isLastStep ? ( {isLastStep ? (
<Button onClick={handleCreate} disabled={disableCreate}> <Button onClick={handleCreate} disabled={disableCreate}>
Crear plan Crear plan

View File

@@ -16,7 +16,7 @@ import type {
AsignaturaSugerida, AsignaturaSugerida,
DataAsignaturaSugerida, DataAsignaturaSugerida,
} from '@/features/asignaturas/nueva/types' } from '@/features/asignaturas/nueva/types'
import type { Database } from '@/types/supabase' import type { Database, TablesInsert } from '@/types/supabase'
const EDGE = { const EDGE = {
generate_subject_suggestions: 'generate-subject-suggestions', generate_subject_suggestions: 'generate-subject-suggestions',
@@ -89,23 +89,18 @@ export async function subjects_bibliografia_list(
return data ?? [] return data ?? []
} }
/** Wizard: crear asignatura manual (Edge Function) */
export type SubjectsCreateManualInput = {
planId: UUID
datosBasicos: {
nombre: string
clave?: string
tipo: TipoAsignatura
creditos: number
horasSemana?: number
estructuraId: UUID
}
}
export async function subjects_create_manual( export async function subjects_create_manual(
payload: SubjectsCreateManualInput, payload: TablesInsert<'asignaturas'>,
): Promise<Asignatura> { ): Promise<Asignatura> {
return invokeEdge<Asignatura>(EDGE.subjects_create_manual, payload) const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('asignaturas')
.insert(payload)
.select()
.single()
throwIfError(error)
return requireData(data, 'No se pudo crear la asignatura.')
} }
export type AIGenerateSubjectInput = { export type AIGenerateSubjectInput = {

View File

@@ -77,6 +77,16 @@ export function usePlanAsignaturas(planId: UUID | null | undefined) {
: ['planes', 'asignaturas', null], : ['planes', 'asignaturas', null],
queryFn: () => plan_asignaturas_list(planId as UUID), queryFn: () => plan_asignaturas_list(planId as UUID),
enabled: Boolean(planId), enabled: Boolean(planId),
refetchInterval: (query) => {
const data = query.state.data
if (!Array.isArray(data)) return false
const hayGenerando = data.some(
(a: any) => (a as { estado?: unknown }).estado === 'generando',
)
return hayGenerando ? 500 : false
},
refetchIntervalInBackground: true,
}) })
} }
@@ -269,7 +279,7 @@ export function useDeleteLinea() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: lineas_delete, mutationFn: lineas_delete,
onSuccess: (idEliminado) => { onSuccess: (_idEliminado) => {
// Invalidamos para que las materias y líneas se refresquen // Invalidamos para que las materias y líneas se refresquen
qc.invalidateQueries({ queryKey: ['plan_lineas'] }) qc.invalidateQueries({ queryKey: ['plan_lineas'] })
qc.invalidateQueries({ queryKey: ['plan_asignaturas'] }) qc.invalidateQueries({ queryKey: ['plan_asignaturas'] })

View File

@@ -23,10 +23,10 @@ import { qk } from '../query/keys'
import type { import type {
BibliografiaUpsertInput, BibliografiaUpsertInput,
SubjectsCreateManualInput,
SubjectsUpdateFieldsPatch, SubjectsUpdateFieldsPatch,
} from '../api/subjects.api' } from '../api/subjects.api'
import type { UUID } from '../types/domain' import type { UUID } from '../types/domain'
import type { TablesInsert } from '@/types/supabase'
export function useSubject(subjectId: UUID | null | undefined) { export function useSubject(subjectId: UUID | null | undefined) {
return useQuery({ return useQuery({
@@ -82,7 +82,7 @@ export function useCreateSubjectManual() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (payload: SubjectsCreateManualInput) => mutationFn: (payload: TablesInsert<'asignaturas'>) =>
subjects_create_manual(payload), subjects_create_manual(payload),
onSuccess: (subject) => { onSuccess: (subject) => {
qc.setQueryData(qk.asignatura(subject.id), subject) qc.setQueryData(qk.asignatura(subject.id), subject)

View File

@@ -1,4 +1,3 @@
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,
@@ -9,7 +8,7 @@ import {
BookOpen, BookOpen,
Loader2, Loader2,
} from 'lucide-react' } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { 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 type { Tables } from '@/types/supabase'
@@ -32,14 +31,18 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { qk, supabaseBrowser, usePlanAsignaturas, usePlanLineas } from '@/data' import { 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' }, generando: {
label: 'Generando',
className:
'bg-slate-100 text-slate-600 animate-pulse [animation-duration:2s]',
},
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' },
@@ -82,38 +85,12 @@ 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')