Merge pull request 'Clonacion de asignaturas a partir del sistema' (#227) from issue/212-nueva-asignatura-clonada-desde-el-sistema into main
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m21s

Reviewed-on: #227
This commit was merged in pull request #227.
This commit is contained in:
2026-03-25 22:46:57 +00:00
12 changed files with 920 additions and 16 deletions
+2 -2
View File
@@ -41,7 +41,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0",
@@ -1327,7 +1327,7 @@
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
+1 -1
View File
@@ -54,7 +54,7 @@
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.0.6",
"tw-animate-css": "^1.3.6",
"use-debounce": "^10.1.0",
@@ -0,0 +1,93 @@
import * as Icons from 'lucide-react'
import { useEffect, useRef } from 'react'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useSubject } from '@/data'
export function PasoBasicosClonadoInterno({
wizard,
onChange,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
const sourceId = wizard.clonInterno?.asignaturaOrigenId ?? null
const { data: source, isLoading, isError } = useSubject(sourceId)
const lastAppliedRef = useRef<string | null>(null)
useEffect(() => {
if (!source) return
if (lastAppliedRef.current === source.id) return
lastAppliedRef.current = source.id
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
nombre: source.nombre,
codigo: source.codigo ?? '',
tipo: (source.tipo as any) ?? null,
creditos: source.creditos,
horasAcademicas: (source as any).horas_academicas ?? null,
horasIndependientes: (source as any).horas_independientes ?? null,
estructuraId: (source.estructura_id ??
w.datosBasicos.estructuraId) as any,
},
}))
}, [onChange, source])
if (!sourceId) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Datos básicos</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
Selecciona una asignatura fuente para continuar.
</CardContent>
</Card>
)
}
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Datos básicos</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
Cargando información de la asignatura fuente
</CardContent>
</Card>
)
}
if (isError || !source) {
return (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive flex items-center gap-2 text-base">
<Icons.AlertTriangle className="h-5 w-5" />
No se pudo cargar la fuente
</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
Intenta seleccionar otra asignatura.
</CardContent>
</Card>
)
}
return (
<PasoBasicosForm
wizard={wizard}
onChange={onChange}
estructuraFuenteId={source.estructura_id ?? null}
/>
)
}
@@ -1,3 +1,4 @@
import * as Icons from 'lucide-react'
import { useEffect, useState } from 'react'
import PasoSugerenciasForm from './PasoSugerenciasForm'
@@ -21,9 +22,11 @@ import { cn } from '@/lib/utils'
export function PasoBasicosForm({
wizard,
onChange,
estructuraFuenteId,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
estructuraFuenteId?: string | null
}) {
const { data: estructuras } = useSubjectEstructuras()
@@ -258,6 +261,17 @@ export function PasoBasicosForm({
)}
</SelectContent>
</Select>
{estructuraFuenteId &&
wizard.datosBasicos.estructuraId &&
wizard.datosBasicos.estructuraId !== estructuraFuenteId ? (
<div className="border-destructive/40 bg-destructive/5 text-destructive flex items-start gap-2 rounded-md border p-2 text-xs">
<Icons.AlertTriangle className="mt-0.5 h-4 w-4 flex-none" />
<span>
Es posible que se pierdan datos generales al seleccionar otra
estructura.
</span>
</div>
) : null}
<p className="text-muted-foreground text-xs">
Define los campos requeridos (ej. Objetivos, Temario, Evaluación).
</p>
@@ -0,0 +1,359 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import * as Icons from 'lucide-react'
import { useEffect, useMemo } from 'react'
import { useDebounce } from 'use-debounce'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import type { Database } from '@/types/supabase'
import Pagination03 from '@/components/shadcn-studio/pagination/pagination-03'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { supabaseBrowser, useCatalogosPlanes, usePlanes } from '@/data'
import { cn } from '@/lib/utils'
type SourceSubjectRow = {
id: string
nombre: string
codigo: string | null
creditos: number
tipo: any
plan_estudio_id: string
estructura_id: string | null
rank?: number
}
const ALL = '__all__'
type SearchAsignaturasRow =
Database['public']['Functions']['search_asignaturas']['Returns'][number]
export function PasoFuenteClonadoInterno({
wizard,
onChange,
}: {
wizard: NewSubjectWizardState
onChange: React.Dispatch<React.SetStateAction<NewSubjectWizardState>>
}) {
const pageSize = 20
const facultadId = wizard.clonInterno?.facultadId ?? null
const carreraId = wizard.clonInterno?.carreraId ?? null
const planOrigenId = wizard.clonInterno?.planOrigenId ?? null
const search = wizard.clonInterno?.search ?? ''
const page = Math.max(1, wizard.clonInterno?.page ?? 1)
const [debouncedSearch] = useDebounce(search, 350)
const { data: catalogos } = useCatalogosPlanes()
const carrerasOptions = useMemo(() => {
const raw = catalogos?.carreras ?? []
return facultadId ? raw.filter((c) => c.facultad_id === facultadId) : raw
}, [catalogos?.carreras, facultadId])
const planesQuery = usePlanes({
search: '',
facultadId: facultadId ?? 'todas',
carreraId: carreraId ?? 'todas',
estadoId: 'todos',
limit: 500,
offset: 0,
} as any)
const needPlansForFilter = Boolean((facultadId || carreraId) && !planOrigenId)
const plansForFilter = planesQuery.data?.data ?? []
const { data: subjectsPaged, isLoading: subjectsLoading } = useQuery({
queryKey: [
'asignaturas',
'clone-source',
{
facultadId,
carreraId,
planOrigenId,
search: debouncedSearch,
page,
pageSize,
planIdsKey: needPlansForFilter
? plansForFilter.map((p) => p.id).join(',')
: null,
},
],
enabled: !needPlansForFilter || !planesQuery.isLoading,
placeholderData: keepPreviousData,
queryFn: async () => {
const supabase = supabaseBrowser()
const from = (page - 1) * pageSize
const term = debouncedSearch.trim()
// Una sola llamada limpia. RPC maneja tanto búsqueda vacía como llena.
const { data, error } = await supabase.rpc('search_asignaturas', {
p_search: term,
p_facultad_id: facultadId ?? undefined, // Corrección TS 2322
p_carrera_id: carreraId ?? undefined, // Corrección TS 2322
p_plan_estudio_id: planOrigenId ?? undefined, // Corrección TS 2322
p_limit: pageSize,
p_offset: from,
})
if (error) throw new Error(error.message)
// Extraemos el conteo total de la primera fila.
// ESLint feliz: "data" ya no puede ser null a este punto.
const count = data.length > 0 ? Number(data[0].total_count) : 0
return {
// ESLint feliz: map directo sobre data
data: data.map((r) => ({
id: r.id,
nombre: r.nombre,
codigo: r.codigo,
creditos: Number(r.creditos),
tipo: r.tipo,
plan_estudio_id: r.plan_estudio_id,
estructura_id: null,
rank: r.rank,
})),
count,
}
},
})
const subjects = subjectsPaged?.data ?? []
const total = subjectsPaged?.count ?? 0
const pageCount = Math.max(1, Math.ceil(total / pageSize))
useEffect(() => {
// clamp page if results shrink
if (page > pageCount) {
onChange((w) => ({
...w,
clonInterno: { ...(w.clonInterno ?? {}), page: pageCount },
}))
}
}, [onChange, page, pageCount])
const patchClonInterno = (
patch: Partial<NonNullable<NewSubjectWizardState['clonInterno']>>,
) =>
onChange((w) => ({
...w,
clonInterno: { ...(w.clonInterno ?? {}), ...patch },
}))
const hasAnyFilter = Boolean(
facultadId || carreraId || planOrigenId || search.trim().length,
)
const clearDisabled = !hasAnyFilter
const selectedId = wizard.clonInterno?.asignaturaOrigenId ?? null
const resetSelection = () => patchClonInterno({ asignaturaOrigenId: null })
return (
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Fuente</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="grid gap-1">
<Label>Facultad</Label>
<Select
value={facultadId ?? ALL}
onValueChange={(val) => {
const next = val === ALL ? null : val
patchClonInterno({
facultadId: next,
carreraId: null,
planOrigenId: null,
page: 1,
})
resetSelection()
}}
>
<SelectTrigger className="w-full min-w-0 [&>span]:block! [&>span]:truncate!">
<SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>Todas</SelectItem>
{(catalogos?.facultades ?? []).map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label>Carrera</Label>
<Select
value={carreraId ?? ALL}
onValueChange={(val) => {
const next = val === ALL ? null : val
patchClonInterno({
carreraId: next,
planOrigenId: null,
page: 1,
})
resetSelection()
}}
disabled={!facultadId}
>
<SelectTrigger className="w-full min-w-0 [&>span]:block! [&>span]:truncate!">
<SelectValue
placeholder={facultadId ? 'Todas' : 'Selecciona facultad'}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>Todas</SelectItem>
{carrerasOptions.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label>Plan</Label>
<Select
value={planOrigenId ?? ALL}
onValueChange={(val) => {
const next = val === ALL ? null : val
patchClonInterno({ planOrigenId: next, page: 1 })
resetSelection()
}}
>
<SelectTrigger
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
disabled={!carreraId && !facultadId}
>
<SelectValue placeholder="Todos" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>Todos</SelectItem>
{(planesQuery.data?.data ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
<div className="grid gap-1">
<Label>Buscar</Label>
<Input
placeholder="Nombre o código..."
value={search}
onChange={(e) =>
patchClonInterno({ search: e.target.value, page: 1 })
}
/>
</div>
<div className="flex items-end justify-end">
<Button
type="button"
variant="secondary"
onClick={() => {
patchClonInterno({
facultadId: null,
carreraId: null,
planOrigenId: null,
search: '',
page: 1,
asignaturaOrigenId: null,
})
}}
disabled={clearDisabled}
>
<Icons.X className="mr-2 h-4 w-4" />
Limpiar filtros
</Button>
</div>
</div>
</CardContent>
</Card>
<div className="grid gap-2">
<div className="text-muted-foreground text-xs">
Selecciona una asignatura fuente (solo una).
</div>
<div className="grid max-h-80 gap-2 overflow-y-auto">
{subjectsLoading ? (
<div className="text-muted-foreground text-sm">
Cargando asignaturas
</div>
) : subjects.length === 0 ? (
<div className="text-muted-foreground text-sm">
No hay asignaturas con esos filtros.
</div>
) : (
subjects.map((m) => {
const active = String(selectedId) === String(m.id)
return (
<label
key={m.id}
className={cn(
'hover:bg-accent flex cursor-pointer items-center justify-between rounded-md border p-3 text-left',
active && 'border-primary bg-primary/5 ring-primary ring-1',
)}
>
<input
className="sr-only"
type="radio"
name="asignaturaFuente"
checked={active}
onChange={() =>
patchClonInterno({ asignaturaOrigenId: m.id, page })
}
/>
<div className="min-w-0">
<div className="truncate font-medium">{m.nombre}</div>
<div className="text-muted-foreground mt-0.5 text-xs">
{(m.codigo ? m.codigo : '—') +
' • ' +
String(m.creditos) +
' créditos'}
</div>
</div>
{active ? (
<Icons.CheckCircle2 className="text-primary h-5 w-5 flex-none" />
) : (
<span className="h-5 w-5 flex-none" aria-hidden />
)}
</label>
)
})
)}
</div>
{pageCount > 1 ? (
<Pagination03
page={page}
pageCount={pageCount}
onPageChange={(nextPage) => patchClonInterno({ page: nextPage })}
/>
) : null}
</div>
</div>
)
}
@@ -15,6 +15,7 @@ import {
qk,
useCreateSubjectManual,
subjects_get_maybe,
subjects_get,
} from '@/data'
export function WizardControls({
@@ -201,6 +202,87 @@ export function WizardControls({
let startedWaiting = false
try {
if (wizard.tipoOrigen === 'CLONADO_INTERNO') {
if (!wizard.plan_estudio_id) {
throw new Error('Plan de estudio inválido.')
}
const asignaturaOrigenId = wizard.clonInterno?.asignaturaOrigenId
if (!asignaturaOrigenId) {
throw new Error('Selecciona una asignatura fuente.')
}
if (!wizard.datosBasicos.estructuraId) {
throw new Error('Estructura inválida.')
}
if (!wizard.datosBasicos.nombre.trim()) {
throw new Error('Nombre inválido.')
}
if (wizard.datosBasicos.tipo == null) {
throw new Error('Tipo inválido.')
}
if (wizard.datosBasicos.creditos == null) {
throw new Error('Créditos inválidos.')
}
const fuente = await subjects_get(asignaturaOrigenId as any)
const supabase = supabaseBrowser()
const codigo = (wizard.datosBasicos.codigo ?? '').trim()
const payload: TablesInsert<'asignaturas'> = {
plan_estudio_id: wizard.plan_estudio_id,
estructura_id: wizard.datosBasicos.estructuraId,
codigo: codigo ? codigo : null,
nombre: wizard.datosBasicos.nombre,
tipo: wizard.datosBasicos.tipo,
creditos: wizard.datosBasicos.creditos,
datos: (fuente as any).datos,
contenido_tematico: (fuente as any).contenido_tematico,
criterios_de_evaluacion: (fuente as any).criterios_de_evaluacion,
tipo_origen: 'CLONADO_INTERNO',
meta_origen: {
...(fuente as any).meta_origen,
asignatura_origen_id: fuente.id,
plan_origen_id: (fuente as any).plan_estudio_id,
},
horas_academicas:
wizard.datosBasicos.horasAcademicas ??
(fuente as any).horas_academicas ??
null,
horas_independientes:
wizard.datosBasicos.horasIndependientes ??
(fuente as any).horas_independientes ??
null,
}
console.log('payload:', payload)
const { data: inserted, error: insertError } = await supabase
.from('asignaturas')
.insert(payload)
.select('id,plan_estudio_id')
.single()
if (insertError) throw new Error(insertError.message)
qc.invalidateQueries({
queryKey: qk.planAsignaturas(wizard.plan_estudio_id),
})
qc.invalidateQueries({
queryKey: qk.planHistorial(wizard.plan_estudio_id),
})
navigate({
to: `/planes/${inserted.plan_estudio_id}/asignaturas/${inserted.id}`,
state: { showConfetti: true },
resetScroll: false,
})
return
}
if (wizard.tipoOrigen === 'IA_SIMPLE') {
if (!wizard.plan_estudio_id) {
throw new Error('Plan de estudio inválido.')
@@ -0,0 +1,128 @@
import { buttonVariants } from '@/components/ui/button'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
import { cn } from '@/lib/utils'
export type Pagination03Props = {
page: number
pageCount: number
onPageChange: (page: number) => void
className?: string
}
const toInt = (n: unknown, fallback: number) => {
const x = typeof n === 'number' ? n : Number(n)
return Number.isFinite(x) ? Math.floor(x) : fallback
}
function getPageItems(page: number, pageCount: number): Array<number | '...'> {
if (pageCount <= 7) {
return Array.from({ length: pageCount }, (_, i) => i + 1)
}
const items: Array<number | '...'> = []
const safePage = Math.min(Math.max(page, 1), pageCount)
items.push(1)
const start = Math.max(2, safePage - 1)
const end = Math.min(pageCount - 1, safePage + 1)
if (start > 2) items.push('...')
for (let p = start; p <= end; p++) items.push(p)
if (end < pageCount - 1) items.push('...')
items.push(pageCount)
return items
}
export function Pagination03({
page,
pageCount,
onPageChange,
className,
}: Pagination03Props) {
const safePageCount = Math.max(1, toInt(pageCount, 1))
const safePage = Math.min(Math.max(toInt(page, 1), 1), safePageCount)
const items = getPageItems(safePage, safePageCount)
const canPrev = safePage > 1
const canNext = safePage < safePageCount
const go = (p: number) => {
const next = Math.min(Math.max(p, 1), safePageCount)
if (next === safePage) return
onPageChange(next)
}
return (
<Pagination className={className}>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
className={cn(!canPrev && 'pointer-events-none opacity-50')}
onClick={(e) => {
e.preventDefault()
if (!canPrev) return
go(safePage - 1)
}}
/>
</PaginationItem>
{items.map((it, idx) =>
it === '...' ? (
<PaginationItem key={`ellipsis-${idx}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={it}>
<PaginationLink
href="#"
isActive={it === safePage}
onClick={(e) => {
e.preventDefault()
go(it)
}}
className={
it === safePage
? cn(
buttonVariants({
variant: 'default',
size: 'icon',
}),
'hover:text-primary-foreground! dark:bg-primary dark:text-primary-foreground dark:hover:text-primary-foreground dark:hover:bg-primary/90 shadow-none! dark:border-transparent',
)
: undefined
}
>
{it}
</PaginationLink>
</PaginationItem>
),
)}
<PaginationItem>
<PaginationNext
href="#"
className={cn(!canNext && 'pointer-events-none opacity-50')}
onClick={(e) => {
e.preventDefault()
if (!canNext) return
go(safePage + 1)
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}
export default Pagination03
+127
View File
@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants, type Button } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}
+37 -1
View File
@@ -52,6 +52,35 @@ const cleanText = (text: string) => {
.toLowerCase()
}
const recalculoVectoresAsignaturasInFlight = new Set<string>()
function triggerRecalculoVectoresAsignaturasNonBlocking(
supabase: ReturnType<typeof supabaseBrowser>,
planId: UUID,
) {
const key = String(planId)
if (recalculoVectoresAsignaturasInFlight.has(key)) return
recalculoVectoresAsignaturasInFlight.add(key)
void (async () => {
const { error } = await supabase.rpc('recalcular_vectores_asignaturas')
if (error) {
// No debe bloquear ni romper el flujo principal.
console.warn(
'[recalcular_vectores_asignaturas] RPC error:',
error.message,
)
}
})()
.catch((err: unknown) => {
console.warn('[recalcular_vectores_asignaturas] RPC failed:', err)
})
.finally(() => {
recalculoVectoresAsignaturasInFlight.delete(key)
})
}
export async function plans_list(
filters: PlanListFilters = {},
): Promise<Paged<PlanEstudio>> {
@@ -207,7 +236,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,estado,datos,contenido_tematico,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,prerrequisito_asignatura_id',
'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,criterios_de_evaluacion,asignatura_hash,tipo_origen,meta_origen,creado_por,actualizado_por,creado_en,actualizado_en,prerrequisito_asignatura_id,search_vector',
)
.eq('plan_estudio_id', planId)
.order('numero_ciclo', { ascending: true, nullsFirst: false })
@@ -215,6 +244,13 @@ export async function plan_asignaturas_list(
.order('nombre', { ascending: true })
throwIfError(error)
// No bloqueante: si el primer registro viene sin vector, dispara el recalculo.
const first: any = (data as any)?.[0]
if (first && first.search_vector === null) {
triggerRecalculoVectoresAsignaturasNonBlocking(supabase, planId)
}
return data ?? []
}
@@ -3,8 +3,10 @@ import * as Icons from 'lucide-react'
import { useNuevaAsignaturaWizard } from './hooks/useNuevaAsignaturaWizard'
import { PasoBasicosClonadoInterno } from '@/components/asignaturas/wizard/PasoBasicosClonadoInterno.tsx'
import { PasoBasicosForm } from '@/components/asignaturas/wizard/PasoBasicosForm/PasoBasicosForm'
import { PasoDetallesPanel } from '@/components/asignaturas/wizard/PasoDetallesPanel'
import { PasoFuenteClonadoInterno } from '@/components/asignaturas/wizard/PasoFuenteClonadoInterno.tsx'
import { PasoMetodoCardGroup } from '@/components/asignaturas/wizard/PasoMetodoCardGroup'
import { PasoResumenCard } from '@/components/asignaturas/wizard/PasoResumenCard'
import { WizardControls } from '@/components/asignaturas/wizard/WizardControls'
@@ -63,7 +65,12 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
basicos: 'Sugerencias',
detalles: 'Estructura',
}
: undefined
: wizard.tipoOrigen === 'CLONADO_INTERNO'
? {
basicos: 'Fuente',
detalles: 'Datos básicos',
}
: undefined
const handleClose = () => {
navigate({ to: `/planes/${planId}/asignaturas`, resetScroll: false })
@@ -99,6 +106,21 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
>
{({ methods }) => {
const idx = Wizard.utils.getIndex(methods.current.id)
const stepId = methods.current.id
const disableNext =
wizard.isLoading ||
(stepId === 'metodo'
? !canContinueDesdeMetodo
: stepId === 'basicos'
? wizard.tipoOrigen === 'CLONADO_INTERNO'
? !canContinueDesdeDetalles
: !canContinueDesdeBasicos
: stepId === 'detalles'
? wizard.tipoOrigen === 'CLONADO_INTERNO'
? !canContinueDesdeBasicos
: !canContinueDesdeDetalles
: false)
return (
<WizardLayout
@@ -118,12 +140,7 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
onPrev={() => methods.prev()}
onNext={() => methods.next()}
disablePrev={idx === 0 || wizard.isLoading}
disableNext={
wizard.isLoading ||
(idx === 0 && !canContinueDesdeMetodo) ||
(idx === 1 && !canContinueDesdeBasicos) ||
(idx === 2 && !canContinueDesdeDetalles)
}
disableNext={disableNext}
disableCreate={wizard.isLoading}
isLastStep={idx >= Wizard.steps.length - 1}
wizard={wizard}
@@ -141,13 +158,27 @@ export function NuevaAsignaturaModalContainer({ planId }: { planId: string }) {
{idx === 1 && (
<Wizard.Stepper.Panel>
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
{wizard.tipoOrigen === 'CLONADO_INTERNO' ? (
<PasoFuenteClonadoInterno
wizard={wizard}
onChange={setWizard}
/>
) : (
<PasoBasicosForm wizard={wizard} onChange={setWizard} />
)}
</Wizard.Stepper.Panel>
)}
{idx === 2 && (
<Wizard.Stepper.Panel>
<PasoDetallesPanel wizard={wizard} onChange={setWizard} />
{wizard.tipoOrigen === 'CLONADO_INTERNO' ? (
<PasoBasicosClonadoInterno
wizard={wizard}
onChange={setWizard}
/>
) : (
<PasoDetallesPanel wizard={wizard} onChange={setWizard} />
)}
</Wizard.Stepper.Panel>
)}
+5 -3
View File
@@ -50,10 +50,12 @@ export type NewSubjectWizardState = {
}
sugerencias: Array<AsignaturaSugerida>
clonInterno?: {
facultadId?: string
carreraId?: string
planOrigenId?: string
facultadId?: string | null
carreraId?: string | null
planOrigenId?: string | null
asignaturaOrigenId?: string | null
search?: string
page?: number
}
clonTradicional?: {
archivoWordAsignaturaId: string | null
+32
View File
@@ -155,6 +155,7 @@ export type Database = {
orden_celda: number | null
plan_estudio_id: string
prerrequisito_asignatura_id: string | null
search_vector: unknown
tipo: Database['public']['Enums']['tipo_asignatura']
tipo_origen: Database['public']['Enums']['tipo_origen'] | null
}
@@ -181,6 +182,7 @@ export type Database = {
orden_celda?: number | null
plan_estudio_id: string
prerrequisito_asignatura_id?: string | null
search_vector?: unknown
tipo?: Database['public']['Enums']['tipo_asignatura']
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
}
@@ -207,6 +209,7 @@ export type Database = {
orden_celda?: number | null
plan_estudio_id?: string
prerrequisito_asignatura_id?: string | null
search_vector?: unknown
tipo?: Database['public']['Enums']['tipo_asignatura']
tipo_origen?: Database['public']['Enums']['tipo_origen'] | null
}
@@ -1393,6 +1396,35 @@ export type Database = {
Args: { p_append: Json; p_id: string }
Returns: undefined
}
build_asignaturas_prefix_tsquery: {
Args: { p_search: string }
Returns: unknown
}
recalcular_vectores_asignaturas: { Args: never; Returns: undefined }
search_asignaturas: {
Args: {
p_carrera_id?: string
p_facultad_id?: string
p_limit?: number
p_offset?: number
p_plan_estudio_id?: string
p_search?: string
}
Returns: Array<{
codigo: string
contenido_tematico: Json
creditos: number
datos: Json
estado: Database['public']['Enums']['estado_asignatura']
id: string
nombre: string
numero_ciclo: number
plan_estudio_id: string
rank: number
tipo: Database['public']['Enums']['tipo_asignatura']
total_count: number
}>
}
suma_porcentajes: { Args: { '': Json }; Returns: number }
unaccent: { Args: { '': string }; Returns: string }
unaccent_immutable: { Args: { '': string }; Returns: string }